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

fix(plugins): notify only changed diagnostics and remove stale probe cleanup

Yichen Jiang 1 месяц назад
Родитель
Сommit
eaecfd59bf

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
-2026-09-04-plugin-manager-over-the-profile-runtime.md: 0d101b4c134bdf779d640275d5dea267f920a96b
-2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 582aca7d0b7395860d7dcd1277ad97b48d9b08db
+2026-09-04-plugin-manager-over-the-profile-runtime.md: e5b3e697e56ffe008556bb112565cd0f83941ee7
+2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 4661241c23210b09e483c558348054fa81e84677

+ 13 - 15
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md

@@ -4,40 +4,38 @@ Status: implemented
 
 
 English | [中文](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)
 English | [中文](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)
 
 
-Enablement transactions, contained failure records and execution probes in this record are superseded by [native entry diagnostics and static declarations](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md). The manager/Remote separation, pnpm process choice, mutation exclusion and patch-file ownership remain applicable.
-
 ## Problem
 ## Problem
 
 
-Installing a plugin was a terminal-only act: `dsh plugin --profile web add <spec>` ran pnpm, appended every bundle it found to `dsh.profile.bundles`, and the next start composed it. Nothing running could learn what was installed but not enabled, switch a bundle off without editing `package.json` by hand, add one package's module to the profile's user layer or to one agent preset, or say which rows a package's service kept alive. The Web surface could list rows through `pluginInventory/list` and nothing more, while the launcher's `profileRuntime` (previous note) already recomposed the tree on a user patch reload and `reconcileInstalledBundles` already separated installation from enablement. The missing piece was the host service that performs the operations and reports each package as one thing.
+The CLI and Web need the same installation checks, while only a running application can apply bundle changes or inspect active rows. Keeping both responsibilities in the Web host would make the CLI depend on that host or duplicate its installer. Profile manifests, user patches and pnpm operations also need coordinated mutation so concurrent requests cannot overwrite each other's intent.
 
 
 ## Decision
 ## Decision
 
 
-**One manager, one Remote.** `dsh-plugin-manager`, in the boot group beside `app-boot`, owns what plugin management does: `PluginInstaller` needs only the profile on disk (pnpm runs, the probe, the post-install checks) and `PluginManager` adds every operation over the booted tree, each refusal a `PluginOperationError` with a `plugins/*` code. `dsh-host-plugin-manager` provides `pluginManager` and the `plugins` Remote — `list`, `add`, `uninstall`, `enable`, `disable`, `retry`, `addRow`, `removeRow`, `setRowDisabled`, `dependents` — as a relay: one Remote method per manager method, and one exhaustive switch turning a failure into the Remote error of the same code (`plugins/bad-request` into the Gateway's `gateway/bad-request`). The manager takes the profile runtime, the preset roster, and the running-agent count as readers called per call, and the roster only as `PresetLayers` (layer path, preset list, composition rows), so it depends on neither the roster nor the agent registry; `dsh plugin add` and `remove` run the same installer, so the CLI and the Web host share one admission rule without booting a profile in the terminal. Every operation reads the profile manifest afresh and writes it through the same app-boot helpers the CLI uses — `reconcileInstalledBundles`, `enableBundle`, `disableBundle` — so the CLI and the manager cannot disagree on the file: `dependencies` says what is installed, `dsh.profile.bundles` says what is enabled. The profile runtime is resolved per call rather than injected, so the web bundle's row starts in a composition booted without the profile launcher and answers `plugins/unavailable`.
+**One business manager, one Remote adapter.** `dsh-plugin-manager` belongs beside app-boot. `PluginInstaller` operates on profile files and pnpm without a running Loader; `PluginManager` adds bundle and row operations over the live profile. The CLI shares the installer. `dsh-host-plugin-manager` supplies per-call readers for the profile runtime, preset layers and running-agent count, relays methods, and maps `PluginOperationError` codes to Remote errors. Missing profile runtime reports `plugins/unavailable` when called. The manager depends on the preset-layer interface, not the preset or agent implementations.
 
 
-**Enablement is the Loader's transaction.** `enable` puts the bundle in the layer list and calls `profileRuntime.recompose({ reloadBundles: true })`, after `healProfilesModuleFallback` has linked the packages the bundle carries. A rejected recomposition — a `boot`-stage bundle whose row throws — is the Loader rolling back to the tree that was running; the manager restores the list and reports `plugins/enable-failed`. A `runtime`-stage bundle whose row fails is isolated by the contained group and reported per row. Because the boot audit does not run again, the manager calls `recordContainedStates` after a live recomposition, and `ContainedGroup.create` now records a row that resolved in the pending state instead of clearing it — a reload re-creates every row of a group, and a waiting row must keep its record through that. `retry` is disable then enable: the Loader's update leaves an unchanged row alone, so only leaving and returning restarts a failed isolated row.
+**One mutation at a time.** An overlapping mutation receives `plugins/busy` rather than entering a queue with stale assumptions. Installation and removal reject with `plugins/agents-running` while an agent is running because pnpm rewrites modules those agents import. Bundle and row edits leave `node_modules` alone and do not use that guard. Every operation reads the profile manifest afresh: dependencies record installation, and `dsh.profile.bundles` records enablement.
 
 
-**pnpm runs the way the CLI runs it.** Through `node:child_process` with the parent environment and `shell` on Windows, not through the subprocess seam: the seam scrubs secret-shaped variables pnpm needs for registries and proxies and has no shell mode for the `.cmd` shim. Output streams as `plugins/install-log` chunks under a job id, each naming the command line and the profile directory, with pnpm's colours kept for the Web dialog's terminal and stripped for a CLI whose stdout is not a terminal; a non-zero exit, a spawn error, or the timeout is `plugins/install-failed` with the log tail. New packages are probed and left disabled unless the caller asked for `enable`.
+**pnpm retains its normal launch environment.** The installer uses `node:child_process` and Windows shell handling for the `.cmd` shim. Registry credentials and proxy configuration remain available. Output streams as `plugins/install-log` under a job id, with terminal colours when requested; spawn failures, timeouts and nonzero exits report `plugins/install-failed` with a bounded log tail. A failed add restores the saved manifest. Static declaration and bundle-conflict checks follow installation; unknown packages remain installed, and new bundles remain disabled unless the caller requests enablement. No discovery-cache directory is owned.
 
 
-**A successful `pnpm add` is not an installed plugin.** The manifest is snapshotted before the run and restored when pnpm fails, so a failed add leaves no dependency behind. Each package pnpm added is then judged: one that declares neither a bundle nor a plugin module, or a bundle whose row id a composed layer already owns (`claimLayerIds` over the current layers plus the candidate), is removed again with `pnpm remove` and reported under `removed` with its reason; a package the probe refused stays, because the view can explain it and `retry` can try again. The manager runs one mutation at a time and refuses a second with `plugins/busy` rather than queueing it — every write races on the manifest, the user layers, or `node_modules` otherwise — and `install` and `uninstall` refuse with `plugins/agents-running` while any agent is running, because pnpm rewrites the directory those sessions import from. The three guards follow the community `dshmarket` manager, which learned each of them from a bug.
+**Enablement selects a layer; diagnostics describe actual rows.** Bundle enablement, nontransactional recomposition and explicit `dsh.plugins` discovery follow [native entry diagnostics and static declarations](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md). The manager keeps per-row issues distinct from the enabled choice. `retry` removes the complete layer, awaits cleanup, and adds it again; unchanged row options alone do not restart a failed plugin. `list` derives each package view from the manifest, static declarations and current Loader state.
 
 
-**Rows go through the patch-file writer.** `addRow` inserts `{ id, name, config }` into the profile's `cordis.patch.yml` or an agent preset's user layer (through the roster's `overlayPathFor`), with the id derived from the package name and subpath; `setRowDisabled` is deny-only, writing or removing `disabled: true` so a bundle's `!!js` gate is restored rather than overridden. The global layer is recomposed on the spot; a preset's layer reaches its next standing generation.
+**Rows go through the patch-file writer.** `addRow` inserts the declared module and config into the profile user patch or the preset overlay returned by its roster. `setRowDisabled` writes or removes `disabled: true`, restoring the author's original gate when the user withdraws a denial. Global changes recompose the live tree; preset changes affect subsequent standing generations.
 
 
-**One view per package.** `list` folds the manifest, the probe record (cached under `.dsh-plugins/`, refreshed on a version change), and the live tree into a `status`: `running`, `partial`, or `failed` by active rows; `disabled`; `not-enableable` with the probe's reason; `restart-required` when a `startup`-reload profile's manifest and tree disagree; `plain` for a library or plugin module. Rows come from the tree while composed and from the probe otherwise, already carrying the prefixed ids the launcher will use; trust for a bundle outside the tree comes from `layerTrust`, the one rule `loadProfile` also applies.
+**Runtime notifications follow diagnostics.** Native entry and fiber events share one pending reader. It waits for Loader and profile recomposition, then compares row identity, module, fiber phase, failure stage and message. Only a changed diagnostic set emits `plugins/changed` with reason `runtime`; healthy status churn and repeated identical failures stay silent. Management operations retain their own completion notifications. Events arriving during a read request another pass, and disposing the adapter cancels publication.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 
-**Running pnpm through `ctx.subprocess`.** Rejected for this release: the seam has no shell mode and scrubs the environment; adding both to the seam for one caller is a larger change than the manager, and the CLI's spawn is proven.
+**Run pnpm through `ctx.subprocess`.** Its implicit environment scrub removes registry credentials, and it lacks the Windows shell mode this caller needs. Extending the tool subprocess service for package installation would enlarge an unrelated interface.
 
 
-**Restarting a failed row in place on `retry`.** Rejected: a row's options are unchanged, so the Loader's transactional update would not touch it; re-creating one row by hand would bypass the group's own create path and its failure record.
+**Restart one failed row directly.** This bypasses whole-layer composition and does not account for the bundle's groups and overrides. Removing and adding the layer gives its Loader entries a complete lifecycle.
 
 
-**Enabling a bundle without probing it.** Rejected: the probe is what turns a package that cannot import, or that resolves its own cordis copy, into a `not-enableable` view with a reason before the tree is asked to mount it.
+**Keep business operations in the Web host or app-boot entry file.** The host would become a CLI dependency; putting installation, streaming and dependency inspection in the boot entry would couple every launcher to management implementation. A separate boot-group package serves both consumers.
 
 
-**Keeping the operations inside the host package.** Rejected after review: `host/` is the web GUI's half, so the `dsh plugin` command could reuse the install path only by depending on a Web host package, and it kept its own pnpm forwarder without the post-install checks instead. The one-file-in-`app-boot` variant was rejected too: every `dsh` surface loads `app-boot` at start, and a thousand lines of pnpm streaming, probing, and dependents belong beside it, not inside it.
+**Broadcast after every fiber transition.** Most transitions do not change a plugin diagnostic. Unconditional notifications make clients reread package files and rerender during unrelated work. Diagnostic comparison preserves failure discovery and recovery notifications; explicit management operations already report their own changes.
 
 
 ## Consequences
 ## Consequences
 
 
-A running Web host can install, enable, disable, retry, and remove third-party bundles and add their modules to the global layer or a preset without a restart on a live profile. Updating a loaded package still needs a restart (Node's module cache); `dependents` stops at injection edges; `engines.dsh` is reported, not enforced; the client UI arrives in a later PR. An enable, disable, or row edit is not guarded by running sessions: it recomposes the tree, which is the Loader's transaction, and leaves `node_modules` alone.
+Live profiles support bundle and row changes without restarting. Updated module code still requires a restart because Node caches ESM modules. `dependents` reports injection relationships, not arbitrary application dependencies, and `engines.dsh` is advisory. Runtime notifications do not promise to describe every healthy configuration-file change; they report diagnostic changes, while operation notifications report management mutations.
 
 
 ## Testing
 ## Testing
 
 
-`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` covers manager operations over real profile files and a native Loader tree. The Host adapter tests cover direct delegation, error codes and dependency-recovery notifications; `apps/cli/tests/plugin.spec.ts` covers the shared installer. Lifecycle and static-discovery verification follows the superseding note.
+Manager integration tests use real profile files and a native Loader tree; CLI tests exercise the shared installer. Adapter tests cover Remote forwarding, failure changes and recovery, unchanged-diagnostic suppression, one pending read during event bursts, profile-recomposition barriers, and disposal during settlement.

+ 19 - 21
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md

@@ -1,43 +1,41 @@
-# Agent Note:插件管理器驱动 profile runtime
+# Agent Note: 插件管理器驱动 profile runtime
 
 
 Status: implemented
 Status: implemented
 
 
 [English](2026-09-04-plugin-manager-over-the-profile-runtime.md) | 中文
 [English](2026-09-04-plugin-manager-over-the-profile-runtime.md) | 中文
 
 
-本文中的启用事务、contained 失败记录和执行 probe 已由[原生条目诊断与静态声明](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md)取代。管理器与 Remote 的拆分、pnpm 进程选择、修改互斥和 patch 文件归属仍然适用。
+## Problem
 
 
-## 问题
+CLI 与 Web 需要相同的安装检查,而只有运行中的应用才能应用组合包变更或检查活跃行。把两类职责都放在 Web 宿主会迫使 CLI 依赖该宿主或重复实现安装器。Profile 清单、用户 patch 和 pnpm 操作也需要协调修改,避免并发请求覆盖彼此的意图。
 
 
-安装插件曾是只有终端能做的事:`dsh plugin --profile web add <spec>` 运行 pnpm,把找到的每个组合包追加进 `dsh.profile.bundles`,下次启动再组合。运行中的任何东西都无法得知哪些包装了但没启用,无法不手改 `package.json` 就关掉一个组合包,无法把某个包的模块加进 profile 的用户层或某个 agent preset,也说不出一个包的服务撑着哪些行。Web 界面能经 `pluginInventory/list` 列出行,仅此而已;而 launcher 的 `profileRuntime`(前一篇笔记)已经能在用户 patch 重载时重新组合树,`reconcileInstalledBundles` 也已经把安装与启用分开。缺的是执行这些操作并把每个包报告成一个整体的宿主服务。
+## Decision
 
 
-## 决定
+**一个业务管理器,一个 Remote 适配器。** `dsh-plugin-manager` 位于 app-boot 旁。`PluginInstaller` 操作 profile 文件和 pnpm,不需要运行中的 Loader;`PluginManager` 在其上增加面向运行中 profile 的组合包与行操作。CLI 共用安装器。`dsh-host-plugin-manager` 提供逐次调用的 profile runtime、预设层与运行中 agent 数读取器,转接方法并将 `PluginOperationError` 错误码映射为 Remote 错误。缺少 profile runtime 时在调用处报告 `plugins/unavailable`。管理器依赖预设层接口,不依赖预设或 agent 的实现。
 
 
-**一个管理器,一个 Remote。** boot 组里与 `app-boot` 并列的 `dsh-plugin-manager` 拥有插件管理做什么:`PluginInstaller` 只需要磁盘上的 profile(pnpm 运行、探针、装后检查),`PluginManager` 在其上加上已启动树的每项操作,每次拒绝都是带 `plugins/*` 码的 `PluginOperationError`。`dsh-host-plugin-manager` 提供 `pluginManager` 与 `plugins` Remote——`list`、`add`、`uninstall`、`enable`、`disable`、`retry`、`addRow`、`removeRow`、`setRowDisabled`、`dependents`——作为转接层:一个 Remote 方法对应一个管理器方法,一个穷尽的 switch 把失败转成同码的 Remote 错误(`plugins/bad-request` 转成 Gateway 的 `gateway/bad-request`)。管理器把 profile runtime、preset roster 与运行中 agent 数当作按调用读取的读取器接入,roster 只以 `PresetLayers`(层路径、preset 列表、组合行)的形态接入,因此既不依赖 roster 也不依赖 agent 注册表;`dsh plugin add` 与 `remove` 跑同一个安装器,CLI 与 Web 宿主共用一套准入规则,终端里不必启动 profile。每个操作都重新读取 profile manifest,并通过 CLI 所用的同一组 app-boot 助手——`reconcileInstalledBundles`、`enableBundle`、`disableBundle`——写回,因此 CLI 与管理器不可能对这个文件有分歧:`dependencies` 说装了什么,`dsh.profile.bundles` 说启用了什么。profile runtime 按调用解析而非注入,于是 web 组合包的这一行在不经 profile launcher 启动的组合里也能启动,并回答 `plugins/unavailable`。
+**同时只允许一个变更。** 重叠变更收到 `plugins/busy`,不进入可能携带过期假设的队列。有 agent 运行时,安装与移除以 `plugins/agents-running` 拒绝,因为 pnpm 会重写这些 agent 导入的模块。组合包与行编辑不改 `node_modules`,不使用这一限制。每次操作重新读取 profile 清单:依赖记录安装,`dsh.profile.bundles` 记录启用。
 
 
-**启用就是 Loader 的事务。** `enable` 把组合包放进层列表,在 `healProfilesModuleFallback` 链接好该组合包携带的包之后调用 `profileRuntime.recompose({ reloadBundles: true })`。被拒绝的重新组合——`boot` 阶段而行抛错的组合包——就是 Loader 回滚到原本运行的树;管理器恢复层列表并报告 `plugins/enable-failed`。`runtime` 阶段而行失败的组合包由受控组隔离并逐行报告。由于启动审计不会再跑一次,管理器在在线重新组合之后调用 `recordContainedStates`,而 `ContainedGroup.create` 现在把以 pending 状态完成创建的行记录下来而不是清除——重载会重新创建组里的每一行,等待中的行必须带着记录穿过这一过程。`retry` 是先停用再启用:Loader 的更新不碰未改变的行,只有离开再回来才能重启一条失败的隔离行。
+**pnpm 保留正常的启动环境。** 安装器使用 `node:child_process`,并在 Windows 上通过 shell 处理 `.cmd` 垫片。Registry 凭据与代理配置保持可用。输出以 job id 下的 `plugins/install-log` 流式发送,按请求保留终端颜色;启动失败、超时与非零退出以 `plugins/install-failed` 和有界日志尾部报告。add 失败时恢复保存的清单。安装后进行静态声明和组合包冲突检查;未知包保留安装,新组合包保持禁用,除非调用方请求启用。没有需要维护的发现缓存目录。
 
 
-**pnpm 按 CLI 的方式运行。** 经 `node:child_process`、带父进程环境、Windows 上开 `shell`,而不经 subprocess seam:seam 会清洗 pnpm 访问 registry 与代理所需的形似密钥的变量,也没有解析 `.cmd` shim 的 shell 模式。输出以某个 job id 下的 `plugins/install-log` 分块流式发出,每块写明命令行与 profile 目录,pnpm 的颜色为 Web 对话框的终端保留、对 stdout 不是终端的 CLI 去掉;非零退出、spawn 错误或超时即带日志尾部的 `plugins/install-failed`。新包被探测并保持停用,除非调用方要求 `enable`。
+**启用选择层,诊断描述实际行。** 组合包启用、非事务重组和显式 `dsh.plugins` 发现遵循[原生条目诊断与静态声明](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md)。管理器区分逐行问题与启用选择。`retry` 移除完整层,等待清理,再将其加入;仅保留不变的行选项不会重启失败插件。`list` 从清单、静态声明和当前 Loader 状态派生每个包的视图。
 
 
-**`pnpm add` 成功不等于装好了插件。** 运行前先给 manifest 拍快照,pnpm 失败时恢复,失败的 add 不会留下依赖。之后逐个裁决 pnpm 加进来的包:既不声明组合包也不声明插件模块的,或者行 id 已被已组合层占有的组合包(对当前各层加候选层跑 `claimLayerIds`),再以 `pnpm remove` 移除并连同原因报在 `removed` 里;探针拒绝的包保留,因为视图能解释它、`retry` 还能再试。管理器一次只跑一个变更,第二个以 `plugins/busy` 拒绝而不是排队——否则每次写入都会在 manifest、用户层或 `node_modules` 上竞争——`install` 与 `uninstall` 在任一 agent 运行时以 `plugins/agents-running` 拒绝,因为 pnpm 会重写那些会话正在 import 的目录。这三道守卫来自社区的 `dshmarket` 管理器,它每一条都是从一个 bug 学来的。
+**行通过 patch-file 写入器修改。** `addRow` 将声明的模块与配置插入 profile 用户 patch 或 roster 返回的预设覆盖层。`setRowDisabled` 写入或移除 `disabled: true`,用户撤销禁用时恢复作者原来的条件。全局修改重组运行中的树;预设修改影响后续常驻代际。
 
 
-**行经补丁文件写入器落地。** `addRow` 把 `{ id, name, config }` 插入 profile 的 `cordis.patch.yml` 或某个 agent preset 的用户层(经 roster 的 `overlayPathFor`),id 由包名与子路径派生;`setRowDisabled` 只写拒绝,写入或移除 `disabled: true`,因此组合包的 `!!js` 门被恢复而不是被覆盖。全局层当场重新组合;preset 的层在其下一个常驻代际生效。
+**运行时通知跟随诊断。** 原生条目与 fiber 事件共用一次待完成的读取。读取等待 Loader 和 profile 重组完成,再比较行身份、模块、fiber 阶段、失败阶段与消息。只有诊断集合变化才发送原因是 `runtime` 的 `plugins/changed`;健康状态波动和重复的相同失败保持安静。管理操作仍保留自己的完成通知。读取期间到来的事件请求再次读取,适配器销毁时取消发布。
 
 
-**每个包一份视图。** `list` 把 manifest、探针记录(缓存在 `.dsh-plugins/` 下,版本变化即刷新)与在线树折叠成一个 `status`:按活跃行数是 `running`、`partial` 或 `failed`;`disabled`;带探针原因的 `not-enableable`;`startup` 重载的 profile 上 manifest 与树不一致时是 `restart-required`;库或插件模块是 `plain`。行在已组合时来自树,否则来自探针,并已带上 launcher 将使用的前缀 id;树外组合包的 trust 来自 `layerTrust`,这也是 `loadProfile` 所用的同一条规则。
+## Alternatives considered
 
 
-## 考虑过的替代方案
+**通过 `ctx.subprocess` 运行 pnpm。** 它的隐式环境清洗会移除 registry 凭据,也缺少本调用方需要的 Windows shell 模式。为安装包而扩展工具子进程服务会扩大无关接口。
 
 
-**经 `ctx.subprocess` 运行 pnpm。** 本次否决:seam 没有 shell 模式且会清洗环境;为一个调用方给 seam 加上两者,比管理器本身改动更大,而 CLI 的 spawn 已被验证。
+**直接重启某个失败行。** 这会绕开整层组合,也无法顾及组合包中的 group 和覆盖。移除并重新加入层为其 Loader 条目提供完整生命周期。
 
 
-**`retry` 时原地重启失败的行。** 否决:行的 options 未变,Loader 的事务性更新不会碰它;手工重建一行会绕过组自己的创建路径及其失败记录。
+**把业务操作保留在 Web 宿主或 app-boot 入口文件。** 宿主会成为 CLI 依赖;把安装、流式输出与依赖查询放进 boot 入口则会让每个启动器耦合管理实现。独立的 boot 组包同时服务两类消费方。
 
 
-**不探测就启用组合包。** 否决:正是探针把无法 import、或解析到自己那份 cordis 副本的包,在树被要求挂载之前变成带原因的 `not-enableable` 视图。
+**每次 fiber 状态变化都广播。** 大部分变化不改变插件诊断。无条件通知会让客户端在无关工作期间重新读取包文件并渲染。比较诊断仍能报告失败发现与恢复;显式管理操作已经会报告自己的变更。
 
 
-**把操作留在宿主包里。** 评审后否决:`host/` 是 Web GUI 的那一半,`dsh plugin` 命令要复用安装路径就只能依赖一个 Web 宿主包,于是它一直保留着自己的、没有装后检查的 pnpm 转发器。放进 `app-boot` 一个文件的变体也被否决:每个 `dsh` 表面启动时都加载 `app-boot`,上千行的 pnpm 流式输出、探测与依赖检测应该与它并列,而不是塞进它。
+## Consequences
 
 
-## 后果
+实时 profile 支持不重启地修改组合包与行。模块代码更新仍需重启,因为 Node 会缓存 ESM 模块。`dependents` 报告注入关系,不报告任意应用依赖,`engines.dsh` 仅供参考。运行时通知不保证描述每一次健康的配置文件修改;它报告诊断变化,操作通知则报告管理变更。
 
 
-运行中的 Web 宿主可以在 live profile 上不重启地安装、启用、停用、重试与移除三方组合包,并把它们的模块加进全局层或某个 preset。更新已加载的包仍需重启(Node 的模块缓存);`dependents` 止于注入边;`engines.dsh` 只报告不强制;客户端 UI 在后续 PR 到来。启用、停用与行编辑不受运行中会话限制:它们重组的是树,那是 Loader 的事务,不碰 `node_modules`。
+## Testing
 
 
-## 测试
-
-`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` 覆盖实际 profile 文件与原生 Loader 树上的管理操作。Host 适配器测试覆盖直接转接、错误码与依赖恢复通知;`apps/cli/tests/plugin.spec.ts` 覆盖共用安装器。生命周期与静态发现的验证遵循取代这些机制的新笔记。
+管理器集成测试使用真实 profile 文件和原生 Loader 树;CLI 测试覆盖共用安装器。适配器测试覆盖 Remote 转接、失败变化与恢复、不变诊断不发通知、事件突发期间共用一次读取、profile 重组屏障以及等待期间的销毁。

+ 2 - 2
docs/subsystems/core.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: 0e96a41ec96b246fd104a9625783211753eac429
-core.zh.md: 1b02373d427e297763114ff9c34af2f957d84ab7
+core.md: 6f79778545f284068142a9d96fe0e118466db153
+core.zh.md: d81002498ac9db8d3f93f8a5bad784fb48accdc5

+ 1 - 1
docs/subsystems/core.md

@@ -944,7 +944,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
 @Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
 @Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
 
 
 /**
 /**
- * Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
+ * Remove a package from the profile with its user-layer rows.
  * @param packageName - the installed dependency to remove.
  * @param packageName - the installed dependency to remove.
  */
  */
 @Remote('uninstall') async uninstall(packageName: string): Promise<void>
 @Remote('uninstall') async uninstall(packageName: string): Promise<void>

+ 1 - 1
docs/subsystems/core.zh.md

@@ -954,7 +954,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
 @Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
 @Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
 
 
 /**
 /**
- * Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
+ * Remove a package from the profile with its user-layer rows.
  * @param packageName - the installed dependency to remove.
  * @param packageName - the installed dependency to remove.
  */
  */
 @Remote('uninstall') async uninstall(packageName: string): Promise<void>
 @Remote('uninstall') async uninstall(packageName: string): Promise<void>

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
 #   pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
-README.md: 490c0522eaf27136a5650cfdbe4fe672820948da
-README.zh.md: b31f461c8cdd83428f4e1107ad636d3658cee4d2
+README.md: 54f3eabaca809cd40c3b26810c3c21bcf835be09
+README.zh.md: 6a3ef96352586c5c015e99908179e94dd4d97779

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

@@ -74,7 +74,7 @@ console.log(await manager.list())
 
 
 `list` reports package identity, `bundle` / `plugin` / `unknown` classification, trust, stage, installation and enablement. Runtime rows carry actual phases and failures; disabled bundles show static patch declarations. `addable` comes only from `dsh.plugins`, including `.` for the main export and declared defaults. An active row can retain its previous config after an update fails. Package `issues` also names failed rows its patch overrides, without transferring their ownership.
 `list` reports package identity, `bundle` / `plugin` / `unknown` classification, trust, stage, installation and enablement. Runtime rows carry actual phases and failures; disabled bundles show static patch declarations. `addable` comes only from `dsh.plugins`, including `.` for the main export and declared defaults. An active row can retain its previous config after an update fails. Package `issues` also names failed rows its patch overrides, without transferring their ownership.
 
 
-`enable` selects the whole bundle layer and recomposes live profiles. Per-row failures retain the enabled choice and successful siblings; results report `issues`, and the list can show `partial` or `failed`. Preparation failures revert the enable selection and raise `plugins/enable-failed`. `disable` removes the whole layer, including overrides. `retry` disables, awaits cleanup, and enables again. Startup-only profiles report `effect: restart`. `uninstall` disables the bundle, removes user-inserted references, runs pnpm remove, and cleans obsolete discovery records.
+`enable` selects the whole bundle layer and recomposes live profiles. Per-row failures retain the enabled choice and successful siblings; results report `issues`, and the list can show `partial` or `failed`. Preparation failures revert the enable selection and raise `plugins/enable-failed`. `disable` removes the whole layer, including overrides. `retry` disables, awaits cleanup, and enables again. Startup-only profiles report `effect: restart`. `uninstall` disables the bundle, removes user-inserted references, and runs pnpm remove.
 
 
 `addRow` writes an explicitly declared `dsh.plugins` module to the profile’s global `cordis.patch.yml` or a preset user layer. It preserves declared defaults, checks the target row id, and performs no pre-mount import. `removeRow` removes a user insert. `setRowDisabled` writes or removes `disabled: true`, preserving the bundle’s own condition. Global edits recompose immediately on live profiles; preset edits apply to subsequent generations. `dependents` reports injection dependents and user-layer module references.
 `addRow` writes an explicitly declared `dsh.plugins` module to the profile’s global `cordis.patch.yml` or a preset user layer. It preserves declared defaults, checks the target row id, and performs no pre-mount import. `removeRow` removes a user insert. `setRowDisabled` writes or removes `disabled: true`, preserving the bundle’s own condition. Global edits recompose immediately on live profiles; preset edits apply to subsequent generations. `dependents` reports injection dependents and user-layer module references.
 
 

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

@@ -74,7 +74,7 @@ console.log(await manager.list())
 
 
 `list` 报告包身份、`bundle` / `plugin` / `unknown` 分类、trust、stage、安装与启用状态。运行行携带实际阶段与失败;禁用组合包显示静态 patch 声明。`addable` 只来自 `dsh.plugins`,包括表示主入口的 `.` 和声明的默认配置。行更新失败后,活跃实例可能保留先前配置。包的 `issues` 还报告其 patch 覆盖的失败行,但不转移这些行的归属。
 `list` 报告包身份、`bundle` / `plugin` / `unknown` 分类、trust、stage、安装与启用状态。运行行携带实际阶段与失败;禁用组合包显示静态 patch 声明。`addable` 只来自 `dsh.plugins`,包括表示主入口的 `.` 和声明的默认配置。行更新失败后,活跃实例可能保留先前配置。包的 `issues` 还报告其 patch 覆盖的失败行,但不转移这些行的归属。
 
 
-`enable` 选择整份组合包层,并在实时 profile 中重组。逐行失败保留启用选择与成功的其他行;结果报告 `issues`,列表可显示 `partial` 或 `failed`。准备失败会撤销启用选择,并抛出 `plugins/enable-failed`。`disable` 移除整层,包括覆盖。`retry` 先禁用并等待清理,再启用。仅启动时生效的 profile 报告 `effect: restart`。`uninstall` 禁用组合包、删除用户插入的引用、执行 pnpm remove 并清理过期发现记录。
+`enable` 选择整份组合包层,并在实时 profile 中重组。逐行失败保留启用选择与成功的其他行;结果报告 `issues`,列表可显示 `partial` 或 `failed`。准备失败会撤销启用选择,并抛出 `plugins/enable-failed`。`disable` 移除整层,包括覆盖。`retry` 先禁用并等待清理,再启用。仅启动时生效的 profile 报告 `effect: restart`。`uninstall` 禁用组合包、删除用户插入的引用、执行 pnpm remove。
 
 
 `addRow` 将显式声明的 `dsh.plugins` 模块写入 profile 的全局 `cordis.patch.yml` 或预设用户层。它保留声明的默认配置,检查目标行 id,不在挂载前 import。`removeRow` 删除用户插入。`setRowDisabled` 写入或删除 `disabled: true`,保留组合包自己的条件。全局编辑在实时 profile 中立即重组;预设编辑应用于后续代际。`dependents` 报告注入依赖方与用户层模块引用。
 `addRow` 将显式声明的 `dsh.plugins` 模块写入 profile 的全局 `cordis.patch.yml` 或预设用户层。它保留声明的默认配置,检查目标行 id,不在挂载前 import。`removeRow` 删除用户插入。`setRowDisabled` 写入或删除 `disabled: true`,保留组合包自己的条件。全局编辑在实时 profile 中立即重组;预设编辑应用于后续代际。`dependents` 报告注入依赖方与用户层模块引用。
 
 

+ 2 - 3
packages/boot/plugin-manager/src/installer.ts

@@ -7,7 +7,7 @@
 
 
 import { spawn as spawnChild } from 'node:child_process'
 import { spawn as spawnChild } from 'node:child_process'
 import { randomUUID } from 'node:crypto'
 import { randomUUID } from 'node:crypto'
-import { readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { readFileSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import {
 import {
   awaitChildClose,
   awaitChildClose,
@@ -189,7 +189,7 @@ export class PluginInstaller {
   }
   }
 
 
   /**
   /**
-   * Run `pnpm remove`, reconcile the layer list, and remove any obsolete discovery record.
+   * Run `pnpm remove` and reconcile the layer list.
    * @param packageName - the dependency to remove.
    * @param packageName - the dependency to remove.
    * @throws {PluginOperationError} `plugins/install-failed` when pnpm fails.
    * @throws {PluginOperationError} `plugins/install-failed` when pnpm fails.
    */
    */
@@ -198,7 +198,6 @@ export class PluginInstaller {
     const before = readProfileManifest(NAME, profileDir)
     const before = readProfileManifest(NAME, profileDir)
     await this.runPnpm(['remove', packageName], packageName)
     await this.runPnpm(['remove', packageName], packageName)
     reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
     reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
-    rmSync(join(profileDir, '.dsh-plugins', `${packageName.replaceAll('/', '__')}.json`), { force: true })
   }
   }
 
 
   /**
   /**

+ 1 - 1
packages/boot/plugin-manager/src/manager.ts

@@ -219,7 +219,7 @@ export class PluginManager {
 
 
   /**
   /**
    * Remove a package from the profile: disable it when enabled, drop every
    * Remove a package from the profile: disable it when enabled, drop every
-   * user-layer row that names it, run `pnpm remove`, and clean obsolete discovery metadata.
+   * user-layer row that names it, and run `pnpm remove`.
    * @param packageName - the installed dependency to remove.
    * @param packageName - the installed dependency to remove.
    * @throws {PluginOperationError} `plugins/not-installed`, `plugins/agents-running`,
    * @throws {PluginOperationError} `plugins/not-installed`, `plugins/agents-running`,
    * or `plugins/install-failed` when pnpm exits non-zero.
    * or `plugins/install-failed` when pnpm exits non-zero.

+ 0 - 5
packages/boot/plugin-manager/tests/plugin-manager.spec.ts

@@ -382,7 +382,6 @@ describe('PluginManager', () => {
       // Rows come from static declarations while the bundle is not composed, under the ids the patch declares.
       // Rows come from static declarations while the bundle is not composed, under the ids the patch declares.
       expect(bundle?.rows).toEqual([{ entryId: 'hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
       expect(bundle?.rows).toEqual([{ entryId: 'hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
       expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra' }])
       expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra' }])
-      expect(existsSync(join(staged.profileDir, '.dsh-plugins'))).toBe(false)
     })
     })
 
 
     it('reads a composed bundle\'s rows from the live tree with their failures', async () => {
     it('reads a composed bundle\'s rows from the live tree with their failures', async () => {
@@ -558,7 +557,6 @@ describe('PluginManager', () => {
       })
       })
       expect(calls).toEqual([['pnpm', 'add', 'ext-lib']])
       expect(calls).toEqual([['pnpm', 'add', 'ext-lib']])
       expect(manifestOf(staged.profileDir).dependencies).toHaveProperty('ext-lib')
       expect(manifestOf(staged.profileDir).dependencies).toHaveProperty('ext-lib')
-      expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-lib.json'))).toBe(false)
       expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(true)
       expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(true)
     })
     })
 
 
@@ -718,7 +716,6 @@ describe('PluginManager', () => {
       expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] })
       expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] })
       expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new'])
       expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new'])
       expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello']))
       expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello']))
-      expect(existsSync(join(staged.profileDir, '.dsh-plugins'))).toBe(false)
       expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
       expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
       expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
       expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
     })
     })
@@ -1011,7 +1008,6 @@ describe('PluginManager', () => {
       await manager.enable('ext-bundle')
       await manager.enable('ext-bundle')
       await manager.addRow('ext-bundle', { kind: 'global' }, { module: './extra.js' })
       await manager.addRow('ext-bundle', { kind: 'global' }, { module: './extra.js' })
       expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello', 'include:ext-bundle/extra.js']))
       expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello', 'include:ext-bundle/extra.js']))
-      expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
 
 
       await manager.uninstall('ext-bundle')
       await manager.uninstall('ext-bundle')
 
 
@@ -1020,7 +1016,6 @@ describe('PluginManager', () => {
       expect(entryIds(ctx)).not.toContain('include:hello')
       expect(entryIds(ctx)).not.toContain('include:hello')
       expect(entryIds(ctx)).not.toContain('include:ext-bundle/extra.js')
       expect(entryIds(ctx)).not.toContain('include:ext-bundle/extra.js')
       expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
       expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
-      expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
       expect(changes.map(change => change.reason)).toEqual(['enable', 'row', 'disable', 'uninstall'])
       expect(changes.map(change => change.reason)).toEqual(['enable', 'row', 'disable', 'uninstall'])
       await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/not-installed' })
       await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/not-installed' })
     })
     })

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

@@ -1431,7 +1431,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       },
       {
       {
         signature: '@Remote(\'uninstall\') async uninstall(packageName: string): Promise<void>',
         signature: '@Remote(\'uninstall\') async uninstall(packageName: string): Promise<void>',
-        description: 'Remove a package from the profile with its user-layer rows and any obsolete discovery cache.',
+        description: 'Remove a package from the profile with its user-layer rows.',
         parameters: [{ name: 'packageName', description: 'the installed dependency to remove.' }],
         parameters: [{ name: 'packageName', description: 'the installed dependency to remove.' }],
       },
       },
       {
       {

+ 2 - 2
packages/host/plugin-manager/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/plugin-manager/README.md
 #   pnpm run verify-translation-pairing --write packages/host/plugin-manager/README.md
-README.md: 94f4debb4c8ae061d417a9a9da0affa22e07621c
-README.zh.md: 5d4e7c997110ac588aa91821de47732f167f8b67
+README.md: c991e1675effe9ca0633b3637f839ee8835a5aa7
+README.zh.md: e2be2a6cd613a09ea4260a40d5f0765eb6a8520b

+ 1 - 1
packages/host/plugin-manager/README.md

@@ -53,7 +53,7 @@ A manager failure reaches the client as a `RemoteError` with the same `code` and
 
 
 ### A relay, not a second manager
 ### A relay, not a second manager
 
 
-The adapter supplies per-call profile, preset and agent readers to one `PluginManager`, relays each Remote method, and maps domain errors. It observes native entry/status events and publishes a coalesced `plugins/changed` notification after Loader and queued profile recomposition settle. Disposal cancels pending notifications and removes the listeners. Tests may replace the manager, pnpm spawner or static metadata reader.
+The adapter supplies per-call profile, preset and agent readers to one `PluginManager`, relays each Remote method, and maps domain errors. It coalesces native entry/status events into one reader, waits for Loader and queued profile recomposition to settle, and publishes `plugins/changed` only when the row diagnostics change. Management operations retain their own change notifications. Disposal cancels pending notifications and removes the listeners. Tests may replace the manager, pnpm spawner or static metadata reader.
 
 
 ### Source map
 ### Source map
 
 

+ 1 - 1
packages/host/plugin-manager/README.zh.md

@@ -53,7 +53,7 @@ kind: "package-reference"
 
 
 ### 转接,不是第二个管理器
 ### 转接,不是第二个管理器
 
 
-适配器为一个 `PluginManager` 提供逐次调用的 profile、预设与 agent 读取器,转接各 Remote 方法并映射领域错误。它观测原生条目与状态事件,在 Loader 和已排队的 profile 重组完成后合并发布 `plugins/changed` 通知。销毁会取消待发通知并移除监听器。测试可替换管理器、pnpm 启动器或静态元信息读取器。
+适配器为一个 `PluginManager` 提供逐次调用的 profile、预设与 agent 读取器,转接各 Remote 方法并映射领域错误。它将原生条目与状态事件合并到一次读取中,等待 Loader 和已排队的 profile 重组完成后,仅在行诊断变化时发布 `plugins/changed` 通知。管理操作仍保留各自的变更通知。销毁会取消待发通知并移除监听器。测试可替换管理器、pnpm 启动器或静态元信息读取器。
 
 
 ### 源码地图
 ### 源码地图
 
 

+ 29 - 9
packages/host/plugin-manager/src/index.ts

@@ -10,7 +10,7 @@
 
 
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import z from '@deepseek-ai/schemastery'
-import type { readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
+import { inspectEntryIssues, type readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 import {
 import {
@@ -87,21 +87,41 @@ export class PluginManagerRemote extends TypertRemoteService {
       ...internals.metadata === undefined ? {} : { metadata: internals.metadata },
       ...internals.metadata === undefined ? {} : { metadata: internals.metadata },
     })
     })
     const loader = ctx.loader
     const loader = ctx.loader
-    let generation = 0
     let disposed = false
     let disposed = false
+    let refreshing = false
+    let dirty = false
+    let previousIssues = '[]'
+    const invalidated = (): boolean => disposed || dirty
     ctx.effect(() => () => { disposed = true })
     ctx.effect(() => () => { disposed = true })
     const refresh = (): void => {
     const refresh = (): void => {
-      const requested = ++generation
-      // Loader events precede asynchronous import/update completion; publish only after settlement.
+      dirty = true
+      if (refreshing) return
+      refreshing = true
+      // One reader settles the latest tree; events during its read request another pass.
       void Promise.resolve().then(async () => {
       void Promise.resolve().then(async () => {
-        await loader.await()
-        await ctx.get('profileRuntime')?.whenIdle()
-        if (!disposed && requested === generation) ctx.emit('plugins/changed', { reason: 'runtime' })
-      }).catch((error: unknown) => { ctx.logger.warn('plugin inventory refresh failed', error) })
+        while (dirty && !disposed) {
+          dirty = false
+          await loader.await()
+          await ctx.get('profileRuntime')?.whenIdle()
+          if (invalidated()) continue
+          const issues = await inspectEntryIssues(ctx)
+          if (invalidated()) continue
+          const next = JSON.stringify(issues.map(({ entry, stage, message }) =>
+            JSON.stringify([entry.id, entry.options.name, entry.fiber?.state, stage, message]),
+          ).sort())
+          if (next === previousIssues) continue
+          previousIssues = next
+          ctx.emit('plugins/changed', { reason: 'runtime' })
+        }
+      }).catch((error: unknown) => { ctx.logger.warn('plugin inventory refresh failed', error) }).finally(() => {
+        refreshing = false
+        if (dirty && !disposed) refresh()
+      })
     }
     }
     ctx.on('internal/status', (fiber) => { if (fiber.entry !== undefined) refresh() }, { global: true })
     ctx.on('internal/status', (fiber) => { if (fiber.entry !== undefined) refresh() }, { global: true })
     ctx.on('loader/entry-init', refresh, { global: true })
     ctx.on('loader/entry-init', refresh, { global: true })
     ctx.on('loader/partial-dispose', refresh, { global: true })
     ctx.on('loader/partial-dispose', refresh, { global: true })
+    refresh()
 
 
   }
   }
 
 
@@ -126,7 +146,7 @@ export class PluginManagerRemote extends TypertRemoteService {
   }
   }
 
 
   /**
   /**
-   * Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
+   * Remove a package from the profile with its user-layer rows.
    * @param packageName - the installed dependency to remove.
    * @param packageName - the installed dependency to remove.
    */
    */
   @Remote('uninstall')
   @Remote('uninstall')

+ 123 - 2
packages/host/plugin-manager/tests/plugin-manager.spec.ts

@@ -45,19 +45,21 @@ async function mount(manager?: PluginManager): Promise<PluginManagerRemote> {
 }
 }
 
 
 describe('PluginManagerRemote', () => {
 describe('PluginManagerRemote', () => {
-  it('waits for queued profile recomposition before publishing refreshed ownership', async () => {
+  it('waits for queued profile recomposition before publishing changed issues', async () => {
     const ctx = new Context()
     const ctx = new Context()
     contexts.push(ctx)
     contexts.push(ctx)
     await ctx.plugin(Loader)
     await ctx.plugin(Loader)
-    ctx.loader.builtins.good = () => {}
+    ctx.loader.builtins.good = { inject: ['missingService'], apply() {} }
     const id = await ctx.loader.create({ name: 'cordis:good' })
     const id = await ctx.loader.create({ name: 'cordis:good' })
     await ctx.plugin(PluginManagerRemote, CONFIG)
     await ctx.plugin(PluginManagerRemote, CONFIG)
+    await new Promise<void>(resolve => setImmediate(resolve))
     let release!: () => void
     let release!: () => void
     const gate = new Promise<void>((resolve) => { release = resolve })
     const gate = new Promise<void>((resolve) => { release = resolve })
     const whenIdle = vi.fn(() => gate)
     const whenIdle = vi.fn(() => gate)
     ctx.provide('profileRuntime', { whenIdle } as never)
     ctx.provide('profileRuntime', { whenIdle } as never)
     const changes: string[] = []
     const changes: string[] = []
     ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
     ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    ctx.provide('missingService', {})
     ctx.emit('loader/entry-init', ctx.loader.resolve(id))
     ctx.emit('loader/entry-init', ctx.loader.resolve(id))
     try {
     try {
       await vi.waitFor(() => { expect(whenIdle).toHaveBeenCalledOnce() })
       await vi.waitFor(() => { expect(whenIdle).toHaveBeenCalledOnce() })
@@ -125,6 +127,124 @@ describe('PluginManagerRemote', () => {
     expect(changes).toHaveLength(count)
     expect(changes).toHaveLength(count)
   })
   })
 
 
+  it('keeps unrelated healthy changes and unchanged errors silent, but reports error changes and recovery', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.good = () => {}
+    const id = await ctx.loader.create({ name: 'cordis:good' })
+    const entry = ctx.loader.resolve(id)
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    await ctx.plugin(PluginManagerRemote, CONFIG)
+    await new Promise<void>(resolve => setImmediate(resolve))
+    await entry.update({ config: { healthy: true } })
+    await ctx.loader.await()
+    await new Promise<void>(resolve => setImmediate(resolve))
+    expect(changes).toEqual([])
+
+    entry.lastFailure = { stage: 'update', error: 'first update failed' }
+    ctx.emit('loader/entry-init', entry)
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime']) })
+    ctx.emit('loader/entry-init', entry)
+    await new Promise<void>(resolve => setImmediate(resolve))
+    expect(changes).toEqual(['runtime'])
+    entry.lastFailure = { stage: 'update', error: 'different update failed' }
+    ctx.emit('loader/entry-init', entry)
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime', 'runtime']) })
+    delete entry.lastFailure
+    ctx.emit('loader/entry-init', entry)
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime', 'runtime', 'runtime']) })
+  })
+
+  it('shares one pending refresh across a burst of Loader events', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.waiting = { inject: ['lateService'], apply() {} }
+    const id = await ctx.loader.create({ name: 'cordis:waiting' })
+    const entry = ctx.loader.resolve(id)
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    await ctx.plugin(PluginManagerRemote, CONFIG)
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime']) })
+    const gate = Promise.withResolvers<undefined>()
+    const whenIdle = vi.fn(() => gate.promise)
+    ctx.provide('profileRuntime', { whenIdle } as never)
+    const settle = vi.spyOn(ctx.loader, 'await')
+    try {
+      ctx.emit('loader/entry-init', entry)
+      await vi.waitFor(() => { expect(whenIdle).toHaveBeenCalledOnce() })
+      for (let i = 0; i < 100; i++) ctx.emit('loader/entry-init', entry)
+      expect(settle).toHaveBeenCalledOnce()
+      expect(whenIdle).toHaveBeenCalledOnce()
+      ctx.provide('lateService', {})
+      gate.resolve(undefined)
+      await vi.waitFor(() => { expect(changes).toEqual(['runtime', 'runtime']) })
+      expect(entry.fiber?.state).toBe(2)
+    } finally {
+      gate.resolve(undefined)
+      settle.mockRestore()
+    }
+  })
+
+  it('discards a diagnostic snapshot changed while it was being read', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.good = () => {}
+    const id = await ctx.loader.create({ name: 'cordis:good' })
+    const entry = ctx.loader.resolve(id)
+    await ctx.plugin(PluginManagerRemote, CONFIG)
+    await new Promise<void>(resolve => setImmediate(resolve))
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    entry.lastFailure = { stage: 'update', error: new Error('transient failure') }
+    const read = vi.spyOn(entry, 'disabled', 'get').mockImplementationOnce(() => {
+      queueMicrotask(() => {
+        delete entry.lastFailure
+        ctx.emit('loader/entry-init', entry)
+      })
+      return false
+    })
+    try {
+      ctx.emit('loader/entry-init', entry)
+      await vi.waitFor(() => { expect(read).toHaveBeenCalledTimes(2) })
+      await new Promise<void>(resolve => setImmediate(resolve))
+      expect(changes).toEqual([])
+    } finally {
+      read.mockRestore()
+    }
+  })
+
+  it('cancels publication when disposed during the diagnostic read', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.good = () => {}
+    const id = await ctx.loader.create({ name: 'cordis:good' })
+    const entry = ctx.loader.resolve(id)
+    const remote = ctx.plugin(PluginManagerRemote, CONFIG)
+    await remote
+    await new Promise<void>(resolve => setImmediate(resolve))
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    entry.lastFailure = { stage: 'update', error: new Error('update failed') }
+    const disposed = Promise.withResolvers<undefined>()
+    const read = vi.spyOn(entry, 'disabled', 'get').mockImplementationOnce(() => {
+      queueMicrotask(() => { void remote.dispose().then(() => { disposed.resolve(undefined) }, disposed.reject) })
+      return false
+    })
+    try {
+      ctx.emit('loader/entry-init', entry)
+      await disposed.promise
+      await new Promise<void>(resolve => setImmediate(resolve))
+      expect(changes).toEqual([])
+    } finally {
+      read.mockRestore()
+    }
+  })
+
   it('publishes the plugins namespace with one direct method per operation', async () => {
   it('publishes the plugins namespace with one direct method per operation', async () => {
     const remote = await mount()
     const remote = await mount()
     expect(remote.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
     expect(remote.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
@@ -183,6 +303,7 @@ describe('PluginManagerRemote', () => {
     await ctx.plugin(Loader)
     await ctx.plugin(Loader)
     ctx.provide('profileRuntime', {
     ctx.provide('profileRuntime', {
       dir: profileDir, profileName: 'web', installAnchor: join(profileDir, 'package.json'), patchReload: 'startup', current: { layers: [] },
       dir: profileDir, profileName: 'web', installAnchor: join(profileDir, 'package.json'), patchReload: 'startup', current: { layers: [] },
+      whenIdle: async () => {},
     } as never)
     } as never)
     const metadata: typeof readPackageMetadata = options => ({
     const metadata: typeof readPackageMetadata = options => ({
       packageName: options.packageName, kind: 'plugin', cordisSameCopy: null, rows: [], overrides: [], addable: [{ name: '.' }],
       packageName: options.packageName, kind: 'plugin', cordisSameCopy: null, rows: [], overrides: [], addable: [{ name: '.' }],