Jelajahi Sumber

feat(web): manage plugins over the #4182 manager from the sidebar page

The sidebar Plugins page now drives `ctx.pluginManager` from #4182 and the
settings Plugin list tab goes back to read-only: its bundle form and row
checkboxes are removed, and the page joins `listBundles` with
`listPlugins` into one view per bundle, decides availability from the
inventory's `managementAvailable`, switches bundles and rows through
`setBundleEnabled` and `setPluginEnabled`, and confirms uninstalls.

The manager gains what the guided install dialog needs: `inspect(spec)`
reads a spec through the registry or a directory before anything installs
and refuses specs without a bundle; `installBundle` takes a request id,
streams each pnpm run as `plugin-manager/install-log`, reports its phases
as `plugin-manager/install-state`, classifies a failed run into
`packageResult.kind`, names the bundle it added, and restores
`package.json` and `pnpm-lock.yaml` when the run fails, is cancelled, or
adds a package without a bundle patch; `cancelInstall(requestId)` answers
only after pnpm exited and the files are back; `listBundles` carries
titles, one-liners, declared rows with their live entries, and the
built-in rows a bundle overrides; every completed operation and every
patch generation applied outside the manager emits
`plugin-manager/changed`. The config gains `pnpmCommand` and
`inspectTimeoutMs`. App boot emits `profile/reconciled` after each applied
patch generation and finds a root Include mounted under its pinned id by
another copy of the module.

Row diagnostics, dependency checks, and the non-bundle dependency group
are not carried over; the web e2e scaffold mounts a launcher-style
managed profile instead of the removed profile runtime.
Yichen Jiang 4 minggu lalu
induk
melakukan
4d1adb8541
82 mengubah file dengan 2483 tambahan dan 2174 penghapusan
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml
  8. 12 8
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
  9. 12 8
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md
  10. 0 9
      apps/web/tests/expected/plugin-manager/manager.expected.md
  11. 0 5
      apps/web/tests/fixtures/plugins/fixture-plain-plugin/index.js
  12. 0 16
      apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json
  13. 15 5
      apps/web/tests/plugin-install-cancel.e2e.ts
  14. 55 54
      apps/web/tests/plugin-manager.e2e.ts
  15. 65 14
      apps/web/tests/scaffold.ts
  16. 2 2
      docs/config-catalog.i18n.yaml
  17. 7 2
      docs/config-catalog.md
  18. 7 2
      docs/config-catalog.zh.md
  19. 2 2
      docs/event-producer-consumer.i18n.yaml
  20. 4 0
      docs/event-producer-consumer.md
  21. 4 0
      docs/event-producer-consumer.zh.md
  22. 2 2
      docs/module-graph.i18n.yaml
  23. 4 1
      docs/module-graph.md
  24. 4 1
      docs/module-graph.zh.md
  25. 2 2
      docs/subsystems/boot.i18n.yaml
  26. 105 6
      docs/subsystems/boot.md
  27. 105 6
      docs/subsystems/boot.zh.md
  28. 5 1
      packages/api/remotes/src/client/index.ts
  29. 4 0
      packages/api/remotes/src/remote-events.ts
  30. 6 0
      packages/api/remotes/tsconfig.host.json
  31. 2 2
      packages/boot/app-boot/README.i18n.yaml
  32. 1 1
      packages/boot/app-boot/README.md
  33. 1 1
      packages/boot/app-boot/README.zh.md
  34. 28 3
      packages/boot/app-boot/src/index.ts
  35. 17 1
      packages/boot/app-boot/tests/user-patches.spec.ts
  36. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  37. 12 5
      packages/boot/plugin-manager/README.md
  38. 12 5
      packages/boot/plugin-manager/README.zh.md
  39. 4 2
      packages/boot/plugin-manager/package.json
  40. 308 36
      packages/boot/plugin-manager/src/index.ts
  41. 43 0
      packages/boot/plugin-manager/src/install-failure.ts
  42. 74 0
      packages/boot/plugin-manager/src/install-spec.ts
  43. 47 1
      packages/boot/plugin-manager/src/operations.ts
  44. 139 2
      packages/boot/plugin-manager/src/types.ts
  45. 71 0
      packages/boot/plugin-manager/tests/install-spec.spec.ts
  46. 219 15
      packages/boot/plugin-manager/tests/manager.spec.ts
  47. 23 1
      packages/boot/plugin-manager/tests/operations.spec.ts
  48. 3 0
      packages/boot/plugin-manager/tsconfig.json
  49. 8 0
      packages/bundle/web-app/cordis.patch.yml
  50. 2 2
      packages/client/ui-plugin-manager/README.i18n.yaml
  51. 19 27
      packages/client/ui-plugin-manager/README.md
  52. 25 33
      packages/client/ui-plugin-manager/README.zh.md
  53. 1 1
      packages/client/ui-plugin-manager/package.json
  54. 139 299
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
  55. 9 9
      packages/client/ui-plugin-manager/src/client/index.ts
  56. 8 140
      packages/client/ui-plugin-manager/src/client/locales.ts
  57. 209 278
      packages/client/ui-plugin-manager/src/client/manager-store.ts
  58. 3 53
      packages/client/ui-plugin-manager/src/client/presentation.ts
  59. 12 8
      packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
  60. 162 365
      packages/client/ui-plugin-manager/tests/components.client.spec.tsx
  61. 245 354
      packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
  62. 1 1
      packages/client/ui-plugin-manager/tsconfig.json
  63. 2 2
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml
  64. 5 5
      packages/client/ui-settings-plugin-inventory/README.md
  65. 5 5
      packages/client/ui-settings-plugin-inventory/README.zh.md
  66. 1 1
      packages/client/ui-settings-plugin-inventory/package.json
  67. 0 61
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css
  68. 2 17
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
  69. 3 17
      packages/client/ui-settings-plugin-inventory/src/client/index.ts
  70. 0 8
      packages/client/ui-settings-plugin-inventory/src/client/locales.ts
  71. 0 93
      packages/client/ui-settings-plugin-inventory/src/client/management.tsx
  72. 2 26
      packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx
  73. 3 120
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  74. 0 4
      packages/extensions/cordis-client-runner/src/client/api-catalog.ts
  75. 4 1
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  76. 91 7
      packages/extensions/tool-cordis/src/api-catalog.ts
  77. 6 2
      packages/host/plugin-inventory/src/index.ts
  78. 2 0
      packages/util/package-manifest/src/types.ts
  79. 60 0
      pnpm-lock.yaml
  80. 10 0
      scripts/gen-cordis-catalog.ts
  81. 2 0
      snapshots/web/lifecycle-chrome/hero.expected.md
  82. 2 0
      snapshots/web/lifecycle-chrome/plan-active.expected.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.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-09-plugin-management-in-the-web-sidebar.md
-2026-09-09-plugin-management-in-the-web-sidebar.md: 562fafcb9307f1ff61b361c94fdde79cbf5175dc
-2026-09-09-plugin-management-in-the-web-sidebar.zh.md: 58515c1f0d25a0ffb7d902abcccc8da46d3b281d
+2026-09-09-plugin-management-in-the-web-sidebar.md: d2a3329e7c878f25ad5e50d98fc2e3ab39722887
+2026-09-09-plugin-management-in-the-web-sidebar.zh.md: aae035b3a93aec72e84e2792334c42c54d2bca62

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md

@@ -10,9 +10,9 @@ Installed packages belong to the running profile, while Settings is a modal over
 
 ## Decision
 
-**Management is a sidebar entry; configuration stays in Settings.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages packages and global rows, displays install output and confirms dependency-sensitive actions. The page lists installed packages only. The Settings Plugins section keeps the global configuration cards beside the read-only Plugin list tab, where the built-in bundles (`dsh-base`, `dsh-web-app`) are inspected; both of that tab's groups start collapsed.
+**Management is a sidebar entry; configuration stays in Settings.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. The page lists installed bundles only. The Settings Plugins section keeps the global configuration cards beside the read-only Plugin list tab, where the installation's own bundles (`dsh-base`, `dsh-web-app`) are inspected; both of that tab's groups start collapsed, and the tab carries no management controls of its own.
 
-**One store follows Host state.** The manager controller reads package views, refreshes after management operations and reconnects, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings.
+**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings.
 
 **Installation results belong to a request.** The dialog generates a fresh request id for every install or retry and filters Host progress, logs, and responses by that id. A cancellation acknowledgement can arrive before the original add response, so that response cannot settle a subsequent retry. Cancellation uses the manager's explicit cleanup acknowledgement; local RPC cancellation and connection loss never imply that pnpm has stopped. The application phase closes the cancellation window.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md

@@ -10,9 +10,9 @@ Status: implemented
 
 ## 决定
 
-**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面管理包与全局行、展示安装输出,并确认影响依赖的操作。页面只列出已安装的包。设置的插件分区保留全局配置卡片,旁边是只读的「插件列表」标签页,内置 bundle(`dsh-base`、`dsh-web-app`)在那里查看;该标签页的两个分组默认收起。
+**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。页面只列出已安装的组合包。设置的插件分区保留全局配置卡片,旁边是只读的「插件列表」标签页,随安装提供的组合包(`dsh-base`、`dsh-web-app`)在那里查看;该标签页的两个分组默认收起,且不带任何管理控件。
 
-**一个 store 跟随 Host 状态。** 管理器控制器读取包视图,在管理操作和重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。
+**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。
 
 **安装结果属于一次请求。** 每次安装或重试都生成新的请求 ID,对话框按该 ID 筛选 Host 进度、日志和返回结果。取消确认可能先于原 add 响应到达,因此旧响应不能结束随后的重试。取消采用管理器的明确清理确认;本地 RPC 取消和断线均不表示 pnpm 已停止。进入配置应用阶段后关闭取消窗口。
 

+ 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: 1234007022bb0ac5e9247a0387d03ed9a4e94cdc
-2026-09-14-current-profile-plugin-management.zh.md: 0d6059089c51379bad3b99bbcbe533afa8ac4c26
+2026-09-14-current-profile-plugin-management.md: 646b9f09cf1938524c487bb7f40afb0391667f08
+2026-09-14-current-profile-plugin-management.zh.md: 2671ba1ec0cdd74ead54ff8d25ca9ade14fbf5bb

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

@@ -20,7 +20,7 @@ This extends the [profile bundle composition decision](2026-08-05-profile-plugin
 
 **Spawning another dsh process from the service.** This duplicates lifecycle coordination and cannot establish that the current Loader finished unloading before pnpm removes files. Sharing the operation module retains one implementation while letting each caller own its presentation.
 
-**A second desired-state database or automatic rollback.** These require synchronizing package-manager side effects with another state store. Profile files remain inspectable and repairable; partial installation and loading failures are reported rather than concealed by an incomplete rollback.
+**A second desired-state database or automatic rollback.** These require synchronizing package-manager side effects with another state store. Profile files remain inspectable and repairable; partial loading failures are reported rather than concealed by an incomplete rollback. For installations this was later reversed: a failed or cancelled installation restores the manifest and lockfile ([guided plugin installation](2026-09-15-guided-plugin-installation.md)).
 
 **Source-module hot replacement for package updates.** Configuration changes can reuse the loaded module cache, whereas replacing installed JavaScript needs a new process generation. Replacing an existing dependency reports a required restart.
 

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

@@ -20,7 +20,7 @@ profile 文件保持为持久状态:条目开关只修改 YAML 文档中的 `d
 
 **由 service 启动另一个 dsh 进程。** 这会重复生命周期协调,也无法确认当前 Loader 已完成卸载后才让 pnpm 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。
 
-**第二份目标状态数据库或自动回滚。** 这些方案需要将包管理器副作用与另一份状态同步。profile 文件保持可检查、可修复;安装与加载的部分失败直接报告,不用不完整的回滚掩盖。
+**第二份目标状态数据库或自动回滚。** 这些方案需要将包管理器副作用与另一份状态同步。profile 文件保持可检查、可修复;加载的部分失败直接报告,不用不完整的回滚掩盖。安装这一路径后来被反转:失败或被取消的安装会恢复 manifest 与 lockfile([引导式插件安装](2026-09-15-guided-plugin-installation.zh.md))。
 
 **包更新时热替换源码模块。** 配置变化可以复用已加载模块缓存,替换已安装 JavaScript 则需要新的进程。替换已有依赖会报告需要重启。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.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-15-guided-plugin-installation.md
-2026-09-15-guided-plugin-installation.md: 8e4b908d5813a0b87b67fe0f775b89f53bf0f5fa
-2026-09-15-guided-plugin-installation.zh.md: 59dbe042d86a2501aad8d38b99b3bb5f3581568e
+2026-09-15-guided-plugin-installation.md: 83d52e29b2a24c6abd16f4a1ffae8d26cfe83c3f
+2026-09-15-guided-plugin-installation.zh.md: 4669343c662f93e758d5fb35d67213b8f085c962

+ 12 - 8
.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md

@@ -6,19 +6,21 @@ English | [中文](2026-09-15-guided-plugin-installation.zh.md)
 
 ## Problem
 
-The install dialog put a spec straight into `pnpm add` and showed pnpm's terminal as the whole story: a typo, an installed package, a missing path, and a registry outage all ended in the same red exit code, the person read pnpm's output to learn which, and nothing could be stopped once started. Enabling was a checkbox to tick before knowing what would be installed, and a finished install left the new package somewhere in the list. Refusals such as a running session sat on the page until dismissed.
+The install dialog put a spec straight into `pnpm add` and showed pnpm's terminal as the whole story: a typo, an installed package, a missing path, and a registry outage all ended in the same red exit code, the person read pnpm's output to learn which, and nothing could be stopped once started. A run that failed, or that added a package without a bundle patch, left the dependency in the profile with nothing in the list to show or remove it. Enabling was a checkbox to tick before knowing what would be installed, and a finished install left the new package somewhere in the list.
 
 ## Decision
 
-**The Host reads a spec before installing it.** `PluginInstaller.inspect` sorts the spec — registry name, absolute path, git address, tarball — with `parseInstallSpec`, refuses what pnpm or the registry would refuse, and asks the registry through `pnpm view` or the directory through its `package.json` for the name, version, description, title, and bundle declaration. `pnpm view` runs in the profile directory so the registry and proxy settings match the install. `plugins/inspect-rejected` carries one of six problems; the client renders each as a sentence under the field and keeps the spec editable. The dialog refuses a name the list already shows without asking the Host.
+**The Host reads a spec before installing it.** `PluginManager.inspect` sorts the spec — registry name, absolute path, git address, tarball — with `parseInstallSpec`, refuses what pnpm or the registry would refuse, and asks the registry through `pnpm view` or the directory through its `package.json` for the name, version, description, title, and bundle declaration. `pnpm view` runs in the profile directory so the registry and proxy settings match the install. A package without a bundle patch is refused here, before pnpm runs: the manager installs bundles only. The answer carries one of seven problems; the client renders each as a sentence under the field and keeps the spec editable. The dialog refuses a name the list already shows without asking the Host.
 
-**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into a `kind` on `plugins/install-failed`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
+**A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store.
 
-**Cancellation is the manager's, not the signal's.** A run is stopped through `plugins/cancelInstall` with the request id the dialog generated, and the dialog waits for the Host's answer before offering the spec again; [the manager note](2026-09-04-plugin-manager-over-the-profile-runtime.md) records why an aborted RPC is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
+**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
 
-**Enabling comes after the fact.** The finished screen offers **Enable now** for the bundles the run added; the dialog closes and the list scrolls to the first of them. Nothing is enabled before the person has seen what was installed.
+**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
 
-**Refusals of the moment are toasts.** `plugins/busy` and `plugins/agents-running` return the dialog to the spec and toast; every other notice on the page is a toast too. The restart banner stays, because it describes state rather than an event.
+**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed.
+
+**Outcomes of the moment are toasts.** A change that waits for the next start, one a higher layer overrides, a cancelled run, and a refused action each toast and retire; nothing stays on the page.
 
 ## Alternatives considered
 
@@ -28,10 +30,12 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
 
 **Stop the run by aborting the add RPC.** Rejected: a dropped RPC does not say whether pnpm stopped or the manifest is back, so the dialog would offer the spec again over a run still writing to the profile. The manager's `cancelInstall` answers only after cleanup, and the dialog waits for it.
 
+**Keep listing dependencies that are not bundles.** Rejected: the manager manages bundles, and a package it refuses to install cannot be listed or removed through it; the check refuses such a package before anything is written.
+
 ## Consequences
 
-`plugins/inspect` and `plugins/inspect-rejected` join the Remote; `plugins/install-failed` gains `kind`; the host config gains `inspectTimeoutMs`. The dialog is four screens over one subject card. The non-pack dependency group folds by default and names what it holds.
+`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand` and `inspectTimeoutMs`. The dialog is four screens over one subject card.
 
 ## Testing
 
-`packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `plugin-manager.spec.ts` drives `inspect` against a fake `pnpm view` and a real directory and checks each kind; `packages/host/plugin-manager/tests` relays `inspect` with its signal. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog and gets the spec back.
+`packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `manager.spec.ts` drives `inspect` against a stubbed registry lookup and a real directory, streams a run, stops one and checks the restored files, and checks the change events; `operations.spec.ts` covers the registry lookup. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host and switches a bundle and one of its rows live, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog, checks the restored files, and installs on the second try.

+ 12 - 8
.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md

@@ -6,19 +6,21 @@ Status: implemented
 
 ## 问题
 
-安装对话框把 spec 直接交给 `pnpm add`,并把 pnpm 的终端当作全部说明:打错字、已装过的包、不存在的路径、注册表不可用,最后都是同一个红色退出码,人得去读 pnpm 输出才知道是哪一种,而且一旦开始就停不下来。启用是一个在知道要装什么之前就得勾的选项,装完的包散在列表某处。会话运行中之类的拒绝会一直挂在页面上,直到手动关掉。
+安装对话框把 spec 直接交给 `pnpm add`,并把 pnpm 的终端当作全部说明:打错字、已装过的包、不存在的路径、注册表不可用,最后都是同一个红色退出码,人得去读 pnpm 输出才知道是哪一种,而且一旦开始就停不下来。失败的运行,或者装进来一个没有组合包 patch 的包,会把依赖留在 profile 里,列表上却没有任何东西能显示或移除它。启用是一个在知道要装什么之前就得勾的选项,装完的包散在列表某处。
 
 ## 决定
 
-**宿主先读 spec,再安装。** `PluginInstaller.inspect` 用 `parseInstallSpec` 把 spec 分成注册表名、绝对路径、git 地址、压缩包,拒绝 pnpm 或注册表不会接受的写法,再通过 `pnpm view` 问注册表、或读目录的 `package.json`,得到名字、版本、描述、标题和组合包声明。`pnpm view` 在 profile 目录里运行,让注册表和代理设置与安装一致。`plugins/inspect-rejected` 带六种 problem 之一;客户端把每一种渲染成输入框下的一句话,spec 保留可改。列表里已有的名字由对话框直接拒绝,不问宿主。
+**宿主先读 spec,再安装。** `PluginManager.inspect` 用 `parseInstallSpec` 把 spec 分成注册表名、绝对路径、git 地址、压缩包,拒绝 pnpm 或注册表不会接受的写法,再通过 `pnpm view` 问注册表、或读目录的 `package.json`,得到名字、版本、描述、标题和组合包声明。`pnpm view` 在 profile 目录里运行,让注册表和代理设置与安装一致。没有组合包 patch 的包在这一步、在 pnpm 运行之前就被拒绝:管理器只安装组合包。答复带七种 problem 之一;客户端把每一种渲染成输入框下的一句话,spec 保留可改。列表里已有的名字由对话框直接拒绝,不问宿主。
 
-**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给 `plugins/install-failed` 加上 `kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
+**失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。
 
-**取消属于管理器,不属于信号。** 运行通过 `plugins/cancelInstall` 带上对话框生成的 request id 来停止,对话框等宿主答复之后才把 spec 重新交回;[管理器笔记](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)记录了为何中止 RPC 不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
+**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
 
-**启用在事后。** 完成画面为这次运行新增的组合包提供**立即启用**;对话框关闭,列表滚动到其中第一个。在人看到装了什么之前,不会启用任何东西。
+**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
 
-**当下的拒绝是 toast。** `plugins/busy` 与 `plugins/agents-running` 让对话框回到输入并弹 toast;页面上其余提示也都是 toast。重启横幅保留,因为它描述的是状态而非事件。
+**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
+
+**当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。
 
 ## 考虑过的替代方案
 
@@ -28,10 +30,12 @@ Status: implemented
 
 **通过中止 add RPC 来停止运行。** 否决:断掉的 RPC 说不清 pnpm 是否停了、manifest 是否已恢复,对话框会在一次仍在写 profile 的运行之上把 spec 重新交回。管理器的 `cancelInstall` 只在清理完成后答复,对话框等它。
 
+**继续列出不是组合包的依赖。** 否决:管理器管理的是组合包,它拒绝安装的包无法经由它列出或移除;检查在写入任何东西之前就拒绝这样的包。
+
 ## 后果
 
-Remote 新增 `plugins/inspect` 与 `plugins/inspect-rejected`;`plugins/install-failed` 增加 `kind`;宿主配置增加 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。非插件包依赖分组默认折叠,并说明自己装的是什么。
+`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand` 与 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
 
 ## 测试
 
-`packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`plugin-manager.spec.ts` 用假的 `pnpm view` 和真实目录驱动 `inspect`,并检查每一种 kind;`packages/host/plugin-manager/tests` 连同信号转接 `inspect`。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程并拿回 spec。
+`packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`manager.spec.ts` 用桩住的注册表查询和真实目录驱动 `inspect`,流式转发一次运行,停下一次运行并检查恢复后的文件,并检查变更事件;`operations.spec.ts` 覆盖注册表查询。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,并实时切换一个组合包及其中一行,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成。

+ 0 - 9
apps/web/tests/expected/plugin-manager/manager.expected.md

@@ -9,12 +9,3 @@
     - button "查看 示例组合包": 示例组合包
     - text: "Web e2e fixture: a bundle whose one row is an inert plugin."
     - switch "启用 示例组合包"
-- button "非插件包依赖" [expanded]:
-  - img
-  - text: 非插件包依赖
-- text: 1 个
-- paragraph: 这些包不是 dsh 插件包,不会被加载;可以在这里卸载。
-- list:
-  - listitem:
-    - button "查看 示例插件": 示例插件
-    - text: "Web e2e fixture: a plain plugin module with no bundle, added to a composition per row."

+ 0 - 5
apps/web/tests/fixtures/plugins/fixture-plain-plugin/index.js

@@ -1,5 +0,0 @@
-/** Web e2e fixture: an inert cordis plugin that a composition adds as one row. */
-export const name = 'fixture-plain-plugin'
-
-/** Nothing to mount. */
-export function apply() {}

+ 0 - 16
apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json

@@ -1,16 +0,0 @@
-{
-  "name": "@fixture/plain-plugin",
-  "version": "0.0.1",
-  "private": true,
-  "description": "Web e2e fixture: a plain plugin module with no bundle, added to a composition per row.",
-  "type": "module",
-  "main": "index.js",
-  "dsh": {
-    "title": "示例插件",
-    "plugins": [
-      {
-        "name": "."
-      }
-    ]
-  }
-}

+ 15 - 5
apps/web/tests/plugin-install-cancel.e2e.ts

@@ -12,10 +12,10 @@ import { ZH_BROWSER_LOCALE } from './support.ts'
 it('cancels installation through the UI, restores files, and offers the spec again', async () => {
   const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-'))
   const overlay = join(scratch, 'cordis.patch.yml')
-  await writeFile(overlay, `- id: plugin-manager\n  config: ${JSON.stringify({ pnpmCommand: process.execPath, installTimeoutMs: 60_000, installKillGraceMs: 50 })}\n`)
+  await writeFile(overlay, `- id: plugin-manager\n  config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`)
   let scaffold: WebScaffold | undefined
   try {
-    scaffold = await launchWebScaffold({ profileRuntime: { packages: [] }, extraOverlayPath: overlay })
+    scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay })
     const browser = await chromium.launch()
     try {
       const profile = join(scaffold.harnessHome, 'profiles', 'scaffold')
@@ -25,7 +25,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga
       await writeFile(lockPath, 'original lockfile\n')
       // Node stands in for the pnpm executable: `view` answers the check that precedes the run,
       // and the same installer owns and stops the real `add` child.
-      await writeFile(join(profile, 'view'), 'console.log(JSON.stringify({ name: "slow-package", version: "1.0.0" }))\n')
+      await writeFile(join(profile, 'view'), 'console.log(JSON.stringify({ name: "slow-package", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }))\n')
       await writeFile(join(profile, 'add'), `
         import('node:fs').then(fs => {
         fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { partial: '1.0.0' } }));
@@ -60,11 +60,21 @@ it('cancels installation through the UI, restores files, and offers the spec aga
         .split(process.execPath).join('{{node}}')
         .split(scaffold.harnessHome).join('{{harnessHome}}')
       await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/cancelled.expected.md', import.meta.url)), snapshot, webSnapshotMode())
-      await writeFile(join(profile, 'add'), 'console.log("Retry completed")\n')
+      // The second run installs a bundle the way pnpm leaves one: the dependency in the manifest and the package under node_modules.
+      await writeFile(join(profile, 'add'), `
+        import('node:fs').then(fs => {
+        fs.mkdirSync('node_modules/slow-package', { recursive: true });
+        fs.writeFileSync('node_modules/slow-package/package.json', JSON.stringify({ name: 'slow-package', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }));
+        fs.writeFileSync('node_modules/slow-package/cordis.patch.yml', '[]\\n');
+        fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { 'slow-package': '1.0.0' } }));
+        console.log('Retry completed');
+        });
+      `)
       await dialog.getByRole('button', { name: '安装', exact: true }).click()
-      await dialog.getByRole('button', { name: '完成', exact: true }).waitFor()
+      await dialog.getByRole('button', { name: '立即启用', exact: true }).waitFor()
       await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click()
       await dialog.getByText('Retry completed', { exact: true }).waitFor()
+      expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } })
       expect(tripwire.pageErrors).toEqual([])
     } finally { await browser.close() }
   } finally {

+ 55 - 54
apps/web/tests/plugin-manager.e2e.ts

@@ -1,7 +1,7 @@
-// Web e2e scenario: the plugin manager page behind the sidebar's Plugins entry over a scaffold
-// profile runtime: installed packages and bundle enablement. Zero model calls: everything is
-// client state plus the profile files and the settings document, so there is
-// no fixture and a stray stream would fail loud on the open llm seam.
+// Web e2e scenario: the plugin manager page behind the sidebar's Plugins entry over a
+// managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero
+// model calls: everything is client state plus the profile files and the settings
+// document, so there is no fixture and a stray stream would fail loud on the open llm seam.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
@@ -28,12 +28,7 @@ describe('web e2e: plugin manager', () => {
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({
-      profileRuntime: {
-        packages: [
-          { dir: join(FIXTURE_PLUGINS, 'fixture-bundle') },
-          { dir: join(FIXTURE_PLUGINS, 'fixture-plain-plugin') },
-        ],
-      },
+      profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
     })
     browser = await chromium.launch()
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
@@ -69,28 +64,22 @@ describe('web e2e: plugin manager', () => {
     return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
   }
 
-  it('lists the profile packages with their switches', async () => {
+  it('lists the installed bundles with their switches and leaves the installation\'s own to Settings', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
     const panel = await openPluginsPanel()
 
     await panel.getByText('示例组合包', { exact: true }).waitFor({ timeout: 20_000 })
-    // Non-bundle packages remain installed, folded under their own group, and expose their uninstall on the detail page.
-    expect(await panel.getByText('示例插件', { exact: true }).count()).toBe(0)
     const toggle = panel.getByRole('switch', { name: '启用 示例组合包' })
     expect(await toggle.getAttribute('aria-checked')).toBe('false')
-    expect(await panel.getByRole('switch', { name: '启用 示例插件' }).count()).toBe(0)
-    expect(await panel.getByRole('button', { name: '加入全局' }).count()).toBe(0)
-    expect(await panel.getByRole('button', { name: '卸载 示例插件' }).count()).toBe(0)
-    const others = panel.getByRole('button', { name: '非插件包依赖', exact: true })
-    expect(await others.getAttribute('aria-expanded')).toBe('false')
-    expect(await panel.getByRole('button', { name: '查看 示例插件' }).count()).toBe(0)
-    await others.click()
-    expect(await panel.getByText('示例插件', { exact: true }).count()).toBe(1)
-    await panel.getByRole('button', { name: '查看 示例插件' }).click()
-    await panel.getByRole('button', { name: '卸载 示例插件' }).waitFor({ timeout: 5_000 })
-    await panel.getByText('此包未提供组合包 patch,可通过 Cordis 配置手动加载模块。', { exact: true }).waitFor({ timeout: 5_000 })
+    // The shipped bundles are not the person's to manage here.
+    expect(await panel.locator('[data-plugin-package]').count()).toBe(1)
+    // A bundle that is off still shows the rows its patch declares, without switches.
+    await panel.getByRole('button', { name: '查看 示例组合包' }).click()
+    await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
+    expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0)
+    await panel.getByRole('button', { name: '卸载 示例组合包' }).waitFor({ timeout: 5_000 })
     await panel.getByRole('button', { name: '返回插件列表' }).click()
-    await expect.poll(() => panel.getByRole('button', { name: '卸载 示例插件' }).count(), { timeout: 5_000 }).toBe(0)
+    await expect.poll(() => panel.getByRole('button', { name: '卸载 示例组合包' }).count(), { timeout: 5_000 }).toBe(0)
 
     const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE)
@@ -125,33 +114,45 @@ describe('web e2e: plugin manager', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('enables a bundle into the profile manifest and reports the restart it waits for', async () => {
+  it('enables a bundle into the profile manifest, mounts its rows live, and switches one of them', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable'))
     const panel = await openPluginsPanel()
     const toggle = panel.getByRole('switch', { name: '启用 示例组合包' })
     await toggle.waitFor({ timeout: 20_000 })
+    const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
+    expect(mounted()?.fiber?.state).toBeUndefined()
 
     await toggle.click()
 
-    await expect.poll(async () => (await homeFile('profiles', 'scaffold', 'package.json')).includes('"@fixture/bundle"'), {
-      timeout: 10_000,
-    }).toBe(true)
-    const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
+    const bundles = async () => (JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
       dsh: { profile: { bundles: string[] } }
-    }
-    expect(manifest.dsh.profile.bundles).toEqual(['@fixture/bundle'])
-    // A startup-applied profile: the switch is on, and the banner names the package.
+    }).dsh.profile.bundles
+    await expect.poll(bundles, { timeout: 10_000 }).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@fixture/bundle'])
+    // A live profile: the row mounts once the whole tree recomposed, the switch is on, and nothing waits for a restart.
+    await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2)
     await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('true')
-    await panel.getByText('以下更改会在下次启动生效:示例组合包').waitFor({ timeout: 10_000 })
-    expect(await panel.getByText('需重启', { exact: true }).count()).toBe(1)
-    // The pack's page lists its rows from its static declarations. A profile that applies
-    // patches at its next start keeps them read-only: no row switch.
+    expect(await panel.getByText(/下次启动生效/).count()).toBe(0)
+    const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE)
+    // The pack's page lists its rows as the Host runs them, each with a switch that writes the profile patch.
     await panel.getByRole('button', { name: '查看 示例组合包' }).click()
-    await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
-    expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0)
+    const rowSwitch = panel.getByRole('switch', { name: '启用组件 fixture-row' })
+    await rowSwitch.waitFor({ timeout: 10_000 })
+    expect(await rowSwitch.getAttribute('aria-checked')).toBe('true')
+    await rowSwitch.click()
+    await expect.poll(async () => (await homeFile('profiles', 'scaffold', 'cordis.patch.yml')).includes('fixture-row'), { timeout: 10_000 }).toBe(true)
+    await expect.poll(() => rowSwitch.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('false')
+    // A disabled entry keeps its disposed fiber; only an active one counts as mounted.
+    await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2)
+    await rowSwitch.click()
+    await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2)
     await panel.getByRole('button', { name: '返回插件列表' }).click()
+
+    await toggle.click()
+    await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2)
+    await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
     expect(tripwire.pageErrors).toEqual([])
-  }, 60_000)
+  }, 90_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
@@ -160,10 +161,10 @@ describe('web e2e: plugin manager', () => {
 })
 
 
-describe('web e2e: live plugin management', () => {
-  it('applies bundle enable and disable through the Remote without a restart', async () => {
+describe('web e2e: startup-applied plugin management', () => {
+  it('saves a bundle selection that waits for the next start and keeps its rows read-only', async () => {
     const scaffold = await launchWebScaffold({
-      profileRuntime: { patchReload: 'live', packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
+      profile: { patchReload: 'startup', packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
     })
     let browser: Browser | undefined
     try {
@@ -181,22 +182,22 @@ describe('web e2e: live plugin management', () => {
         const text = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'package.json'), 'utf8')
         return (JSON.parse(text) as { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles
       }
-      expect(mounted()).toBeUndefined()
+      expect(mounted()?.fiber?.state).toBeUndefined()
       await toggle.click()
-      await expect.poll(() => mounted()?.fiber?.state, { timeout: 10_000 }).toBe(2)
-      await expect.poll(bundles).toEqual(['@fixture/bundle'])
+      // The selection is saved and the switch turns on, but nothing mounts before the next start; a toast says so.
+      await expect.poll(bundles).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@fixture/bundle'])
       await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
-      expect(await panel.getByText('需重启', { exact: true }).count()).toBe(0)
-      expect(await panel.getByText(/以下更改会在下次启动生效/).count()).toBe(0)
-      const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
-      await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE)
+      await page.getByText('更改会在下次启动生效。', { exact: true }).waitFor({ timeout: 10_000 })
+      expect(mounted()?.fiber?.state).toBeUndefined()
+      // The pack's page lists its rows from their declarations, with no live entry to switch.
+      await panel.getByRole('button', { name: '查看 示例组合包' }).click()
+      await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
+      expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).isDisabled()).toBe(true)
+      await panel.getByRole('button', { name: '返回插件列表' }).click()
 
       await toggle.click()
-      await expect.poll(mounted, { timeout: 10_000 }).toBeUndefined()
-      await expect.poll(bundles).toEqual([])
+      await expect.poll(bundles).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
       await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
-      expect(await panel.getByText('需重启', { exact: true }).count()).toBe(0)
-      expect(await panel.getByText(/以下更改会在下次启动生效/).count()).toBe(0)
       expect(tripwire.pageErrors).toEqual([])
     } finally {
       await browser?.close()

+ 65 - 14
apps/web/tests/scaffold.ts

@@ -61,9 +61,14 @@ import {
   composeEntries,
   createProfileResolutionGeneration,
   healProfilesModuleFallback,
+  initProfile,
+  mountRootInclude,
+  readProfileManifest,
+  readProfilePatches,
   loadOverlayPatches,
   PluginPackages,
   type Profile,
+  type ProfileContext,
   type ProfileResolutionMode,
 } from '@deepseek-ai/dsh-app-boot'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
@@ -308,6 +313,19 @@ export interface LaunchOptions {
    * profile layers named by {@link extraOverlayPath}.
    */
   extraInstallAnchors?: string[]
+  /**
+   * Manage the scaffold profile the way the launcher does: a `profileContext`
+   * over the profile directory, whose manifest lists the shipped web bundles
+   * and each package directory here as an installed dependency (`file:` in the
+   * manifest, a symlink under the profile's `node_modules`); `enabled` also
+   * lists a bundle in `dsh.profile.bundles`. The plugin manager mounts on such
+   * a profile, and the root Include is mounted from the profile's own layers.
+   * `patchReload` defaults to `live`.
+   */
+  profile?: {
+    patchReload?: 'live' | 'startup'
+    packages: { dir: string; enabled?: boolean }[]
+  }
   /**
    * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
    * in replay/refresh modes; ignored in record mode (the real adapter
@@ -523,9 +541,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     surfaceContext?: boolean
   } | undefined
   const surfaceContext = webRuntimeConfig?.surfaceContext !== false
-  const patches: PatchOptions[] = [
-    ...basePatches,
-    ...surfacePatches,
+  // The scaffold's own overrides, above every bundle layer like `--patch` overlays.
+  const overlayPatches: PatchOptions[] = [
     { id: 'session-log-deepseek', config: { enabled: false } },
     // The historical Messages fixture retains its recorded route during replay;
     // live configuration uses the shared DeepSeek route. Explicit overlays win.
@@ -653,6 +670,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
         config: messages ? {} : { protocol: 'chat-completions' } },
     ],
   ]
+  const patches: PatchOptions[] = [...basePatches, ...surfacePatches, ...overlayPatches]
 
   // Sessions inherit the gateway's process.cwd() default; run the boot from
   // the temp workspace so tool cwd, session cwd, and fixtures agree.
@@ -689,13 +707,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
         patches: [],
       }
     }))
+    const profilePatchReload = options.profile?.patchReload ?? 'live'
     const profile: Profile = {
       name: 'scaffold',
       dir: profileDir,
       layers: extraLayers,
       patchPath: join(profileDir, 'cordis.patch.yml'),
       patches: [],
-      patchReload: 'startup',
+      patchReload: options.profile === undefined ? 'startup' : profilePatchReload,
     }
     const profileResolutionMode = options.profileResolutionMode ?? 'runtime'
     const resolutionOptions = { installAnchor: INSTALL_ANCHOR, home: harnessHome, profile }
@@ -706,6 +725,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     const rootConfig = join(profileDir, 'cordis.yml')
     await writeFile(rootConfig, '[]\n')
     ctx.baseUrl = pathToFileURL(profileDir).href + '/'
+    let profileContext: ProfileContext | undefined
+    if (options.profile !== undefined) {
+      // A real profile: the shipped web bundles plus each fixture package,
+      // installed the way `dsh plugin add` leaves them.
+      const dependencies: Record<string, string> = {}
+      const bundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
+      for (const entry of options.profile.packages) {
+        const manifest = JSON.parse(await readFile(join(entry.dir, 'package.json'), 'utf8')) as { name: string }
+        dependencies[manifest.name] = `file:${entry.dir}`
+        if (entry.enabled === true) bundles.push(manifest.name)
+        const link = join(profileDir, 'node_modules', manifest.name)
+        await mkdir(dirname(link), { recursive: true })
+        await symlink(entry.dir, link, 'junction')
+      }
+      initProfile(profileDir, bundles, profilePatchReload)
+      const manifest = readProfileManifest('dsh', profileDir)
+      manifest.dependencies = dependencies
+      await writeFile(join(profileDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n')
+      profileContext = {
+        name: 'scaffold', dir: profileDir, patchPath: profile.patchPath, installAnchor: INSTALL_ANCHOR,
+        cwd: workspaceCwd, home: harnessHome, startedBundles: bundles, patchReload: profilePatchReload,
+        overlays: overlayPatches, telemetryDisabledEnv: undefined,
+      }
+      ctx.provide('profileContext', profileContext)
+    }
     // This direct Loader harness supplies the same root-path capability as app-boot.
     ctx.provide('dshHomePath', dshHomePath)
     // A host with no command line still provides one: the web bundle's startup
@@ -723,16 +767,23 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       behavior: profileResolutionMode === 'dual' ? 'verify' : 'enforce',
     })
     await ctx.plugin(Loader)
-    ctx.loader.builtins.include = Include
-    // `cordis:group` beside it, exactly as `boot()` registers it: a group row is
-    // how a preset gives one `isolate` realm to a provider and its consumers,
-    // and a preset resolving package names from its own directory cannot reach
-    // `@deepseek-ai/cordis-plugin-group` by name.
-    ctx.loader.builtins.group = Group
-    await ctx.loader.create({
-      name: 'cordis:include',
-      config: { path: pathToFileURL(rootConfig).href, patches },
-    })
+    if (profileContext === undefined) {
+      ctx.loader.builtins.include = Include
+      // `cordis:group` beside it, exactly as `boot()` registers it: a group row is
+      // how a preset gives one `isolate` realm to a provider and its consumers,
+      // and a preset resolving package names from its own directory cannot reach
+      // `@deepseek-ai/cordis-plugin-group` by name.
+      ctx.loader.builtins.group = Group
+      await ctx.loader.create({
+        name: 'cordis:include',
+        config: { path: pathToFileURL(rootConfig).href, patches },
+      })
+    } else {
+      // The launcher's own mount, so the manager's reloads find the root Include
+      // and compose the same layers the profile files name; bare names still
+      // resolve through the resolution generation above, as in the direct mount.
+      await mountRootInclude(ctx, rootConfig, readProfilePatches('dsh', profileContext))
+    }
     await ctx.loader.await()
     await auditStartupEntries(ctx, 'web e2e scaffold')
     if (options.welcomeNoticePending !== 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: cfd41d7396c14d1662fe51bc74b295ccf551a895
-config-catalog.zh.md: eff5f686fef8f7add5ea527d18e997d695541ae5
+config-catalog.md: 702caf98f2a782854d43d0742385cc6ab1f8593c
+config-catalog.zh.md: 7516f9a1c349bcbce2daa0a4216c7077bd92eefc

+ 7 - 2
docs/config-catalog.md

@@ -1804,18 +1804,22 @@ Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/s
 Requires: `loader` · `profileContext`
 
 ```ts config-catalog
-/** Limits for package diagnostics and change notifications. */
+/** The pnpm executable and the limits for package diagnostics, registry lookups, and change notifications. */
 export interface Config {
+  /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
+  pnpmCommand?: string
   /** Maximum retained pnpm diagnostic bytes per operation. */
   outputBytes?: number
   /** Delay for combining consecutive management notices in one durable injection. */
   notificationDelayMs?: number
   /** Maximum time to wait for another process's profile package operation. */
   lockWaitMs?: number
+  /** Bound on one registry lookup an inspection runs, in milliseconds. */
+  inspectTimeoutMs?: number
 }
 ```
 
-Source: [`packages/boot/plugin-manager/src/index.ts:21`](../packages/boot/plugin-manager/src/index.ts)
+Source: [`packages/boot/plugin-manager/src/index.ts:31`](../packages/boot/plugin-manager/src/index.ts)
 
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
@@ -3659,6 +3663,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-open-in-app` ([`packages/client/ui-open-in-app/src/index.ts`](../packages/client/ui-open-in-app/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-permission-presets` ([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-plugin-manager` ([`packages/client/ui-plugin-manager/src/index.ts`](../packages/client/ui-plugin-manager/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-renderer` ([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-schedule` ([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts))

+ 7 - 2
docs/config-catalog.zh.md

@@ -1806,18 +1806,22 @@ export interface PlanModeConfig {
 依赖: `loader` · `profileContext`
 
 ```ts config-catalog
-/** Limits for package diagnostics and change notifications. */
+/** The pnpm executable and the limits for package diagnostics, registry lookups, and change notifications. */
 export interface Config {
+  /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
+  pnpmCommand?: string
   /** Maximum retained pnpm diagnostic bytes per operation. */
   outputBytes?: number
   /** Delay for combining consecutive management notices in one durable injection. */
   notificationDelayMs?: number
   /** Maximum time to wait for another process's profile package operation. */
   lockWaitMs?: number
+  /** Bound on one registry lookup an inspection runs, in milliseconds. */
+  inspectTimeoutMs?: number
 }
 ```
 
-来源: [`packages/boot/plugin-manager/src/index.ts:19`](../packages/boot/plugin-manager/src/index.ts)
+来源: [`packages/boot/plugin-manager/src/index.ts:31`](../packages/boot/plugin-manager/src/index.ts)
 
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
@@ -3661,6 +3665,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-open-in-app`([`packages/client/ui-open-in-app/src/index.ts`](../packages/client/ui-open-in-app/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-permission-presets`([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-plugin-manager`([`packages/client/ui-plugin-manager/src/index.ts`](../packages/client/ui-plugin-manager/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-reference`([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-renderer`([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-schedule`([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts))

+ 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: 12abe60e6a5e7905c449bd5cc54cd0b163ea2891
-event-producer-consumer.zh.md: 47fff7f14a3b61d1df65079b0c46ce814e4f496e
+event-producer-consumer.md: 1184a56c1a26e16ebd040f6b78c90e9160756afa
+event-producer-consumer.zh.md: afa67a598842262fda9a57acb1ee6aea30a76d37

+ 4 - 0
docs/event-producer-consumer.md

@@ -51,6 +51,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `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` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:165`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:171`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:177`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `profile/reconciled` | `emit` | [`packages/boot/app-boot/src/index.ts:250`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`plugin-manager`](../packages/boot/plugin-manager) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |

+ 4 - 0
docs/event-producer-consumer.zh.md

@@ -53,6 +53,10 @@
 | `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` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:165`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:171`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:177`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `profile/reconciled` | `emit` | [`packages/boot/app-boot/src/index.ts:250`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`plugin-manager`](../packages/boot/plugin-manager) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: 3c6e6a81c741e4f3779cda709fe00d455a65ca95
-module-graph.zh.md: 5df66d6740712a4d617778c9279d3676266cb218
+module-graph.md: e6ef41ef6ede09da1e94695877c53d11e59ace9f
+module-graph.zh.md: 5c670f83eba696df4e353f725c160f597cc712f1

+ 4 - 1
docs/module-graph.md

@@ -168,6 +168,7 @@ flowchart TD
     pkg_client_ui_open_in_app["client-ui-open-in-app"]
     pkg_client_ui_permission_presets["client-ui-permission-presets"]
     pkg_client_ui_plan["client-ui-plan"]
+    pkg_client_ui_plugin_manager["client-ui-plugin-manager"]
     pkg_client_ui_primitives["client-ui-primitives"]
     pkg_client_ui_reference["client-ui-reference"]
     pkg_client_ui_renderer["client-ui-renderer"]
@@ -1084,6 +1085,7 @@ flowchart TD
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
   pkg_plugin_manager --> pkg_agent
+  pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
   pkg_plugin_manager --> pkg_llm
@@ -1334,6 +1336,7 @@ flowchart TD
 | [`client-ui-open-in-app`](../packages/client/ui-open-in-app) | `client` | — |
 | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — |
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | — |
+| [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager) | `client` | — |
 | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — |
 | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — |
@@ -1542,7 +1545,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 4 - 1
docs/module-graph.zh.md

@@ -170,6 +170,7 @@ flowchart TD
     pkg_client_ui_open_in_app["client-ui-open-in-app"]
     pkg_client_ui_permission_presets["client-ui-permission-presets"]
     pkg_client_ui_plan["client-ui-plan"]
+    pkg_client_ui_plugin_manager["client-ui-plugin-manager"]
     pkg_client_ui_primitives["client-ui-primitives"]
     pkg_client_ui_reference["client-ui-reference"]
     pkg_client_ui_renderer["client-ui-renderer"]
@@ -1086,6 +1087,7 @@ flowchart TD
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
   pkg_plugin_manager --> pkg_agent
+  pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
   pkg_plugin_manager --> pkg_llm
@@ -1336,6 +1338,7 @@ flowchart TD
 | [`client-ui-open-in-app`](../packages/client/ui-open-in-app) | `client` | — |
 | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — |
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | — |
+| [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager) | `client` | — |
 | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — |
 | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — |
@@ -1544,7 +1547,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 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: 32f6e17fb2728b59b4d77ea993bcaf3c00f72db3
+boot.zh.md: 46ce15e612b42e42441d60cc5028ffa48aa91a1d

+ 105 - 6
docs/subsystems/boot.md

@@ -10,11 +10,15 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
 
 `PluginInfo` carries module identity, effective enablement and fiber phase, plus a unique `patchId` or a `readOnlyReason`.
 
-`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error.
+`BundleInfo` carries the package name, optional installed version, title and description, whether the profile's own dependencies hold it, selected enablement, removal availability, optional resolution error, the rows its patch declares (`BundleRowInfo`: row id, module, and the live entry id while the bundle contributes it), and the ids of existing rows its patch overrides.
 
-`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer.
+`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `requestId` (`PluginInstallRequestId`, a caller-generated UUID) identifies the installation for its log and state events and for `cancelInstall`, which answers a `PluginInstallCancellation`: `cancelled`, `too-late`, or `not-running`.
 
-`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. `message` describes the result. Optional `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path.
+`PluginSpecInspection` is what `inspect` answers before an install: an accepted spec's form, name, version, description, title, and bundle declaration, or a refusal's problem and reason.
+
+`PluginInstallLogChunk` is one chunk of a pnpm run's output under its request id and job id, with the command line, the directory, the stream, and the exit code on the run's last chunk; `PluginInstallProgress` is an installation's request id and Host phase; `PluginChange` names the operation behind a `plugin-manager/changed` event, or `reload` for a patch generation applied outside the manager.
+
+`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden`, `failed` or `cancelled`. `message` describes the result. Optional `packageResult` records the pnpm exit code, bounded output, truncation flag, complete diagnostic log path, and the failure kind of a failed run; optional `bundle` names the package an installation added.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -66,10 +70,17 @@ Manage profile files and apply their declared reload lifecycle.
 @Remote async listPlugins(): Promise<PluginInfo[]>
 
 /** Read installed bundles and bundles supplied by this dsh installation.
- * @returns Package versions, activation selections and removal availability.
+ * @returns Package versions, titles, rows, activation selections and removal availability.
  */
 @Remote listBundles(): Promise<BundleInfo[]>
 
+/** Read what a spec names before installing it.
+ * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball.
+ * @param signal Ends a registry lookup early.
+ * @returns The package the spec names, or why it is refused.
+ */
+@Remote async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection>
+
 /** Persist a plugin entry's desired enablement and apply it on live profiles.
  * @param id Loader entry identity returned by listPlugins.
  * @param enabled Whether the plugin should run.
@@ -84,13 +95,23 @@ Manage profile files and apply their declared reload lifecycle.
  */
 @Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
 
-/** Install a package using the same pnpm implementation as dsh plugin.
+/**
+ * Install a package using the same pnpm implementation as dsh plugin. A run
+ * that fails, is cancelled, or adds a package without a bundle patch restores
+ * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
  * @param spec One package spec, including local paths relative to the invocation directory.
- * @param options Whether to activate the installed bundle; defaults to true.
+ * @param options Whether to activate the installed bundle (defaults to true) and the request id a cancellation names.
  * @returns Package-manager diagnostics and observed activation outcome.
  */
 @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>
 
+/** Stop an installation this manager owns and wait until its files are back.
+ * @param requestId The id the installation was started with.
+ * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
+ * applied, `not-running` for any other id.
+ */
+@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation>
+
 /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
  * @param name Installed dependency name.
  * @returns Removal diagnostics and the remaining profile state.
@@ -159,4 +180,82 @@ Module replacements have finished loading.
 ```
 
 Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
+
+<a id="plugin-manager-events"></a>
+
+### `plugin-manager/*` events
+
+<a id="plugin-managerchanged--emit"></a>
+
+#### `plugin-manager/changed` — emit
+
+The profile's plugins, bundles, or composition changed: a manager operation completed, or a patch generation was applied.
+
+```ts cordis-catalog
+/**
+ * The profile's plugins, bundles, or composition changed: a manager
+ * operation completed, or a patch generation was applied.
+ * @mode emit
+ * @param change - what changed.
+ */
+'plugin-manager/changed'(change: PluginChange): void
+```
+
+Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
+
+<a id="plugin-managerinstall-log--emit"></a>
+
+#### `plugin-manager/install-log` — emit
+
+One chunk of a pnpm run's output, streamed as the run produces it.
+
+```ts cordis-catalog
+/**
+ * One chunk of a pnpm run's output, streamed as the run produces it.
+ * @mode emit
+ * @param chunk - the chunk and the run it belongs to.
+ */
+'plugin-manager/install-log'(chunk: PluginInstallLogChunk): void
+```
+
+Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
+
+<a id="plugin-managerinstall-state--emit"></a>
+
+#### `plugin-manager/install-state` — emit
+
+An installation moved between its Host phases.
+
+```ts cordis-catalog
+/**
+ * An installation moved between its Host phases.
+ * @mode emit
+ * @param progress - the installation's request id and phase.
+ */
+'plugin-manager/install-state'(progress: PluginInstallProgress): void
+```
+
+Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
+
+<a id="profile-events"></a>
+
+### `profile/*` events
+
+<a id="profilereconciled--emit"></a>
+
+#### `profile/reconciled` — emit
+
+A complete patch generation was applied to the root Include and the Loader settled, whether or not every row activated.
+
+```ts cordis-catalog
+/**
+ * A complete patch generation was applied to the root Include and the
+ * Loader settled, whether or not every row activated.
+ * @mode emit
+ * @param patches - the ordered patch list that was applied.
+ */
+'profile/reconciled'(patches: readonly PatchOptions[]): void
+```
+
+Source: [`packages/boot/app-boot/src/index.ts`](../../packages/boot/app-boot/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 105 - 6
docs/subsystems/boot.zh.md

@@ -10,11 +10,15 @@
 
 `PluginInfo` 包含模块标识、实际启停状态和 fiber 阶段,以及唯一的 `patchId` 或 `readOnlyReason`。
 
-`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。
+`BundleInfo` 包含包名、可选的安装版本、标题与描述、profile 自身依赖是否持有该包、组合层选择状态、删除可用性、可选的解析错误、其 patch 声明的行(`BundleRowInfo`:行 id、模块,以及组合包生效期间的存活条目 id),以及其 patch 覆盖的已有行 id。
 
-`InstallBundleOptions.enabled` 默认为 true。False 表示安装但不选择该组合包层。
+`InstallBundleOptions.enabled` 默认为 true。False 表示安装但不选择该组合包层。`requestId`(`PluginInstallRequestId`,调用方生成的 UUID)标识这次安装,用于其日志与状态事件以及 `cancelInstall`,后者答复 `PluginInstallCancellation`:`cancelled`、`too-late` 或 `not-running`。
 
-`ChangeResult.changed` 独立报告磁盘修改,`application` 为 `applied`、`restart-required`、`overridden` 或 `failed`。`message` 描述结果。可选的 `packageResult` 记录 pnpm 退出码、有界输出、截断标记与完整诊断日志路径。
+`PluginSpecInspection` 是 `inspect` 在安装前的答复:被接受的 spec 的形式、名称、版本、描述、标题与组合包声明,或拒绝的问题与原因。
+
+`PluginInstallLogChunk` 是 pnpm 运行输出的一块,归于其 request id 与 job id 之下,带命令行、目录、流,以及运行最后一块上的退出码;`PluginInstallProgress` 是一次安装的 request id 与 Host 阶段;`PluginChange` 给出 `plugin-manager/changed` 事件背后的操作,管理器之外应用的一代 patch 记为 `reload`。
+
+`ChangeResult.changed` 独立报告磁盘修改,`application` 为 `applied`、`restart-required`、`overridden`、`failed` 或 `cancelled`。`message` 描述结果。可选的 `packageResult` 记录 pnpm 退出码、有界输出、截断标记、完整诊断日志路径,以及失败运行的失败类别;可选的 `bundle` 给出安装新增的包。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -66,10 +70,17 @@ Manage profile files and apply their declared reload lifecycle.
 @Remote async listPlugins(): Promise<PluginInfo[]>
 
 /** Read installed bundles and bundles supplied by this dsh installation.
- * @returns Package versions, activation selections and removal availability.
+ * @returns Package versions, titles, rows, activation selections and removal availability.
  */
 @Remote listBundles(): Promise<BundleInfo[]>
 
+/** Read what a spec names before installing it.
+ * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball.
+ * @param signal Ends a registry lookup early.
+ * @returns The package the spec names, or why it is refused.
+ */
+@Remote async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection>
+
 /** Persist a plugin entry's desired enablement and apply it on live profiles.
  * @param id Loader entry identity returned by listPlugins.
  * @param enabled Whether the plugin should run.
@@ -84,13 +95,23 @@ Manage profile files and apply their declared reload lifecycle.
  */
 @Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
 
-/** Install a package using the same pnpm implementation as dsh plugin.
+/**
+ * Install a package using the same pnpm implementation as dsh plugin. A run
+ * that fails, is cancelled, or adds a package without a bundle patch restores
+ * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
  * @param spec One package spec, including local paths relative to the invocation directory.
- * @param options Whether to activate the installed bundle; defaults to true.
+ * @param options Whether to activate the installed bundle (defaults to true) and the request id a cancellation names.
  * @returns Package-manager diagnostics and observed activation outcome.
  */
 @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>
 
+/** Stop an installation this manager owns and wait until its files are back.
+ * @param requestId The id the installation was started with.
+ * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
+ * applied, `not-running` for any other id.
+ */
+@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation>
+
 /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
  * @param name Installed dependency name.
  * @returns Removal diagnostics and the remaining profile state.
@@ -159,4 +180,82 @@ Module replacements have finished loading.
 ```
 
 Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
+
+<a id="plugin-manager-events"></a>
+
+### `plugin-manager/*` events
+
+<a id="plugin-managerchanged--emit"></a>
+
+#### `plugin-manager/changed` — emit
+
+The profile's plugins, bundles, or composition changed: a manager operation completed, or a patch generation was applied.
+
+```ts cordis-catalog
+/**
+ * The profile's plugins, bundles, or composition changed: a manager
+ * operation completed, or a patch generation was applied.
+ * @mode emit
+ * @param change - what changed.
+ */
+'plugin-manager/changed'(change: PluginChange): void
+```
+
+Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
+
+<a id="plugin-managerinstall-log--emit"></a>
+
+#### `plugin-manager/install-log` — emit
+
+One chunk of a pnpm run's output, streamed as the run produces it.
+
+```ts cordis-catalog
+/**
+ * One chunk of a pnpm run's output, streamed as the run produces it.
+ * @mode emit
+ * @param chunk - the chunk and the run it belongs to.
+ */
+'plugin-manager/install-log'(chunk: PluginInstallLogChunk): void
+```
+
+Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
+
+<a id="plugin-managerinstall-state--emit"></a>
+
+#### `plugin-manager/install-state` — emit
+
+An installation moved between its Host phases.
+
+```ts cordis-catalog
+/**
+ * An installation moved between its Host phases.
+ * @mode emit
+ * @param progress - the installation's request id and phase.
+ */
+'plugin-manager/install-state'(progress: PluginInstallProgress): void
+```
+
+Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
+
+<a id="profile-events"></a>
+
+### `profile/*` events
+
+<a id="profilereconciled--emit"></a>
+
+#### `profile/reconciled` — emit
+
+A complete patch generation was applied to the root Include and the Loader settled, whether or not every row activated.
+
+```ts cordis-catalog
+/**
+ * A complete patch generation was applied to the root Include and the
+ * Loader settled, whether or not every row activated.
+ * @mode emit
+ * @param patches - the ordered patch list that was applied.
+ */
+'profile/reconciled'(patches: readonly PatchOptions[]): void
+```
+
+Source: [`packages/boot/app-boot/src/index.ts`](../../packages/boot/app-boot/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 5 - 1
packages/api/remotes/src/client/index.ts

@@ -22,7 +22,11 @@ import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
 
 export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
-export type { PluginInfo, BundleInfo, ChangeResult, PluginEntryId } from '@deepseek-ai/dsh-plugin-manager/types'
+export type {
+  BundleInfo, BundleRowInfo, ChangeResult, InstallBundleOptions, InstallSpecKind, PackageResult, PluginChange, PluginEntryId, PluginInfo,
+  PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind, PluginInstallLogChunk, PluginInstallProgress,
+  PluginInstallRequestId, PluginSpecInspection,
+} from '@deepseek-ai/dsh-plugin-manager/types'
 export type {} from '@deepseek-ai/dsh-plugin-manager/remote'
 export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
 export type {} from '@deepseek-ai/dsh-agent-presets/remote'

+ 4 - 0
packages/api/remotes/src/remote-events.ts

@@ -8,6 +8,7 @@
 
 import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events'
 import type {} from '@deepseek-ai/dsh-permission-presets/types'
+import type {} from '@deepseek-ai/dsh-plugin-manager/types'
 import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol'
 
 /**
@@ -33,6 +34,9 @@ export const API_REMOTE_FORWARDED_EVENTS = [
   { event: 'cordis/inspect-query-resolved', mode: 'emit' },
   { event: 'llm/adapters-updated', mode: 'emit' },
   { event: 'permission-presets/catalog-changed', mode: 'emit' },
+  { event: 'plugin-manager/changed', mode: 'emit' },
+  { event: 'plugin-manager/install-log', mode: 'emit' },
+  { event: 'plugin-manager/install-state', mode: 'emit' },
   { event: 'settings/document-updated', mode: 'emit' },
   { event: 'user-questions/request', mode: 'waterfall' },
 ] as const satisfies readonly TypertForwardableEventEntry[]

+ 6 - 0
packages/api/remotes/tsconfig.host.json

@@ -73,6 +73,12 @@
     },
     {
       "path": "../terminal-controller/tsconfig.host.json"
+    },
+    {
+      "path": "../../boot/plugin-manager"
+    },
+    {
+      "path": "../../host/plugin-inventory"
     }
   ]
 }

+ 2 - 2
packages/boot/app-boot/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/app-boot/README.md
-README.md: 295c4c64002c1f7ddabe4b12eb405355ccb3c91b
-README.zh.md: 8cf81694f62292f6ff941f14d5cb15433f67ddcb
+README.md: b481bcb5df950bf04c7e7b970e3d4fa88d89a95f
+README.zh.md: 7d6aca45dc12c562b885df50321acaf59f610acd

+ 1 - 1
packages/boot/app-boot/README.md

@@ -54,7 +54,7 @@ Your machine-local preferences also live in the Harness home:
 - **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
 - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead.
 
-Profiles with `patchReload: live` watch the profile manifest and both user patch files, re-read the ordered bundle layers, and apply the [reload failure policy](#startup-and-reload-failures). [DSH HMR](../hmr/README.md) serializes these reloads with [Plugin Manager](../plugin-manager/README.md) mutations and waits for the shared profile package lock before reading changes. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
+Profiles with `patchReload: live` watch the profile manifest and both user patch files, re-read the ordered bundle layers, and apply the [reload failure policy](#startup-and-reload-failures). [DSH HMR](../hmr/README.md) serializes these reloads with [Plugin Manager](../plugin-manager/README.md) mutations and waits for the shared profile package lock before reading changes. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback. Every applied patch generation, from the watcher or a management operation, emits `profile/reconciled` with the patches once the Loader settled, whether or not every row activated.
 
 Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
 

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -54,7 +54,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个配置(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。
 
-带 `patchReload: live` 的 profile 会监视 profile manifest 与两份用户 patch 文件,重新读取按顺序排列的组合包层,并应用[重载失败策略](#startup-and-reload-failures)。[DSH HMR](../hmr/README.zh.md) 将这些重载与[插件管理器](../plugin-manager/README.zh.md)的修改串行化,读取变化前等待共享的 profile 包操作锁。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
+带 `patchReload: live` 的 profile 会监视 profile manifest 与两份用户 patch 文件,重新读取按顺序排列的组合包层,并应用[重载失败策略](#startup-and-reload-failures)。[DSH HMR](../hmr/README.zh.md) 将这些重载与[插件管理器](../plugin-manager/README.zh.md)的修改串行化,读取变化前等待共享的 profile 包操作锁。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。每一代应用的 patch,无论来自监视器还是管理操作,都会在 Loader 稳定后带着这批 patch 发出 `profile/reconciled`,不论每一行是否都激活。
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 

+ 28 - 3
packages/boot/app-boot/src/index.ts

@@ -227,6 +227,30 @@ export function loadLayeredEnv(
 
 const bootstrapIncludes = new WeakMap<Context, Entry>()
 
+/** The pinned id of the bootstrap Include: app glue, not a config row. */
+const ROOT_INCLUDE_ID = 'include'
+
+/**
+ * The root Include entry of a booted tree: the one this module mounted, else
+ * the entry carrying the pinned root id, which a tree mounted through another
+ * copy of this module (a test harness importing sources beside built packages) has.
+ */
+function rootIncludeOf(ctx: Context): Entry | undefined {
+  return bootstrapIncludes.get(ctx) ?? [...ctx.get('loader')?.entries() ?? []].find(entry => entry.id === ROOT_INCLUDE_ID)
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Events {
+    /**
+     * A complete patch generation was applied to the root Include and the
+     * Loader settled, whether or not every row activated.
+     * @mode emit
+     * @param patches - the ordered patch list that was applied.
+     */
+    'profile/reconciled'(patches: readonly PatchOptions[]): void
+  }
+}
+
 // The include's YAML dialect (`!!js` scalars become expression nodes the
 // Loader interpolates against each entry's injection-ready context), imported
 // from the include itself so patch parsing and config dumping can never drift
@@ -264,7 +288,7 @@ export async function watchUserPatches(
   const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options
   const hmr = ctx.get('hmr')
   if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the HMR service`)
-  const entry = bootstrapIncludes.get(ctx)
+  const entry = rootIncludeOf(ctx)
   if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
   const register = hmr.watchConfig(filename, async () => {
     await reconcileProfilePatches(ctx, compose(loadOptionalPatches(binName, filename) ?? []), binName)
@@ -287,7 +311,7 @@ export async function watchUserPatches(
  * @param binName Diagnostic prefix.
  */
 export async function reconcileProfilePatches(ctx: Context, patches: PatchOptions[], binName: string): Promise<void> {
-  const entry = bootstrapIncludes.get(ctx)
+  const entry = rootIncludeOf(ctx)
   if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`)
   // Removed entries leave the Loader store before their async disposers finish.
   const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber])
@@ -295,6 +319,7 @@ export async function reconcileProfilePatches(ctx: Context, patches: PatchOption
   await entry.update({ config: { ...includeConfig, patches } })
   const results = await Promise.allSettled(previousFibers.map(fiber => fiber.await()))
   await ctx.loader.await()
+  ctx.emit('profile/reconciled', patches)
   const failures = await inactiveEntries(ctx)
   if (failures.length > 0) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd())
   for (const result of results) if (result.status === 'rejected') throw result.reason
@@ -565,7 +590,7 @@ export async function mountRootInclude(
     ...patches.length > 0 ? { patches: [...patches] } : {},
   }
   const rootInclude: EntryOptions = {
-    id: 'include',
+    id: ROOT_INCLUDE_ID,
     name: 'cordis:include',
     config: includeConfig,
   }

+ 17 - 1
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -13,7 +13,7 @@ import { FSWatcher, type ChokidarOptions } from 'chokidar'
 import { Context } from '@deepseek-ai/cordis'
 import Hmr from '@deepseek-ai/dsh-hmr'
 import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import Timer from '@deepseek-ai/cordis-plugin-timer'
 import {
   boot,
@@ -361,6 +361,22 @@ describe('profile reconciliation settlement', () => {
     const ctx = new Context()
     onTestFinished(() => ctx.fiber.dispose())
     await expect(reconcileProfilePatches(ctx, [], NAME)).rejects.toThrow('profile reload requires the root Include entry')
+    // A Loader without the pinned root id is no better.
+    await ctx.plugin(Loader)
+    await expect(reconcileProfilePatches(ctx, [], NAME)).rejects.toThrow('profile reload requires the root Include entry')
+  })
+
+  it('finds a root Include mounted under its pinned id by another copy of this module', async () => {
+    const dir = tmp()
+    writeFileSync(join(dir, 'cordis.yml'), '[]\n')
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.include = Include
+    const root: EntryOptions = { id: 'include', name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }
+    await ctx.loader.create(root)
+    await ctx.loader.await()
+    await expect(reconcileProfilePatches(ctx, [], NAME)).resolves.toBeUndefined()
   })
 
   it('reports a retained activation failure even when the failed entry is removed', async () => {

+ 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: 57fd9988e6866be26bc2e06dae39359ec2b86632
-README.zh.md: 89afffe76854e4c0bbfbc95cedf785e033b33efd
+README.md: 0930e5d53e5435c86d239a5815789ef93aec227d
+README.zh.md: 359ca50efd5a7ead699b801c8388735153f30b8b

+ 12 - 5
packages/boot/plugin-manager/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Enable profile plugins and install, remove or select bundles from the Web settings page or an agent."
+description: "Enable profile plugins and install, remove or select bundles from the Web sidebar or an agent."
 kind: "package-reference"
 ---
 
@@ -25,7 +25,7 @@ Manage the current profile's plugins without editing configuration by hand. Enab
 <a id="use-this-package"></a>
 ## Use this package
 
-Base-backed profiles provide the manager. In Web Settings, open Plugins and select Plugin list to manage bundles and uniquely addressable global plugin entries. Agent-preset rows remain read-only. The `plugin_manager` tool exposes the same operations and is disabled by default.
+Base-backed profiles provide the manager. In Web, the sidebar's **Plugins** page ([ui-plugin-manager](../../client/ui-plugin-manager/README.md)) manages the profile's bundles and their uniquely addressable rows; the Settings Plugin list stays read-only. Agent-preset rows remain read-only. The `plugin_manager` tool exposes the same operations and is disabled by default.
 
 Enable the tool explicitly in the profile patch; agents using a preset also need its `tool-plugin-manager` entry enabled.
 
@@ -36,10 +36,16 @@ Enable the tool explicitly in the profile patch; agents using a preset also need
 
 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.
 
+`inspect(spec)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same registry and proxy settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form. The answer carries the name, version, description, `dsh.title`, and whether the package declares a bundle, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`. A caller's `signal` or `inspectTimeoutMs` ends the lookup.
+
+`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies a failed run from its exit and output, and `bundle` names the package a finished run added. `listBundles` carries each bundle's title, one-liner, the rows its patch declares with their live entries, and the built-in rows it overrides. Every completed operation, and every patch generation applied outside the manager, emits `plugin-manager/changed`.
+
 ### Configuration
 
 | Field | Default | Meaning |
 |---|---|---|
+| `pnpmCommand` | `pnpm` | The pnpm executable name or path, resolved through `PATH` like the `dsh plugin` command. |
+| `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs, in milliseconds. |
 | `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
 | `lockWaitMs` | `120000` | Maximum time in milliseconds to acquire the profile write lock. |
 | `notificationDelayMs` | `250` | Delay in milliseconds for combining operation notices. |
@@ -54,7 +60,7 @@ A plugin toggle writes only its `disabled` override in the profile's `cordis.pat
 
 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.
 
-Saved configuration, package-manager completion and runtime activation are separate outcomes. Failures retain partial changes and diagnostics rather than automatically restoring files or packages. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
+Saved configuration, package-manager completion and runtime activation are separate outcomes. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
 
 </details>
 
@@ -65,7 +71,8 @@ Saved configuration, package-manager completion and runtime activation are separ
 
 - [App boot](../app-boot/README.md) — profile layers and startup policy.
 - [Plugin inventory](../../host/plugin-inventory/README.md) — current Loader and preset observations.
-- [Plugin settings](../../client/ui-settings-plugin-inventory/README.md) — Web controls.
+- [Plugin manager page](../../client/ui-plugin-manager/README.md) — the Web sidebar page over this service.
+- [Plugin settings](../../client/ui-settings-plugin-inventory/README.md) — the read-only Web inventory.
 
 <a id="model-experience"></a>
 ## Model Experience
@@ -105,7 +112,7 @@ Notices append context; they do not rewrite earlier messages.
 - Package replacements require restarting the process to load a fresh JavaScript module generation.
 - Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`.
 - The manager cannot disable its own management components, change another profile, or edit an agent preset's composition.
-- Package failures may leave dependencies partially changed. Inactive dependencies with missing files remain removable. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory.
+- A failed removal may leave dependencies partially changed, and a failed or cancelled installation can leave downloaded files under `node_modules` or the pnpm store. Inactive dependencies with missing files remain removable. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory.
 - Browser bundle changes require a page refresh to load the current Client module graph. Management results describe Host activation.
 - Desktop package operations remain owned by the Desktop shell.
 

+ 12 - 5
packages/boot/plugin-manager/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "通过 Web 设置页或 agent 启停 profile 插件,并安装、删除或选择组合包。"
+description: "通过 Web 侧边栏或 agent 启停 profile 插件,并安装、删除或选择组合包。"
 kind: "package-reference"
 ---
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-基于 base 的 profile 提供管理服务。在 Web 设置中打开插件并选择插件列表,即可管理组合包和能唯一定位的全局插件条目。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,默认禁用。
+基于 base 的 profile 提供管理服务。在 Web 中,侧边栏的**插件**页([ui-plugin-manager](../../client/ui-plugin-manager/README.zh.md))管理 profile 的组合包及其能唯一定位的行;设置页的插件列表保持只读。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,默认禁用。
 
 在 profile patch 中显式启用工具;使用预设的 Agent 还需要启用该预设中的 `tool-plugin-manager` 条目。
 
@@ -36,10 +36,16 @@ kind: "package-reference"
 
 插件开关只写入 profile 的 `cordis.patch.yml` 中的 `disabled` 覆盖项。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
 
+`inspect(spec)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的注册表与代理设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式。答复携带名称、版本、描述、`dsh.title` 以及该包是否声明组合包,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。
+
+`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对失败运行分类,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的标题、一句话简介、其 patch 声明的行及其存活条目,以及它覆盖的内置行。每个完成的操作,以及在管理器之外应用的每一代 patch,都会发出 `plugin-manager/changed`。
+
 ### 配置
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
+| `pnpmCommand` | `pnpm` | pnpm 可执行文件名或路径,与 `dsh plugin` 命令一样通过 `PATH` 解析。 |
+| `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 |
 | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
 | `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 |
 | `notificationDelayMs` | `250` | 合并操作通知的延迟毫秒数。 |
@@ -54,7 +60,7 @@ kind: "package-reference"
 
 服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。包管理操作持有 profile manifest 锁;文件监听器在锁释放后读取完成的状态。
 
-配置保存、包管理器完成和运行时激活分别报告。失败保留部分改动和诊断,不自动恢复文件或包。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
+配置保存、包管理器完成和运行时激活分别报告。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
 
 </details>
 
@@ -65,7 +71,8 @@ kind: "package-reference"
 
 - [App boot](../app-boot/README.zh.md)——profile 配置层与启动策略。
 - [Plugin inventory](../../host/plugin-inventory/README.zh.md)——当前 Loader 和预设状态。
-- [Plugin settings](../../client/ui-settings-plugin-inventory/README.zh.md)——Web 控件。
+- [插件管理页](../../client/ui-plugin-manager/README.zh.md)——基于本服务的 Web 侧边栏页面。
+- [Plugin settings](../../client/ui-settings-plugin-inventory/README.zh.md)——只读的 Web 清单。
 
 <a id="model-experience"></a>
 ## 模型体验
@@ -105,7 +112,7 @@ kind: "package-reference"
 - 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。
 - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。
 - 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。
-- 包管理失败可能留下部分依赖改动。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。
+- 失败的删除可能留下部分依赖改动,失败或被取消的安装可能在 `node_modules` 或 pnpm 缓存中留下已下载文件。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。
 - 浏览器组合包变化需要刷新页面后才能加载当前 Client 模块图。管理结果描述 Host 激活状态。
 - Desktop 包管理操作仍由 Desktop shell 负责。
 

+ 4 - 2
packages/boot/plugin-manager/package.json

@@ -69,7 +69,8 @@
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/dsh-hmr": "workspace:^"
+    "@deepseek-ai/dsh-hmr": "workspace:^",
+    "@deepseek-ai/dsh-brand": "workspace:^"
   },
   "peerDependenciesMeta": {
     "@deepseek-ai/dsh-agent": {
@@ -91,6 +92,7 @@
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-hmr": "workspace:^",
-    "@deepseek-ai/cordis-plugin-timer": "workspace:^"
+    "@deepseek-ai/cordis-plugin-timer": "workspace:^",
+    "@deepseek-ai/dsh-brand": "workspace:^"
   }
 }

+ 308 - 36
packages/boot/plugin-manager/src/index.ts

@@ -1,42 +1,62 @@
 /** Current-profile plugin and bundle management over shared dsh plugin operations. */
-import { readFileSync } from 'node:fs'
+import { randomUUID } from 'node:crypto'
+import { existsSync, readFileSync } from 'node:fs'
+import { readFile, rm } from 'node:fs/promises'
 import { join } from 'node:path'
-import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
+import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { Context } from '@deepseek-ai/cordis'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import z from '@deepseek-ai/schemastery'
 import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
-import { readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
+import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
 import { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches } from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-hmr'
-import type { ProfileContext } from '@deepseek-ai/dsh-app-boot'
+import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import { bundleManifest, runProfilePnpm, saveManifest } from './operations.ts'
+import { bundleManifest, runProfilePnpm, saveManifest, viewProfilePackage } from './operations.ts'
+import { classifyInstallFailure } from './install-failure.ts'
+import { InvalidInstallSpecError, parseInstallSpec } from './install-spec.ts'
 import { writePluginEnabled } from './patch.ts'
-import type { BundleInfo, ChangeResult, InstallBundleOptions, PackageResult, PluginEntryId, PluginInfo } from './types.ts'
+import type {
+  BundleInfo, BundleRowInfo, ChangeResult, InstallBundleOptions, PackageResult, PluginChange, PluginEntryId, PluginInfo,
+  PluginInspectProblem, PluginInstallCancellation, PluginInstallRequestId, PluginSpecInspection,
+} from './types.ts'
 export type * from './types.ts'
+export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts'
+export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
 
-/** Limits for package diagnostics and change notifications. */
+/** The pnpm executable and the limits for package diagnostics, registry lookups, and change notifications. */
 export interface Config {
+  /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
+  pnpmCommand?: string
   /** Maximum retained pnpm diagnostic bytes per operation. */
   outputBytes?: number
   /** Delay for combining consecutive management notices in one durable injection. */
   notificationDelayMs?: number
   /** Maximum time to wait for another process's profile package operation. */
   lockWaitMs?: number
+  /** Bound on one registry lookup an inspection runs, in milliseconds. */
+  inspectTimeoutMs?: number
 }
 
 const protectedModules = new Set([
   '@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/cordis-plugin-loader',
   '@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-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager',
   '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection',
   '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools',
   '@deepseek-ai/dsh-hmr',
 ])
 
+/** The profile files an installation writes and a failed or cancelled one restores. */
+const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'] as const
+
+/** pnpm's colour escapes, which a JSON answer may be wrapped in. */
+const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g
+
 /** Flatten only the groups addressable by the profile's patch composer. */
 function flatten(rows: EntryOptions[]): EntryOptions[] {
   return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])])
@@ -45,6 +65,51 @@ function flatten(rows: EntryOptions[]): EntryOptions[] {
 /** Preserve the exact observed diagnostic, including non-Error failures. */
 function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
 
+/** The caller stopped an installation; its files are restored before this is thrown. */
+class InstallCancelledError extends Error {
+  constructor() {
+    super('Installation cancelled')
+    this.name = 'InstallCancelledError'
+  }
+}
+
+/** One installation the manager owns until its call settles. */
+interface InstallControl {
+  readonly abort: AbortController
+  /** `applying` once pnpm has exited and the bundle is being selected and loaded, which cannot be stopped. */
+  phase: 'installing' | 'applying'
+  /** Settlement of the install call, whichever way it ended. */
+  settled: Promise<void>
+}
+
+/** A manifest field that is a string, when the manifest carries one. */
+function stringField(manifest: object, field: string): string | undefined {
+  const value = (manifest as Record<string, unknown>)[field]
+  return typeof value === 'string' ? value : undefined
+}
+
+/** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */
+function inspectionOf(kind: 'registry' | 'path', manifest: object): Extract<PluginSpecInspection, { status: 'accepted' }> {
+  const dsh = (manifest as { dsh?: unknown }).dsh
+  const declared = typeof dsh === 'object' && dsh !== null ? dsh as { title?: unknown; bundle?: unknown } : undefined
+  const title = declared === undefined ? undefined : stringField(declared, 'title')
+  const bundle = declared !== undefined && typeof declared.bundle === 'object' && declared.bundle !== null
+  const name = stringField(manifest, 'name')
+  const version = stringField(manifest, 'version')
+  const description = stringField(manifest, 'description')
+  return {
+    status: 'accepted', kind, bundle,
+    ...name === undefined ? {} : { name },
+    ...version === undefined ? {} : { version },
+    ...description === undefined || description === '' ? {} : { description },
+    ...title === undefined ? {} : { title },
+  }
+}
+
+function refused(problem: PluginInspectProblem, reason: string): PluginSpecInspection {
+  return { status: 'refused', problem, reason }
+}
+
 declare module '@deepseek-ai/cordis' {
   interface Context {
     /** Persistent management of the current profile's composition and packages. */
@@ -56,16 +121,20 @@ declare module '@deepseek-ai/cordis' {
 export class PluginManager extends TypertRemoteService {
   static inject = ['loader', 'profileContext']
   static Config: z<Config> = z.object({
+    pnpmCommand: z.string().default('pnpm'),
     outputBytes: z.number().step(1).min(1).default(16384),
     notificationDelayMs: z.number().step(1).min(0).default(250),
     lockWaitMs: z.number().step(1).min(0).default(120000),
+    inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
   })
   private readonly ownerEntryId: string | undefined
-  private readonly packageOperations = new Set<Promise<PackageResult>>()
+  private readonly packageOperations = new Set<Promise<unknown>>()
   private readonly profile: ProfileContext
   private readonly outputBytes: number
   private readonly notificationDelayMs: number
   private readonly lockWaitMs: number
+  private readonly inspectTimeoutMs: number
+  private readonly pnpmCommand: string
   private readonly ownerContext: Context
   private pendingNotice = ''
   private omittedNotices = 0
@@ -73,6 +142,10 @@ export class PluginManager extends TypertRemoteService {
   private noticeDelivered: PromiseWithResolvers<void> | undefined
   private readonly noticeAgents = new Set<Agent>()
   private readonly abort = new AbortController()
+  /** Installations by request id, from their call until it settles. */
+  private readonly installs = new Map<PluginInstallRequestId, InstallControl>()
+  /** A manager operation holds the profile lock; reconciliations it runs announce through its own change. */
+  private operating = false
 
   constructor(ctx: Context, config: Config) {
     super(ctx, 'pluginManager')
@@ -82,12 +155,19 @@ export class PluginManager extends TypertRemoteService {
     this.outputBytes = (config as Required<Config>).outputBytes
     this.notificationDelayMs = (config as Required<Config>).notificationDelayMs
     this.lockWaitMs = (config as Required<Config>).lockWaitMs
+    this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
+    this.pnpmCommand = (config as Required<Config>).pnpmCommand
     ctx.effect(() => async () => {
       this.abort.abort()
       await Promise.allSettled([...this.packageOperations])
       clearTimeout(this.noticeTimer)
       this.flushNotice()
     }, 'plugin-manager: package cancellation')
+    // A patch generation applied outside a manager operation — the launcher's
+    // file watcher after a CLI or hand edit — changes what the lists show.
+    ctx.on('profile/reconciled', () => {
+      if (!this.operating) ctx.emit('plugin-manager/changed', { reason: 'reload' })
+    })
   }
 
   /** Read current plugins, including why a row cannot be changed through the profile patch.
@@ -114,7 +194,7 @@ export class PluginManager extends TypertRemoteService {
   }
 
   /** Read installed bundles and bundles supplied by this dsh installation.
-   * @returns Package versions, activation selections and removal availability.
+   * @returns Package versions, titles, rows, activation selections and removal availability.
    */
   @Remote
   listBundles(): Promise<BundleInfo[]> {
@@ -125,26 +205,101 @@ export class PluginManager extends TypertRemoteService {
     const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])]
     const bundles: BundleInfo[] = []
     for (const name of names) {
-      const removable = dependencies.includes(name) && !Object.hasOwn(installation.dependencies ?? {}, name)
+      const installed = dependencies.includes(name)
+      const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name)
+      const enabled = selected.includes(name)
       try {
         const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
         if (info === undefined) {
-          if (selected.includes(name)) bundles.push({ name, enabled: true, removable, error: `Not a bundle: ${name}` })
+          if (enabled) bundles.push({ name, enabled, installed, removable, error: `Not a bundle: ${name}`, rows: [], overrides: [] })
           continue
         }
         const readOnlyReason = this.protectsManager(name) ? 'This bundle provides plugin management components' : undefined
+        const title = info.dsh?.title
         bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
-          enabled: selected.includes(name), removable: removable && readOnlyReason === undefined,
-          ...(readOnlyReason === undefined ? {} : { readOnlyReason }) })
+          ...(title === undefined ? {} : { title }),
+          ...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
+          enabled, installed, removable: removable && readOnlyReason === undefined,
+          ...(readOnlyReason === undefined ? {} : { readOnlyReason }),
+          ...this.bundleRows(name, info) })
       } catch (error) {
-        if (selected.includes(name) || dependencies.includes(name)) {
-          bundles.push({ name, enabled: selected.includes(name), removable, error: messageOf(error) })
+        if (enabled || installed) {
+          bundles.push({ name, enabled, installed, removable, error: messageOf(error), rows: [], overrides: [] })
         }
       }
     }
     return Promise.resolve(bundles)
   }
 
+  /** Read what a spec names before installing it.
+   * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball.
+   * @param signal Ends a registry lookup early.
+   * @returns The package the spec names, or why it is refused.
+   */
+  @Remote
+  async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection> {
+    let parsed
+    try {
+      parsed = parseInstallSpec(spec)
+    } catch (error) {
+      /* v8 ignore next 2 -- parseInstallSpec throws nothing but its own refusal */
+      if (!(error instanceof InvalidInstallSpecError)) throw error
+      return refused('invalid-spec', error.reason)
+    }
+    const manifest = readProfileManifest('dsh', this.profile.dir)
+    const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as { dependencies?: Record<string, string> }
+    const known = new Set([
+      ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}),
+    ])
+    switch (parsed.kind) {
+      case 'git': return { status: 'accepted', kind: 'git', bundle: null }
+      case 'tarball':
+        if (parsed.path !== undefined && !existsSync(parsed.path)) return refused('not-a-package', 'the tarball does not exist')
+        return { status: 'accepted', kind: 'tarball', bundle: null }
+      case 'path': {
+        if (!existsSync(parsed.path)) return refused('not-a-package', 'the path does not exist')
+        let read: object
+        try {
+          read = JSON.parse(await readFile(join(parsed.path, 'package.json'), 'utf8')) as object
+        } catch (error) {
+          return refused('not-a-package', `no readable package.json at the path: ${messageOf(error)}`)
+        }
+        const inspection = inspectionOf('path', read)
+        if (inspection.name === undefined) return refused('not-a-package', 'the package.json names no package')
+        if (known.has(inspection.name)) return refused('already-installed', `${inspection.name} is already installed`)
+        if (!inspection.bundle) return refused('not-a-bundle', `${inspection.name} declares no dsh.bundle`)
+        return inspection
+      }
+      case 'registry': {
+        if (known.has(parsed.name)) return refused('already-installed', `${parsed.name} is already installed`)
+        const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
+          command: this.pnpmCommand, timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal },
+        })
+        const log = `${view.stderr}${view.cause === undefined ? '' : `${messageOf(view.cause)}\n`}`.trim()
+        if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) {
+          const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } })
+          const reason = log || view.stdout.trim() || `pnpm view exited with ${String(view.exitCode)}`
+          if (kind === 'not-found' || kind === 'no-matching-version') return refused('not-found', reason)
+          if (kind === 'network') return refused('network', reason)
+          return refused('unknown', view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : reason)
+        }
+        let answer: unknown
+        try {
+          answer = JSON.parse(view.stdout.replace(ANSI_SEQUENCE, '').trim() || 'null')
+        } catch (error) {
+          return refused('unknown', `unreadable pnpm view output: ${messageOf(error)}`)
+        }
+        // A range answers one object per matching version, oldest first.
+        const latest: unknown = Array.isArray(answer) ? answer.at(-1) : answer
+        if (typeof latest !== 'object' || latest === null) return refused('unknown', 'pnpm view answered no package')
+        const inspection = inspectionOf('registry', latest)
+        const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection
+        if (!named.bundle) return refused('not-a-bundle', `${named.name} declares no dsh.bundle`)
+        return named
+      }
+    }
+  }
+
   /** Persist a plugin entry's desired enablement and apply it on live profiles.
    * @param id Loader entry identity returned by listPlugins.
    * @param enabled Whether the plugin should run.
@@ -160,7 +315,7 @@ export class PluginManager extends TypertRemoteService {
       await this.reload()
       const current = (await this.listPlugins()).find(item => item.entryId === id)
       return current?.enabled !== enabled && this.profile.patchReload === 'live' ? 'overridden' : undefined
-    }, `Plugin ${id}: ${enabled ? 'enabled' : 'disabled'}.`)
+    }, `Plugin ${id}: ${enabled ? 'enabled' : 'disabled'}.`, 'plugin')
   }
 
   /** Select or remove a bundle layer while retaining installed dependencies.
@@ -173,36 +328,83 @@ export class PluginManager extends TypertRemoteService {
     return this.change(async () => {
       await this.selectBundle(name, enabled)
       await this.reload()
-    }, `Bundle ${name}: ${enabled ? 'enabled' : 'disabled'}.`)
+    }, `Bundle ${name}: ${enabled ? 'enabled' : 'disabled'}.`, 'bundle')
   }
 
-  /** Install a package using the same pnpm implementation as dsh plugin.
+  /**
+   * Install a package using the same pnpm implementation as dsh plugin. A run
+   * that fails, is cancelled, or adds a package without a bundle patch restores
+   * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
    * @param spec One package spec, including local paths relative to the invocation directory.
-   * @param options Whether to activate the installed bundle; defaults to true.
+   * @param options Whether to activate the installed bundle (defaults to true) and the request id a cancellation names.
    * @returns Package-manager diagnostics and observed activation outcome.
    */
   @Remote
   installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult> {
+    const requestId = options?.requestId
+    const control: InstallControl = { abort: new AbortController(), phase: 'installing', settled: Promise.resolve() }
+    const stopped = (): boolean => control.abort.signal.aborted
+    if (requestId !== undefined) this.installs.set(requestId, control)
     let packageResult: PackageResult | undefined
-    return this.change(async () => {
+    let bundle: string | undefined
+    const result = this.change(async () => {
       if (spec.trim() === '' || spec.startsWith('-')) throw new Error('A package spec is required')
+      if (stopped()) throw new InstallCancelledError()
+      const files = await this.readRestoredFiles()
       const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
-      packageResult = await this.runPnpm(['add', spec])
-      if (packageResult.exitCode !== 0) throw new Error(packageResult.output)
+      if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase: 'installing' })
+      packageResult = await this.runPnpm(['add', spec], control.abort.signal, requestId)
+      if (stopped()) {
+        await this.restoreFiles(files)
+        throw new InstallCancelledError()
+      }
+      if (packageResult.exitCode !== 0) {
+        await this.restoreFiles(files)
+        throw new Error(packageResult.output)
+      }
       const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
       const installed = Object.keys(after).filter(name => before[name] !== after[name])
       // Registry retries can retain the saved range after a partial installation.
       if (installed.length === 0) installed.push(...Object.keys(after).filter(name => spec === name || spec.startsWith(`${name}@`)))
       const name = installed[0]
-      if (installed.length !== 1 || name === undefined) throw new Error('Cannot identify one installed bundle from the dependency change')
-      const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
+      if (installed.length !== 1 || name === undefined) {
+        await this.restoreFiles(files)
+        throw new Error('Cannot identify one installed bundle from the dependency change')
+      }
       const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
-      if (manifest?.dsh?.bundle?.patch === undefined) throw new Error(`${name} declares no dsh.bundle.patch`)
+      if (manifest?.dsh?.bundle?.patch === undefined) {
+        await this.restoreFiles(files)
+        throw new Error(`${name} declares no dsh.bundle.patch`)
+      }
+      control.phase = 'applying'
+      if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase: 'applying' })
+      const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
       loadOverlayPatches('dsh', join(dir, manifest.dsh.bundle.patch))
+      bundle = name
       if (options?.enabled !== false) await this.selectBundle(name, true)
       if (Object.hasOwn(before, name)) return 'restart-required'
       await this.reload()
-    }, `Bundle installation: ${spec}.`, () => packageResult)
+    }, `Bundle installation: ${spec}.`, 'install', () => packageResult)
+      .then(outcome => (bundle === undefined ? outcome : { ...outcome, bundle }))
+    /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */
+    control.settled = result.then(() => undefined, () => undefined)
+    return result.finally(() => { if (requestId !== undefined) this.installs.delete(requestId) })
+  }
+
+  /** Stop an installation this manager owns and wait until its files are back.
+   * @param requestId The id the installation was started with.
+   * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
+   * applied, `not-running` for any other id.
+   */
+  @Remote
+  async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation> {
+    const control = this.installs.get(requestId)
+    if (control === undefined) return { status: 'not-running' }
+    if (control.phase === 'applying') return { status: 'too-late' }
+    this.ownerContext.emit('plugin-manager/install-state', { requestId, phase: 'cancelling' })
+    control.abort.abort()
+    await control.settled
+    return { status: 'cancelled' }
   }
 
   /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
@@ -222,19 +424,84 @@ export class PluginManager extends TypertRemoteService {
         await this.selectBundle(name, false)
         await this.reload()
       }
-      packageResult = await this.runPnpm(['remove', name])
+      packageResult = await this.runPnpm(['remove', name], undefined, undefined)
       if (packageResult.exitCode !== 0) throw new Error(packageResult.output)
       await this.reload()
-    }, `Bundle removed: ${name}.`, () => packageResult)
+    }, `Bundle removed: ${name}.`, 'remove', () => packageResult)
   }
 
-  private async runPnpm(args: readonly string[]): Promise<PackageResult> {
+  /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
+  private bundleRows(name: string, info: ProfileManifest): Pick<BundleInfo, 'rows' | 'overrides'> {
+    const patch = info.dsh?.bundle?.patch
+    /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */
+    if (patch === undefined) return { rows: [], overrides: [] }
+    const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
+    const patches: PatchOptions[] = loadOverlayPatches('dsh', join(dir, patch))
+    // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last.
+    const live = new Map<string, PluginEntryId>()
+    for (const entry of this.ctx.loader.entries()) {
+      /* v8 ignore next -- the Loader gives every entry an id before it is listed */
+      if (typeof entry.options.id === 'string') live.set(entry.options.id, pluginEntryId(entry.id))
+    }
+    const rows: BundleRowInfo[] = []
+    for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) {
+      if (typeof row.id !== 'string' || typeof row.name !== 'string') continue
+      const entryId = live.get(row.id)
+      rows.push({ rowId: row.id, moduleName: row.name, ...entryId === undefined ? {} : { entryId } })
+    }
+    const declared = new Set(rows.map(row => row.rowId))
+    const overrides = [...new Set(patches.flatMap(item =>
+      item.insert === undefined && typeof item.id === 'string' && !declared.has(item.id) ? [item.id] : []))]
+    return { rows, overrides }
+  }
+
+  /** Run one pnpm command in the profile, streaming its output as install-log chunks. */
+  private async runPnpm(
+    args: readonly string[], signal: AbortSignal | undefined, requestId: PluginInstallRequestId | undefined,
+  ): Promise<PackageResult> {
+    const jobId = randomUUID()
+    const argv = ['pnpm', ...args]
+    const cwd = this.profile.dir
+    const identity = requestId === undefined ? {} : { requestId }
     const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, {
-      signal: this.abort.signal, outputBytes: this.outputBytes, activateNewBundles: false,
+      command: this.pnpmCommand,
+      signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
+      outputBytes: this.outputBytes, activateNewBundles: false,
+      onOutput: (text, stream) => {
+        this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text })
+      },
     })
     this.packageOperations.add(task)
-    try { return await task }
-    finally { this.packageOperations.delete(task) }
+    try {
+      const result = await task
+      this.ownerContext.emit('plugin-manager/install-log', {
+        ...identity, jobId, argv, cwd, stream: 'stdout', text: '', exitCode: signal?.aborted === true ? null : result.exitCode,
+      })
+      return result.exitCode === 0 ? result : { ...result, kind: classifyInstallFailure({ log: result.output }) }
+    } catch (error) {
+      this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stderr', text: messageOf(error), exitCode: null })
+      throw error
+    } finally {
+      this.packageOperations.delete(task)
+    }
+  }
+
+  /** The profile files an installation may rewrite, as they are now; absent files read as undefined. */
+  private async readRestoredFiles(): Promise<Map<string, string | undefined>> {
+    const files = new Map<string, string | undefined>()
+    for (const name of RESTORED_FILES) {
+      const path = join(this.profile.dir, name)
+      files.set(path, existsSync(path) ? await readFile(path, 'utf8') : undefined)
+    }
+    return files
+  }
+
+  /** Put the profile files back; pnpm has exited by the time this runs. */
+  private async restoreFiles(files: Map<string, string | undefined>): Promise<void> {
+    for (const [path, content] of files) {
+      if (content === undefined) await rm(path, { force: true })
+      else await writeFileAtomic(path, content, { mode: 0o600 })
+    }
   }
 
   private async selectBundle(name: string, enabled: boolean): Promise<void> {
@@ -266,23 +533,28 @@ export class PluginManager extends TypertRemoteService {
   }
 
   private async change(
-    operation: () => Promise<ChangeResult['application'] | void>, message: string, packageResult?: () => PackageResult | undefined,
+    operation: () => Promise<ChangeResult['application'] | void>, message: string, reason: PluginChange['reason'],
+    packageResult?: () => PackageResult | undefined,
   ): Promise<ChangeResult> {
     let notice: Promise<void> | undefined
     const locked = () => withFileLock(join(this.profile.dir, 'package.json'), async () => {
       this.abort.signal.throwIfAborted()
+      this.operating = true
       const before = this.diskState()
       let application: ChangeResult['application'] = this.profile.patchReload === 'live' ? 'applied' : 'restart-required'
       try {
         application = await operation() ?? application
       } catch (error) {
-        application = 'failed'
+        application = error instanceof InstallCancelledError ? 'cancelled' : 'failed'
         message = messageOf(error)
+      } finally {
+        this.operating = false
       }
       const result: ChangeResult = { changed: before !== this.diskState(), application, message }
       const packages = packageResult?.()
       if (packages !== undefined) result.packageResult = packages
       notice = this.notify(result)
+      this.ownerContext.emit('plugin-manager/changed', { reason })
       return result
     }, { waitMs: this.lockWaitMs })
     const hmr = this.ownerContext.get('hmr')
@@ -298,7 +570,7 @@ export class PluginManager extends TypertRemoteService {
         if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
         throw error
       }
-    }).join('\u0000')
+    }).join('\0')
   }
 
   private notify(result: ChangeResult): Promise<void> {

+ 43 - 0
packages/boot/plugin-manager/src/install-failure.ts

@@ -0,0 +1,43 @@
+/**
+ * What a failed pnpm run was, read off how it ended and what it printed:
+ * pnpm names its failures with stable `ERR_PNPM_*` codes and Node's errno
+ * names, which the run's captured tail carries whatever the locale.
+ * @module @deepseek-ai/dsh-plugin-manager/install-failure
+ */
+
+import type { PluginInstallFailureKind } from './types.ts'
+
+/** How a run ended, beyond its exit code. */
+export interface InstallFailureFacts {
+  /** The output tail the failure reports. */
+  readonly log: string
+  /** The spawn error, when the child never ran. */
+  readonly cause?: unknown
+  /** Whether the run outlived its bound. */
+  readonly timedOut?: boolean
+}
+
+/** Patterns in the order they decide: a specific code before the generic network family. */
+const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [
+  ['build-blocked', /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/],
+  ['not-found', /ERR_PNPM_FETCH_404|\bE404\b|404 Not Found|Not Found - GET/],
+  ['no-matching-version', /ERR_PNPM_NO_MATCHING_VERSION|\bETARGET\b|No matching version/],
+  ['disk-full', /\bENOSPC\b|no space left on device/i],
+  ['permission', /\bEACCES\b|\bEPERM\b|permission denied/i],
+  ['integrity', /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
+  ['network', /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|socket hang up|Could not resolve host|unable to access/],
+]
+
+/**
+ * Classify a failed run.
+ * @param facts - how the run ended and what it printed.
+ * @returns the kind, `unknown` when nothing in the facts names one.
+ */
+export function classifyInstallFailure(facts: InstallFailureFacts): PluginInstallFailureKind {
+  if (facts.timedOut === true) return 'timeout'
+  if ((facts.cause as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return 'pnpm-missing'
+  for (const [kind, pattern] of LOG_KINDS) {
+    if (pattern.test(facts.log)) return kind
+  }
+  return 'unknown'
+}

+ 74 - 0
packages/boot/plugin-manager/src/install-spec.ts

@@ -0,0 +1,74 @@
+/**
+ * Reading an install spec before pnpm sees it: which of pnpm's spec forms it
+ * takes, and for a registry name whether it is one the registry can accept.
+ * @module @deepseek-ai/dsh-plugin-manager/install-spec
+ */
+
+import { isAbsolute } from 'node:path'
+
+/** One spec read into its form and the parts an inspection needs. */
+export type ParsedInstallSpec =
+  | { readonly kind: 'registry'; readonly spec: string; readonly name: string; readonly range?: string }
+  | { readonly kind: 'path'; readonly spec: string; readonly path: string }
+  | { readonly kind: 'tarball'; readonly spec: string; readonly path?: string }
+  | { readonly kind: 'git'; readonly spec: string }
+
+/** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */
+const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i
+const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i
+const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i
+/** A tarball, on disk or over HTTP. */
+const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i
+/** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
+const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/
+const PACKAGE_NAME_MAX_LENGTH = 214
+
+/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
+export class InvalidInstallSpecError extends Error {
+  /**
+   * @param spec - the spec as typed, trimmed.
+   * @param reason - why it is refused, as one sentence.
+   */
+  constructor(readonly spec: string, readonly reason: string) {
+    super(`plugin-manager: ${reason}: ${spec}`)
+    this.name = 'InvalidInstallSpecError'
+  }
+}
+
+function invalid(spec: string, reason: string): InvalidInstallSpecError {
+  return new InvalidInstallSpecError(spec, reason)
+}
+
+/**
+ * Read a spec into its form. A path must be absolute: the Host's working
+ * directory means nothing to the person typing into a browser, and a
+ * relative path resolved against the profile would point inside it.
+ * @param raw - the spec as typed.
+ * @returns the parsed spec.
+ * @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the
+ * registry would refuse, or a URL that is neither a git host nor a tarball.
+ */
+export function parseInstallSpec(raw: string): ParsedInstallSpec {
+  const spec = raw.trim()
+  if (spec === '') throw invalid(spec, 'the package spec must not be empty')
+  const path = spec.replace(/^(?:file|link):/, '')
+  if (path !== spec || isAbsolute(path)) {
+    if (!isAbsolute(path)) throw invalid(spec, 'a local path must be absolute')
+    return TARBALL_SPEC.test(path) ? { kind: 'tarball', spec, path } : { kind: 'path', spec, path }
+  }
+  if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, 'a local path must be absolute')
+  const git = GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec)
+  if (git && !TARBALL_SPEC.test(spec)) return { kind: 'git', spec }
+  if (/^https?:\/\//i.test(spec)) {
+    if (TARBALL_SPEC.test(spec)) return { kind: 'tarball', spec }
+    throw invalid(spec, 'a URL must point at a git repository or a tarball')
+  }
+  const at = spec.indexOf('@', 1)
+  const name = at === -1 ? spec : spec.slice(0, at)
+  const range = at === -1 ? undefined : spec.slice(at + 1)
+  if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) {
+    throw invalid(spec, 'not a package name the registry accepts')
+  }
+  if (range === '') throw invalid(spec, 'a version after @ must not be empty')
+  return range === undefined ? { kind: 'registry', spec, name } : { kind: 'registry', spec, name, range }
+}

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

@@ -21,6 +21,8 @@ export interface PackageOperationContext {
 
 /** Output and cancellation policy for one pnpm operation. */
 export interface PackageOperationOptions {
+  /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. Defaults to `pnpm`. */
+  command?: string
   signal?: AbortSignal
   outputBytes: number
   onOutput?: (text: string, stream: 'stdout' | 'stderr') => void
@@ -105,7 +107,7 @@ export async function runProfilePnpm(
   let output = Buffer.alloc(0)
   let truncated = false
   const cancellation = new AbortController()
-  const child = execa('pnpm', args.map(arg => anchorPathSpec(arg, context.cwd)), {
+  const child = execa(options.command ?? 'pnpm', args.map(arg => anchorPathSpec(arg, context.cwd)), {
     cwd: dir, env: scrubbedParentEnv(), extendEnv: false, reject: false,
     buffer: false, stdin: 'ignore', cancelSignal: options.signal === undefined
       ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
@@ -169,3 +171,47 @@ export async function runPluginCommand(
     return runProfilePnpm(context, args, options)
   }, options.lockWaitMs === undefined ? undefined : { waitMs: options.lockWaitMs })
 }
+
+/** What one registry lookup answered. */
+export interface PackageViewResult {
+  /** pnpm's exit code, null when it ended without one or never started. */
+  exitCode: number | null
+  stdout: string
+  stderr: string
+  /** The lookup ran past its bound and was killed. */
+  timedOut: boolean
+  /** The failure of starting pnpm at all, when that is what happened. */
+  cause?: unknown
+}
+
+/** Bounds of one registry lookup. */
+export interface PackageViewOptions {
+  /** The pnpm executable name or path. Defaults to `pnpm`. */
+  command?: string
+  /** Ends the lookup early; the caller's signal, when it has one. */
+  signal?: AbortSignal
+  /** Bound on the lookup, in milliseconds. */
+  timeoutMs: number
+}
+
+/**
+ * Ask the registry what a spec names through `pnpm view`, run in the profile
+ * directory so the registry, proxy, and authentication settings of an install apply.
+ * @param dir Profile directory.
+ * @param spec One registry spec: a package name with an optional range.
+ * @param options Cancellation and the time bound.
+ * @returns pnpm's exit, output, and how the lookup ended.
+ */
+export async function viewProfilePackage(dir: string, spec: string, options: PackageViewOptions): Promise<PackageViewResult> {
+  const result = await execa(options.command ?? 'pnpm', ['view', spec, 'name', 'version', 'description', 'dsh', '--json'], {
+    cwd: dir, env: scrubbedParentEnv(), extendEnv: false, reject: false, stdin: 'ignore',
+    timeout: options.timeoutMs, ...options.signal === undefined ? {} : { cancelSignal: options.signal },
+  })
+  const cause = result.exitCode === undefined && !result.timedOut && !result.isCanceled
+    ? Object.assign(new Error(result.shortMessage), { code: result.code })
+    : undefined
+  return {
+    exitCode: result.exitCode ?? null, stdout: result.stdout, stderr: result.stderr, timedOut: result.timedOut,
+    ...cause === undefined ? {} : { cause },
+  }
+}

+ 139 - 2
packages/boot/plugin-manager/src/types.ts

@@ -1,6 +1,8 @@
 /** Public plugin management records shared with clients. */
+import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types'
 export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types'
+import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types'
 
 /** One running-profile entry and its persistent control availability. */
 export interface PluginInfo extends PluginInventoryEntry {
@@ -10,33 +12,168 @@ export interface PluginInfo extends PluginInventoryEntry {
   readOnlyReason?: string
 }
 
+/** One row a bundle's patch declares, with its live entry while the bundle contributes it. */
+export interface BundleRowInfo {
+  /** The row id as the patch declares it. */
+  rowId: string
+  /** The module the row names. */
+  moduleName: string
+  /** The Loader entry carrying this row, when exactly one live entry has its id. */
+  entryId?: PluginEntryId
+}
+
 /** One installed or installation-provided bundle. */
 export interface BundleInfo {
   name: string
   version?: string
+  /** `dsh.title` of the package manifest. */
+  title?: string
+  /** `description` of the package manifest. */
+  description?: string
   enabled: boolean
+  /** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */
+  installed: boolean
   removable: boolean
   readOnlyReason?: string
   error?: string
+  /** The rows the bundle's patch inserts, in declaration order; empty when the patch cannot be read. */
+  rows: BundleRowInfo[]
+  /** Ids of rows the bundle's patch changes without declaring them: the built-in rows it configures or disables. */
+  overrides: string[]
 }
 
+/** How a pnpm run failed, read off how it ended and what it printed. */
+export type PluginInstallFailureKind =
+  | 'pnpm-missing'
+  | 'timeout'
+  | 'not-found'
+  | 'no-matching-version'
+  | 'network'
+  | 'disk-full'
+  | 'permission'
+  | 'build-blocked'
+  | 'integrity'
+  | 'unknown'
+
 /** Pnpm completion, including a retrieval path for unabridged diagnostics. */
 export interface PackageResult {
   exitCode: number
   output: string
   truncated: boolean
   logPath: string
+  /** Present when the run failed: what kind of failure its exit and output describe. */
+  kind?: PluginInstallFailureKind
 }
 
 /** Persisted change and independently observed application outcome. */
 export interface ChangeResult {
   changed: boolean
-  application: 'applied' | 'restart-required' | 'overridden' | 'failed'
+  /** `cancelled` is an installation the caller stopped, its files restored. */
+  application: 'applied' | 'restart-required' | 'overridden' | 'failed' | 'cancelled'
   message: string
   packageResult?: PackageResult
+  /** The bundle an installation added, once pnpm and the bundle check accepted it. */
+  bundle?: string
 }
 
-/** Bundle installation defaults to activation. */
+/** Identifies one installation from its start to its settlement, including its log chunks and cancellation. */
+export type PluginInstallRequestId = Branded<'PluginInstallRequestId'>
+
+/** Bundle installation defaults to activation; callers that offer cancellation supply their request id. */
 export interface InstallBundleOptions {
   enabled?: boolean
+  requestId?: PluginInstallRequestId
+}
+
+/** The form one install spec takes, in pnpm's vocabulary. */
+export type InstallSpecKind = 'registry' | 'path' | 'git' | 'tarball'
+
+/** Why a spec was refused before anything installed. */
+export type PluginInspectProblem =
+  | 'invalid-spec'
+  | 'already-installed'
+  | 'not-found'
+  | 'not-a-package'
+  | 'not-a-bundle'
+  | 'network'
+  | 'unknown'
+
+/**
+ * What a spec names, read before installing it: the registry's answer for a
+ * name, a directory's manifest for a path, and only the form for a git or
+ * tarball spec, whose package is known once pnpm has fetched it.
+ */
+export type PluginSpecInspection =
+  | {
+    readonly status: 'accepted'
+    readonly kind: InstallSpecKind
+    readonly name?: string
+    readonly version?: string
+    readonly description?: string
+    readonly title?: string
+    /** Whether the package declares a bundle patch; null when the spec's form does not say. */
+    readonly bundle: boolean | null
+  }
+  | {
+    readonly status: 'refused'
+    readonly problem: PluginInspectProblem
+    /** What pnpm, the registry, or the file system said. */
+    readonly reason: string
+  }
+
+/** The Host phase of one installation, before its install call settles. */
+export interface PluginInstallProgress {
+  readonly requestId: PluginInstallRequestId
+  readonly phase: 'installing' | 'cancelling' | 'applying'
+}
+
+/** Cancellation is confirmed only after process exit and file restoration. */
+export interface PluginInstallCancellation {
+  readonly status: 'cancelled' | 'too-late' | 'not-running'
+}
+
+/** One chunk of a pnpm run's output, as the run produces it. */
+export interface PluginInstallLogChunk {
+  /** The installation the run belongs to, when its caller supplied a request id. */
+  readonly requestId?: PluginInstallRequestId
+  /** The run the chunk belongs to. */
+  readonly jobId: string
+  /** The command line the run executes: pnpm's command name, then its arguments. */
+  readonly argv: readonly string[]
+  /** The directory the run executes in: the profile directory. */
+  readonly cwd: string
+  readonly stream: 'stdout' | 'stderr'
+  readonly text: string
+  /** Present on the run's last chunk: pnpm's exit code, null when it ended without one. */
+  readonly exitCode?: number | null
+}
+
+/** What changed in the profile, for consumers that show it. */
+export interface PluginChange {
+  /** The operation that changed it; `reload` is a patch generation applied outside the manager. */
+  readonly reason: 'plugin' | 'bundle' | 'install' | 'remove' | 'reload'
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Events {
+    /**
+     * The profile's plugins, bundles, or composition changed: a manager
+     * operation completed, or a patch generation was applied.
+     * @mode emit
+     * @param change - what changed.
+     */
+    'plugin-manager/changed'(change: PluginChange): void
+    /**
+     * One chunk of a pnpm run's output, streamed as the run produces it.
+     * @mode emit
+     * @param chunk - the chunk and the run it belongs to.
+     */
+    'plugin-manager/install-log'(chunk: PluginInstallLogChunk): void
+    /**
+     * An installation moved between its Host phases.
+     * @mode emit
+     * @param progress - the installation's request id and phase.
+     */
+    'plugin-manager/install-state'(progress: PluginInstallProgress): void
+  }
 }

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

@@ -0,0 +1,71 @@
+/** Reading install specs and classifying pnpm failures: pure, table-driven. */
+
+import { describe, expect, it } from 'vitest'
+import { classifyInstallFailure, InvalidInstallSpecError, parseInstallSpec } from '@deepseek-ai/dsh-plugin-manager'
+
+describe('parseInstallSpec', () => {
+  it('reads registry names with an optional range, scoped or not', () => {
+    expect(parseInstallSpec(' dsh-better-sidebar ')).toEqual({ kind: 'registry', spec: 'dsh-better-sidebar', name: 'dsh-better-sidebar' })
+    expect(parseInstallSpec('@acme/dsh-tool@^1.2')).toEqual({ kind: 'registry', spec: '@acme/dsh-tool@^1.2', name: '@acme/dsh-tool', range: '^1.2' })
+    expect(parseInstallSpec('pkg@latest')).toEqual({ kind: 'registry', spec: 'pkg@latest', name: 'pkg', range: 'latest' })
+  })
+
+  it('reads absolute paths, with or without a file: or link: prefix, and tarballs on disk', () => {
+    expect(parseInstallSpec('/plugins/dsh-x')).toEqual({ kind: 'path', spec: '/plugins/dsh-x', path: '/plugins/dsh-x' })
+    expect(parseInstallSpec('file:/plugins/dsh-x')).toEqual({ kind: 'path', spec: 'file:/plugins/dsh-x', path: '/plugins/dsh-x' })
+    expect(parseInstallSpec('link:/plugins/dsh-x')).toEqual({ kind: 'path', spec: 'link:/plugins/dsh-x', path: '/plugins/dsh-x' })
+    expect(parseInstallSpec('/packs/dsh-x-1.0.0.tgz')).toEqual({ kind: 'tarball', spec: '/packs/dsh-x-1.0.0.tgz', path: '/packs/dsh-x-1.0.0.tgz' })
+  })
+
+  it('reads git hosts and tarball URLs', () => {
+    for (const spec of ['github:someone/dsh-plugin', 'gitlab:a/b#main', 'git+ssh://git@github.com/a/b.git', 'git://host/a/b', 'git@github.com:a/b.git', 'https://github.com/a/b', 'https://github.com/a/b.git#v1']) {
+      expect(parseInstallSpec(spec)).toEqual({ kind: 'git', spec })
+    }
+    expect(parseInstallSpec('https://cdn.example.com/x/y/z/dsh-x-1.0.0.tgz')).toEqual({ kind: 'tarball', spec: 'https://cdn.example.com/x/y/z/dsh-x-1.0.0.tgz' })
+  })
+
+  it('refuses what neither the registry nor pnpm would take, naming why', () => {
+    const refusal = (spec: string): string => {
+      try {
+        parseInstallSpec(spec)
+      } catch (error) {
+        expect(error).toBeInstanceOf(InvalidInstallSpecError)
+        const failure = error as InvalidInstallSpecError
+        expect(failure.spec).toBe(spec.trim())
+        expect(failure.message).toBe(`plugin-manager: ${failure.reason}: ${spec.trim()}`)
+        return failure.reason
+      }
+      throw new Error(`${spec} was accepted`)
+    }
+    expect(refusal('   ')).toBe('the package spec must not be empty')
+    expect(refusal('./dsh-x')).toBe('a local path must be absolute')
+    expect(refusal('../dsh-x')).toBe('a local path must be absolute')
+    expect(refusal('file:./dsh-x')).toBe('a local path must be absolute')
+    expect(refusal('https://example.com/not-a-package')).toBe('a URL must point at a git repository or a tarball')
+    expect(refusal('Dsh-Upper')).toBe('not a package name the registry accepts')
+    expect(refusal('.hidden')).toBe('not a package name the registry accepts')
+    expect(refusal('has space')).toBe('not a package name the registry accepts')
+    expect(refusal('a'.repeat(215))).toBe('not a package name the registry accepts')
+    expect(refusal('pkg@')).toBe('a version after @ must not be empty')
+  })
+})
+
+describe('classifyInstallFailure', () => {
+  it('names the run\'s end before reading its output, then the most specific code in the output', () => {
+    expect(classifyInstallFailure({ log: 'ENOSPC', timedOut: true })).toBe('timeout')
+    expect(classifyInstallFailure({ log: '', cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })).toBe('pnpm-missing')
+    expect(classifyInstallFailure({ log: 'ERR_PNPM_IGNORED_BUILDS  Ignored build scripts: node-pty' })).toBe('build-blocked')
+    expect(classifyInstallFailure({ log: 'npm error code E404\nnpm error 404 Not Found - GET https://registry/x' })).toBe('not-found')
+    expect(classifyInstallFailure({ log: 'ERR_PNPM_NO_MATCHING_VERSION  No matching version found for x@9' })).toBe('no-matching-version')
+    expect(classifyInstallFailure({ log: 'npm error code ETARGET' })).toBe('no-matching-version')
+    expect(classifyInstallFailure({ log: 'ENOSPC: no space left on device, write' })).toBe('disk-full')
+    expect(classifyInstallFailure({ log: 'EACCES: permission denied, mkdir' })).toBe('permission')
+    expect(classifyInstallFailure({ log: 'ERR_PNPM_TARBALL_INTEGRITY  Got sha512-...' })).toBe('integrity')
+    expect(classifyInstallFailure({ log: 'ERR_PNPM_META_FETCH_FAIL  GET https://registry/x: request to https://registry/x failed, reason: getaddrinfo ENOTFOUND registry' })).toBe('network')
+    expect(classifyInstallFailure({ log: 'ECONNRESET' })).toBe('network')
+    expect(classifyInstallFailure({ log: 'ERR_PNPM_FETCH_502  GET https://registry/x: Bad Gateway' })).toBe('network')
+    expect(classifyInstallFailure({ log: 'fatal: unable to access https://github.com/a/b/: Could not resolve host' })).toBe('network')
+    expect(classifyInstallFailure({ log: 'exited with 1', cause: new Error('no code') })).toBe('unknown')
+    expect(classifyInstallFailure({ log: '' })).toBe('unknown')
+  })
+})

+ 219 - 15
packages/boot/plugin-manager/tests/manager.spec.ts

@@ -1,17 +1,18 @@
 /** Persistent manager behavior through a real profile Include and Loader. */
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
+import { pathToFileURL } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
 import type AgentRegistry from '@deepseek-ai/dsh-agent'
 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'
+import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts'
 import Hmr from '@deepseek-ai/dsh-hmr'
 import Timer from '@deepseek-ai/cordis-plugin-timer'
 import { Group } from '@deepseek-ai/cordis-plugin-loader'
@@ -52,16 +53,50 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
 }
 
 it('lists bundle versions and current-profile plugin targets', async () => {
-  const { manager } = await fixture()
+  const { manager, dir } = await fixture()
   const plugins = await manager.listPlugins()
   expect(plugins.find(row => row.entryId === 'include:managed')).toMatchObject({ patchId: 'managed', enabled: true })
   expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBeDefined()
   expect(await manager.listBundles()).toEqual([
-    { name: 'core', version: '1.0.0', enabled: true, removable: false, readOnlyReason: 'This bundle provides plugin management components' },
-    { name: 'extra', version: '1.0.0', enabled: true, removable: true },
+    {
+      name: 'core', version: '1.0.0', enabled: true, installed: false, removable: false,
+      readOnlyReason: 'This bundle provides plugin management components',
+      rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [],
+    },
+    {
+      name: 'extra', version: '1.0.0', enabled: true, installed: true, removable: true,
+      rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [],
+    },
   ])
 })
 
+it('describes a bundle by its manifest and patch: title, one-liner, rows without a live entry, and the built-in rows it changes', async () => {
+  const { manager, dir, bundle } = await fixture()
+  bundle('described', [{ id: 'described-row', name: './plugin.mjs' }])
+  writeFileSync(join(dir, 'node_modules', 'described', 'package.json'), JSON.stringify({
+    name: 'described', version: '2.0.0', description: 'Describes itself.', dsh: { title: 'Described', bundle: { patch: './cordis.patch.yml' } },
+  }))
+  // An anonymous row is not addressable and is left out of the rows.
+  writeFileSync(join(dir, 'node_modules', 'described', 'cordis.patch.yml'), JSON.stringify([
+    { insert: [{ id: 'described-row', name: './plugin.mjs' }, { name: './plugin.mjs' }] }, { id: 'managed', disabled: true }, { id: 'described-row', config: {} },
+  ]))
+  const manifest = readProfileManifest('test', dir)
+  manifest.dependencies = { ...manifest.dependencies, described: '2.0.0' }
+  writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+  const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href
+  expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({
+    name: 'described', version: '2.0.0', title: 'Described', description: 'Describes itself.', enabled: false, installed: true, removable: true,
+    rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'],
+  })
+  await manager.setBundleEnabled('described', true)
+  expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([
+    { rowId: 'described-row', moduleName, entryId: 'include:described-row' },
+  ])
+  // Off again, the rows lose their entries.
+  await manager.setBundleEnabled('described', false)
+  expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([{ rowId: 'described-row', moduleName }])
+})
+
 it('turns a plugin off and on without duplicating patch overrides', async () => {
   const { manager, dir } = await fixture()
   const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
@@ -150,18 +185,26 @@ it('unloads before removing packages and retries inactive dependencies whose fil
   expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
 })
 
-it('reports package failure separately from partial disk changes', async () => {
+it('restores the manifest and lockfile after a failed package run, classifying the failure', async () => {
   const { manager, dir } = await fixture()
+  const lockPath = join(dir, 'pnpm-lock.yaml')
   const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
     const manifest = readProfileManifest('test', dir)
     manifest.dependencies = { ...manifest.dependencies, partial: '1' }
     writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
-    return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: join(dir, 'pnpm.log') }
+    writeFileSync(lockPath, 'partial lockfile\n')
+    return { exitCode: 42, output: 'ERR_PNPM_META_FETCH_FAIL  GET https://registry/partial: ENOTFOUND', truncated: false, logPath: join(dir, 'pnpm.log') }
   })
   onTestFinished(() => { install.mockRestore() })
-  expect(await manager.installBundle('partial')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 42 } })
-  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
-  expect((await manager.listBundles()).find(row => row.name === 'partial')?.error).toContain('cannot resolve profile bundle')
+  const before = readFileSync(join(dir, 'package.json'), 'utf8')
+  expect(await manager.installBundle('partial')).toMatchObject({ changed: false, application: 'failed', packageResult: { exitCode: 42, kind: 'network' } })
+  expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
+  // A lockfile the run created is removed; one that existed is put back.
+  expect(existsSync(lockPath)).toBe(false)
+  writeFileSync(lockPath, 'original lockfile\n')
+  expect(await manager.installBundle('partial')).toMatchObject({ changed: false, application: 'failed' })
+  expect(readFileSync(lockPath, 'utf8')).toBe('original lockfile\n')
+  expect((await manager.listBundles()).some(row => row.name === 'partial')).toBe(false)
 })
 
 it('keeps saved changes after activation failure and allows a corrected configuration to retry', async () => {
@@ -237,7 +280,7 @@ it.each(['', '-g'])('rejects an invalid installation spec before calling pnpm: %
   expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed' })
 })
 
-it('retains a successful install that cannot be activated as a bundle', async () => {
+it('restores the manifest when the package pnpm added declares no bundle', async () => {
   const { manager, dir, bundle } = await fixture()
   const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
     bundle('plain', [])
@@ -248,8 +291,8 @@ it('retains a successful install that cannot be activated as a bundle', async ()
     return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
   })
   onTestFinished(() => { install.mockRestore() })
-  expect(await manager.installBundle('plain')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 0 } })
-  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
+  expect(await manager.installBundle('plain')).toMatchObject({ changed: false, application: 'failed', message: 'plain declares no dsh.bundle.patch', packageResult: { exitCode: 0 } })
+  expect(readProfileManifest('test', dir)).toMatchObject({ dependencies: { extra: '1.0.0' }, dsh: { profile: { bundles: ['core', 'extra'] } } })
 })
 
 it('reports repeated installs as requiring restart and ambiguous package changes as failures', async () => {
@@ -263,7 +306,168 @@ it('reports repeated installs as requiring restart and ambiguous package changes
     writeFileSync(join(dir, 'package.json'), '{}')
     return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
   })
-  expect(await manager.installBundle('unknown')).toMatchObject({ changed: true, application: 'failed' })
+  expect(await manager.installBundle('unknown')).toMatchObject({ changed: false, application: 'failed', message: 'Cannot identify one installed bundle from the dependency change' })
+  expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
+})
+
+it('streams pnpm output, reports the installation phases, and names the installed bundle', async () => {
+  const { ctx, manager, dir, bundle } = await fixture()
+  const chunks: PluginInstallLogChunk[] = []
+  const phases: PluginInstallProgress[] = []
+  const changes: PluginChange[] = []
+  ctx.on('plugin-manager/install-log', (chunk) => { chunks.push(chunk) })
+  ctx.on('plugin-manager/install-state', (progress) => { phases.push(progress) })
+  ctx.on('plugin-manager/changed', (change) => { changes.push(change) })
+  const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args, options) => {
+    options.onOutput?.('Progress: resolved 1\n', 'stdout')
+    options.onOutput?.('warning\n', 'stderr')
+    const name = String(args[1])
+    bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
+    const manifest = readProfileManifest('test', dir)
+    manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+    return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  onTestFinished(() => { install.mockRestore() })
+  const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
+  expect(await manager.installBundle('streamed', { enabled: false, requestId })).toMatchObject({ application: 'applied', changed: true, bundle: 'streamed' })
+  expect(install).toHaveBeenCalledWith(expect.objectContaining({ profile: 'test' }), ['add', 'streamed'], expect.objectContaining({ command: 'pnpm' }))
+  const jobId = chunks[0]?.jobId
+  expect(chunks).toEqual([
+    { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stdout', text: 'Progress: resolved 1\n' },
+    { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stderr', text: 'warning\n' },
+    { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stdout', text: '', exitCode: 0 },
+  ])
+  expect(phases).toEqual([{ requestId, phase: 'installing' }, { requestId, phase: 'applying' }])
+  expect(changes).toEqual([{ reason: 'install' }])
+  // A run without a request id streams too, unidentified.
+  await manager.removeBundle('streamed')
+  expect(chunks.at(-1)).toMatchObject({ argv: ['pnpm', 'remove', 'streamed'], stream: 'stdout', exitCode: 0 })
+  expect(chunks.at(-1)).not.toHaveProperty('requestId')
+})
+
+it('stops a run on request, restores the files, and answers not-running or too-late otherwise', async () => {
+  const { ctx, manager, dir, bundle } = await fixture()
+  const phases: PluginInstallProgress[] = []
+  ctx.on('plugin-manager/install-state', (progress) => { phases.push(progress) })
+  const started = Promise.withResolvers<undefined>()
+  const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, _args, options) => {
+    const manifest = readProfileManifest('test', dir)
+    manifest.dependencies = { ...manifest.dependencies, slow: '1' }
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+    started.resolve(undefined)
+    await new Promise<undefined>((resolve) => { options.signal?.addEventListener('abort', () => { resolve(undefined) }, { once: true }) })
+    return { exitCode: 1, output: 'killed', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  onTestFinished(() => { install.mockRestore() })
+  const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
+  const before = readFileSync(join(dir, 'package.json'), 'utf8')
+  const run = manager.installBundle('slow', { requestId })
+  await started.promise
+  expect(await manager.cancelInstall('00000000-0000-4000-8000-000000000000' as PluginInstallRequestId)).toEqual({ status: 'not-running' })
+  expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' })
+  expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
+  expect(await run).toMatchObject({ application: 'cancelled', changed: false, message: 'Installation cancelled', packageResult: { exitCode: 1 } })
+  expect(phases).toEqual([{ requestId, phase: 'installing' }, { requestId, phase: 'cancelling' }])
+  expect(await manager.cancelInstall(requestId)).toEqual({ status: 'not-running' })
+  // Once pnpm has exited and the bundle is being applied, the run cannot be stopped.
+  install.mockImplementation(async (_context, args) => {
+    const name = String(args[1])
+    bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
+    const manifest = readProfileManifest('test', dir)
+    manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+    return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  let tooLate: Promise<unknown> | undefined
+  ctx.on('plugin-manager/install-state', (progress) => { if (progress.phase === 'applying') tooLate = manager.cancelInstall(progress.requestId) })
+  expect(await manager.installBundle('late', { requestId })).toMatchObject({ application: 'applied', bundle: 'late' })
+  expect(await tooLate).toEqual({ status: 'too-late' })
+  // A request aborted before its turn under the lock never starts pnpm.
+  const early = manager.installBundle('never', { requestId })
+  const queued = manager.installBundle('after', { requestId: '11111111-1111-4111-8111-111111111111' as PluginInstallRequestId })
+  expect(await manager.cancelInstall('11111111-1111-4111-8111-111111111111' as PluginInstallRequestId)).toEqual({ status: 'cancelled' })
+  await early
+  expect(await queued).toMatchObject({ application: 'cancelled', changed: false })
+})
+
+it('reads what a spec names before installing it', async () => {
+  const { manager, dir, profile } = await fixture(undefined, false, undefined, { inspectTimeoutMs: 1000, pnpmCommand: 'pnpm-test' })
+  const view = vi.spyOn(operations, 'viewProfilePackage')
+  onTestFinished(() => { view.mockRestore() })
+  const answers = (stdout: string) => view.mockResolvedValueOnce({ exitCode: 0, stdout, stderr: '', timedOut: false })
+  answers(JSON.stringify({ name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', dsh: { title: 'Sidebar', bundle: { patch: './cordis.patch.yml' } } }))
+  expect(await manager.inspect('dsh-x')).toEqual({
+    status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', title: 'Sidebar', bundle: true,
+  })
+  expect(view).toHaveBeenCalledWith(dir, 'dsh-x', { command: 'pnpm-test', timeoutMs: 1000 })
+  const signal = AbortSignal.abort()
+  answers(JSON.stringify([{ name: 'dsh-lib', version: '1.0.0', dsh: { bundle: {} } }, { name: 'dsh-lib', version: '1.1.0', dsh: null }]))
+  expect(await manager.inspect('dsh-lib@^1', signal)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-lib declares no dsh.bundle' })
+  expect(view).toHaveBeenLastCalledWith(dir, 'dsh-lib@^1', { command: 'pnpm-test', timeoutMs: 1000, signal })
+  // An answer that names no package keeps the name the spec gave; colour escapes around the JSON are dropped.
+  answers('\x1b[36m' + JSON.stringify({ version: '0.0.1', description: '', dsh: { bundle: { patch: './p.yml' } } }) + '\x1b[39m\n')
+  expect(await manager.inspect('dsh-bare')).toEqual({ status: 'accepted', kind: 'registry', name: 'dsh-bare', version: '0.0.1', bundle: true })
+  const failure = (stderr: string, exitCode: number | null = 1, more: Partial<operations.PackageViewResult> = {}) =>
+    view.mockResolvedValueOnce({ exitCode, stdout: '', stderr, timedOut: false, ...more })
+  failure('npm error code E404\nnpm error 404 Not Found - GET https://registry/nope\n')
+  expect(await manager.inspect('nope')).toMatchObject({ status: 'refused', problem: 'not-found', reason: expect.stringContaining('E404') as string })
+  failure('ERR_PNPM_NO_MATCHING_VERSION  No matching version found for old@9\n')
+  expect(await manager.inspect('old@9')).toMatchObject({ status: 'refused', problem: 'not-found' })
+  failure('ERR_PNPM_META_FETCH_FAIL  request failed, reason: getaddrinfo ENOTFOUND registry\n')
+  expect(await manager.inspect('far')).toMatchObject({ status: 'refused', problem: 'network' })
+  view.mockResolvedValueOnce({ exitCode: 3, stdout: 'plain text\n', stderr: '', timedOut: false })
+  expect(await manager.inspect('odd')).toEqual({ status: 'refused', problem: 'unknown', reason: 'plain text' })
+  failure('', 4)
+  expect(await manager.inspect('quiet')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view exited with 4' })
+  failure('', null, { timedOut: true })
+  expect(await manager.inspect('slow')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view timed out after 1000ms' })
+  failure('', null, { cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })
+  expect(await manager.inspect('gone')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('ENOENT') as string })
+  answers('not json')
+  expect(await manager.inspect('garbled')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('unreadable pnpm view output') as string })
+  answers('"just a string"')
+  expect(await manager.inspect('scalar')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package' })
+  answers('')
+  expect(await manager.inspect('silent')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package' })
+  // What is installed, or supplied by the installation, is refused before the registry is asked.
+  expect(await manager.inspect('extra')).toEqual({ status: 'refused', problem: 'already-installed', reason: 'extra is already installed' })
+  expect(await manager.inspect('./relative')).toEqual({ status: 'refused', problem: 'invalid-spec', reason: 'a local path must be absolute' })
+  expect(await manager.inspect('github:acme/dsh-remote')).toEqual({ status: 'accepted', kind: 'git', bundle: null })
+  const tarball = join(profile.home, 'pack.tgz')
+  expect(await manager.inspect(tarball)).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the tarball does not exist' })
+  writeFileSync(tarball, '')
+  expect(await manager.inspect(tarball)).toEqual({ status: 'accepted', kind: 'tarball', bundle: null })
+  expect(await manager.inspect('https://cdn.example.com/x/y/z/dsh-x-1.0.0.tgz')).toEqual({ status: 'accepted', kind: 'tarball', bundle: null })
+  // A directory answers from its own manifest.
+  const local = join(profile.home, 'dev', 'dsh-local')
+  mkdirSync(local, { recursive: true })
+  expect(await manager.inspect(join(profile.home, 'dev', 'missing'))).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the path does not exist' })
+  expect(await manager.inspect(local)).toMatchObject({ status: 'refused', problem: 'not-a-package', reason: expect.stringContaining('no readable package.json') as string })
+  writeFileSync(join(local, 'package.json'), '{"version":"1.0.0"}')
+  expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the package.json names no package' })
+  writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.' }))
+  expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-local declares no dsh.bundle' })
+  writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.', dsh: { bundle: { patch: './p.yml' } } }))
+  expect(await manager.inspect(`file:${local}`)).toEqual({ status: 'accepted', kind: 'path', name: 'dsh-local', version: '0.1.0', description: 'Local.', bundle: true })
+  writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'core', dsh: { bundle: { patch: './p.yml' } } }))
+  expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'already-installed', reason: 'core is already installed' })
+  // A profile and an installation that list nothing know nothing.
+  writeFileSync(join(dir, 'package.json'), '{}')
+  writeFileSync(profile.installAnchor, '{}')
+  expect(await manager.inspect(local)).toEqual({ status: 'accepted', kind: 'path', name: 'core', bundle: true })
+  expect(view).toHaveBeenCalledTimes(13)
+})
+
+it('announces a patch generation applied outside the manager as a change', async () => {
+  const { ctx, manager, profile } = await fixture()
+  const changes: PluginChange[] = []
+  ctx.on('plugin-manager/changed', (change) => { changes.push(change) })
+  await reconcileProfilePatches(ctx, readProfilePatches('test', profile), 'test')
+  expect(changes).toEqual([{ reason: 'reload' }])
+  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
+  await manager.setPluginEnabled(id, false)
+  expect(changes).toEqual([{ reason: 'reload' }, { reason: 'plugin' }])
 })
 
 it('handles missing patch files and retains non-Error package diagnostics', async () => {

+ 23 - 1
packages/boot/plugin-manager/tests/operations.spec.ts

@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
 import { PassThrough } from 'node:stream'
 import { expect, it, onTestFinished, vi } from 'vitest'
 import { initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot'
-import { anchorPathSpec, runPluginCommand, runProfilePnpm } from '../src/operations.ts'
+import { anchorPathSpec, runPluginCommand, runProfilePnpm, viewProfilePackage } from '../src/operations.ts'
 
 const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => ReturnType<typeof result>>() }))
 vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) }))
@@ -169,3 +169,25 @@ it('handles manifests without dependency or bundle selections', async () => {
   command.run.mockImplementationOnce(() => result(0, '', () => { writeFileSync(join(dir, 'package.json'), '{}') }))
   expect(await runProfilePnpm(context, ['root'], { outputBytes: 100 })).toMatchObject({ exitCode: 0 })
 })
+
+it('asks the registry through pnpm view in the profile directory and reports how the lookup ended', async () => {
+  const { dir } = fixture()
+  const answer = (value: object) => command.run.mockResolvedValueOnce(value as never)
+  answer({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false, isCanceled: false })
+  expect(await viewProfilePackage(dir, 'x@^1', { timeoutMs: 5 })).toEqual({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false })
+  expect(command.run).toHaveBeenLastCalledWith('pnpm', ['view', 'x@^1', 'name', 'version', 'description', 'dsh', '--json'], expect.objectContaining({
+    cwd: dir, timeout: 5, reject: false, stdin: 'ignore',
+  }))
+  expect((command.run.mock.lastCall as unknown[])[2]).not.toHaveProperty('cancelSignal')
+  const signal = AbortSignal.abort()
+  answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: true, isCanceled: false })
+  expect(await viewProfilePackage(dir, 'x', { timeoutMs: 5, signal })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: true })
+  expect((command.run.mock.lastCall as unknown[])[2]).toMatchObject({ cancelSignal: signal })
+  answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: false, isCanceled: true })
+  expect(await viewProfilePackage(dir, 'x', { command: 'node', timeoutMs: 5 })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: false })
+  expect((command.run.mock.lastCall as unknown[])[0]).toBe('node')
+  answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: false, isCanceled: false, code: 'ENOENT', shortMessage: 'spawn pnpm ENOENT' })
+  const missing = await viewProfilePackage(dir, 'x', { timeoutMs: 5 })
+  expect(missing).toMatchObject({ exitCode: null, timedOut: false })
+  expect(missing.cause).toMatchObject({ message: 'spawn pnpm ENOENT', code: 'ENOENT' })
+})

+ 3 - 0
packages/boot/plugin-manager/tsconfig.json

@@ -49,6 +49,9 @@
     },
     {
       "path": "../../../vendor/timer"
+    },
+    {
+      "path": "../../util/brand"
     }
   ]
 }

+ 8 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -244,6 +244,14 @@
     - id: ui-settings-models
       name: '@deepseek-ai/dsh-client-ui-settings-models'
 
+    # Plugin management: the sidebar Plugins page installs, enables, disables,
+    # and removes the profile's bundles through the `pluginManager` Remote.
+    # Without a profile-backed Host it reports itself unavailable.
+    - id: ui-plugin-manager
+      name: '@deepseek-ai/dsh-client-ui-plugin-manager'
+
+    # Read-only Plugin list tab in the Settings Plugins section; the built-in
+    # bundles the sidebar page leaves out are inspected here.
     - id: ui-settings-plugin-inventory
       name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory'
 

+ 2 - 2
packages/client/ui-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/client/ui-plugin-manager/README.md
-README.md: 7d464296a110ff261312911b5631f45dc310aa7f
-README.zh.md: 8e264d53f80b6c9d7648887428ade320ca6c8217
+README.md: 1577262246ff6ee4e76efebe91aec53dbcb57c21
+README.zh.md: bbada8274030c69becf6f3157b6d39e944f3870c

+ 19 - 27
packages/client/ui-plugin-manager/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Manage installed plugin packages and global rows from the Web sidebar."
+description: "Manage the profile's plugin bundles and their rows from the Web sidebar."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use the **Plugins** entry in the Web sidebar to manage installed packages. Switch bundle layers on and off, inspect their rows and failures, install packages while watching pnpm output. Dependency-sensitive changes and uninstalling ask for confirmation. Global configuration remains in Settings.
+Use the **Plugins** entry in the Web sidebar to manage the profile's installed bundles. Switch bundles and their rows on and off, install a bundle after the Host has read what the spec names, watch pnpm's output, stop a run, and enable what it added. Uninstalling asks for confirmation. Global configuration remains in Settings.
 
 ## Table of Contents
 
@@ -25,47 +25,39 @@ Use the **Plugins** entry in the Web sidebar to manage installed packages. Switc
 <a id="use-this-package"></a>
 ## Use this package
 
-Select **Plugins** in the sidebar. The page reads packages through `api-remotes` when first opened. Global configuration remains in the Settings **Plugins** section.
+Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. Global configuration remains in the Settings **Plugins** section.
 
-### Installing a package
+### Installing a bundle
 
-**Add plugin** takes a registry name, an absolute local path, or a Git address. **Install** first asks the Host to read what the spec names (`plugins.inspect`): a name the list already shows, a name the registry does not have, a path without a package, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's title, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundles on, closes the dialog, and scrolls the list to the first of them; closing instead leaves them installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand. Packages pnpm added that the Host removed again are listed with its reason; a dependency that is not a plugin pack is named as such. A successful installation does not certify that a module can activate.
+**Add plugin** takes a registry name, an absolute local path, or a Git address. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's title, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. A successful installation does not certify that a module can activate.
 
-### Switching a plugin pack
+During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Loading the bundle cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; the manifest and lockfile are back as they were, while downloaded files can remain. Closing the dialog is blocked while the Host owns the operation. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again.
 
-A pack’s switch changes its enabled layer selection. Live profiles recompose before the operation completes; startup-only profiles show a restart notice. Runtime failures leave successful components active and keep the pack enabled, with **Retry** and **Uninstall** on its page. Unreadable bundle declarations prevent enabling. Built-in packs are not on the page; the Settings Plugins section's Plugin list tab inspects them. Disabling first checks dependents and asks for confirmation when needed.
+### Switching a bundle
 
-### Switching one row of a pack
+A bundle's switch changes its layer selection. Live profiles recompose before the operation completes; a profile that applies patches at its next start, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked with the Host's reason. The installation's own bundles are not on the page; the Settings Plugins section's Plugin list tab inspects them.
 
-A row's switch on the pack's page calls `plugins.setRowDisabled` against the profile's global user layer: off writes `disabled: true` for the row's id into the profile's `cordis.patch.yml`, on deletes that key again. The tree recomposes at once, so the row's host half unmounts or mounts while the rest of the pack keeps running; a browser half the pack's own client bundle mounts for every component stays until the page reloads. The switch appears only where it acts at once — a pack that is switched on, on a profile that applies patches while it runs; packs that are off and profiles that apply patches at their next start list their rows read-only. A row the pack itself keeps off, by a `disabled: true` row or a `!!js` gate, is locked with the reason, because the user layer only ever denies. A failing row can be switched off; a row another layer already owns has nothing mounted to switch. Switching a row off first asks the Host what other rows inject of what the row provides, with the switch inert meanwhile, and opens a confirmation naming them only when there are any; a row nothing depends on switches off at once, and switching on never asks. A list longer than ten rows gets a filter over the row ids.
+### Switching one row of a bundle
 
-### Packages without a bundle
-
-Non-bundle dependencies remain installed and appear under **Dependencies that are not plugin packs**, folded until opened. Their detail page shows package metadata and uninstall. Modules can be loaded by writing Cordis configuration; this page does not infer module exports.
+A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a live profile, so the row's host half unmounts or mounts while the rest of the bundle keeps running; a browser half the bundle's own client bundle mounts stays until the page reloads. Rows show their fiber phase as the Host runs them. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. The rows a bundle's patch changes without declaring them are listed as the built-in components it changes. A list longer than ten rows gets a filter over the row ids.
 
 -----
 
-During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Configuration application cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; downloaded or unpacked files can remain. Closing the dialog is blocked while the Host owns the operation. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again.
-
 <a id="understand-the-implementation"></a>
 ## Understand the implementation
 
-Package management uses the profile’s dependency records: installed bundles can be toggled and installed packages removed; installation-owned bundles remain locked. This distinction does not select startup failure policy.
+Package management uses the profile's dependency records: installed bundles can be toggled and removed; installation-owned bundles remain locked. This distinction does not select startup failure policy.
 
 <details>
 <summary>Implementation internals — click to expand</summary>
 
 ### Registration
 
-The browser plugin registers the `plugins` sidebar entry and its `main` panel through `ctx.slots.inject()`, so both follow late slot declaration, locale changes and teardown. The page is global and belongs to no Session. Shipped module labels come from its dictionary; third-party display text comes from package metadata.
+The browser plugin registers the `plugins` sidebar entry and its `main` panel through `ctx.slots.inject()`, so both follow late slot declaration, locale changes and teardown. The page is global and belongs to no Session. Display text comes from package metadata and the page's dictionary.
 
 ### The store
 
-`PluginManagerController` owns the package snapshot, busy keys, notices, install progress and confirmations. It coalesces overlapping reads, refreshes after operations and Host changes, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `plugins.cancelInstall`, whose answer the dialog waits for. A refusal of the moment — `plugins/busy` or `plugins/agents-running` — returns the dialog to the spec instead of the failed screen. Every notice is a toast that retires on its own; only the restart banner, which names packages whose change waits for the next start, stays on the page.
-
-### Confirmation
-
-`disable` and `uninstall` open the confirmation and ask `plugins.dependents`; the answer for a confirmation that has since changed is dropped. A disable with an empty answer confirms itself. The dialog words each dependent — the rows injecting a service the package provides, by their harness names, and the user-layer rows naming the package, by their package title and the layer they sit in — and its confirming button stays disabled until the answer arrives. The action the confirmation guards is captured when it opens and runs only through that button.
+`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`, whose answer the dialog waits for. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own.
 
 </details>
 
@@ -74,11 +66,11 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th
 <a id="further-exploration"></a>
 ## Further Exploration
 
-These pages cover the settings section, the Remote calls, and the Host-side manager.
+These pages cover the sidebar, the Remote calls, and the Host-side manager.
 
 - [ui-sidebar](../ui-sidebar/README.md) — the panel list the Plugins entry registers into; [ui-layout](../ui-layout/README.md) — the main slot the page occupies.
-- [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `plugins.*`.
-- [plugin-manager](../../host/plugin-manager/README.md) — the Host-side manager this page drives.
+- [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `pluginManager.*` and `pluginInventory.*`.
+- [plugin-manager](../../boot/plugin-manager/README.md) — the Host-side manager this page drives.
 
 -----
 
@@ -98,8 +90,8 @@ None; this package neither assembles nor sends a provider request.
 
 These limits define the reach of the management view; they are current package constraints.
 
-- **User-layer rows are not listed per package** — module references in handwritten patches appear in dependency queries and are cleaned up by uninstall; editing those rows remains a file operation.
-- **Names for harness modules live in this page's dictionary** — a new first-party agent-plane module reads by its short module name until `name.<slug>` are added here.
+- **Only bundles are managed** — a dependency without a bundle patch is refused before it installs and never listed; loading plain plugin modules stays a file operation.
+- **Rows show a phase, not a reason** — a failed row reads as failed without the Host's error text; the Host log has it.
 - **One install at a time** — the dialog runs one pnpm command; a second spec waits for the first to finish.
 - **No version picker** — the spec is typed as pnpm accepts it; the page neither lists registry versions nor offers upgrades.
 
@@ -113,4 +105,4 @@ None.
 
 </details>
 
-**Runtime invariant:** No companion is published. This package owns a sidebar panel and a Settings contribution over Host-owned facts.
+**Runtime invariant:** No companion is published. This package owns a sidebar panel over Host-owned facts.

+ 25 - 33
packages/client/ui-plugin-manager/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "从 Web 侧栏管理已安装插件包与全局插件行。"
+description: "从 Web 侧栏管理 profile 的插件组合包及其行。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-使用 Web 侧栏的**插件**入口管理已安装的包。可以启停组合包层、检查插件行与失败、并在安装时查看 pnpm 输出。影响依赖的修改与卸载会要求确认。全局配置仍在设置中编辑。
+使用 Web 侧栏的**插件**入口管理 profile 已安装的组合包。可以启停组合包及其行、在 Host 读出 spec 指向什么之后安装组合包、查看 pnpm 输出、停止一次运行,并启用它新增的包。卸载会要求确认。全局配置仍在设置中编辑。
 
 ## 目录
 
@@ -25,47 +25,39 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取包。全局配置仍在设置的**插件**分区中编辑。
+在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。全局配置仍在设置的**插件**分区中编辑。
 
-### 安装一个包
+### 安装一个组合包
 
-**添加插件**接受注册表包名、本地绝对路径或 Git 地址。**安装**先让 Host 读出 spec 指向什么(`plugins.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包标题、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到其中第一个;直接关闭则让它们保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边。pnpm 加入后又被 Host 移除的包会连同原因列出;不是插件包的依赖会被如此标明。安装成功不代表模块一定能够激活。
+**添加插件**接受注册表包名、本地绝对路径或 Git 地址。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包标题、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。安装成功不代表模块一定能够激活。
 
-### 切换一个插件包
+安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。
 
-组合包开关改变其启用层选择。实时 profile 在操作完成前重组;仅启动时生效的 profile 显示重启提示。运行失败会保留成功组件和组合包的启用状态,并在详情页提供**重试**与**卸载**。组合包声明不可读时无法启用。内置组合包不在本页;设置中「插件」分区的「插件列表」标签页负责查看它们。停用会先检查依赖方,必要时要求确认。
+### 切换一个组合包
 
-### 切换插件包里的一行
+组合包开关改变其层选择。实时 profile 在操作完成前重组;下次启动才应用 patch 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包带着 Host 的原因保持锁定。随安装提供的组合包不在本页;设置中「插件」分区的「插件列表」标签页负责查看它们。
 
-包页面上行的开关以 profile 的全局用户层为目标调用 `plugins.setRowDisabled`:关闭往 profile 的 `cordis.patch.yml` 写入该行 id 的 `disabled: true`,打开再把这个键删掉。树随即重组,该行的宿主半区卸下或挂上,插件包其余部分照常运行;插件包自己的客户端 bundle 为每个组件挂载的浏览器半区要到页面重载才消失。开关只在立即生效的地方出现——已打开的插件包,且 profile 在运行中就应用补丁;已关闭的插件包,以及下次启动才应用补丁的 profile,其行只读列出。插件包自己关掉的行,无论是 `disabled: true` 的行还是 `!!js` 门,都带原因锁定,因为用户层只能拒绝。异常的行可以关掉;已被别的层占用 id 的行没有挂载任何东西,无从切换。关闭一行前先向宿主询问有哪些行注入它提供的服务,询问期间开关置灰,有则弹出确认框点名它们;没有依赖方的行直接关闭,打开从不询问。超过十行的列表带一个按行 id 筛选的输入框。
+### 切换组合包里的一行
 
-### 没有组合包声明的包
-
-非组合包依赖保持已安装,列在**非插件包依赖**下,默认折叠。详情页展示包元数据和卸载操作。模块可以通过手写 Cordis 配置加载;此页面不推断模块导出。
+组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。实时 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行;组合包自己的客户端 bundle 挂载的浏览器半区要到页面重载才消失。行按 Host 运行它们的 fiber 阶段显示状态。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。组合包 patch 没有声明却改动的行,作为它改动的内置组件列出。超过十行的列表带一个按行 id 筛选的输入框。
 
 -----
 
-安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。配置应用阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;下载或解包文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。
-
 <a id="understand-the-implementation"></a>
 ## 理解实现
 
-插件管理依据 profile 的依赖记录:已安装组合包可启停,已安装包可移除,随安装提供的组合包保持锁定。这一区分不决定启动失败策略。
+插件管理依据 profile 的依赖记录:已安装组合包可启停、可移除,随安装提供的组合包保持锁定。这一区分不决定启动失败策略。
 
 <details>
 <summary>实现细节——点击展开</summary>
 
 ### 注册
 
-浏览器插件通过 `ctx.slots.inject()` 注册 `plugins` 侧栏入口与它的 `main` 面板,使两者跟随 slot 延迟声明、本地化变化与销毁。页面为全局页面,不属于任何 Session。随附模块的标签来自字典,第三方显示文本来自包元信息。
+浏览器插件通过 `ctx.slots.inject()` 注册 `plugins` 侧栏入口与它的 `main` 面板,使两者跟随 slot 延迟声明、本地化变化与销毁。页面为全局页面,不属于任何 Session。显示文本来自包元信息与页面字典。
 
 ### store
 
-`PluginManagerController` 拥有包快照、忙碌键、提示、安装进度和确认状态。它合并重叠读取,在操作或 Host 变化后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `plugins.cancelInstall` 停止,对话框等待其答复。当下的拒绝——`plugins/busy` 或 `plugins/agents-running`——让对话框回到 spec,而不是失败界面。每条提示都是会自行消失的 toast;只有点名哪些包的变更要等下次启动的重启横幅留在页面上。
-
-### 确认
-
-`disable` 与 `uninstall` 打开确认框并询问 `plugins.dependents`;确认框此后已变化时丢弃这份应答。空应答的停用自行确认。对话框把每个依赖方说成人话——注入该包所提供服务的行按 harness 名称点名,引用该包的用户层行按包标题与所在层点名——确认按钮在应答到达前保持禁用。确认框守护的操作在打开时捕获,只经该按钮运行。
+`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止,对话框等待其答复。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。
 
 </details>
 
@@ -74,18 +66,18 @@ kind: "package-reference"
 <a id="further-exploration"></a>
 ## 进一步探索
 
-这些页面覆盖设置分区、Remote 调用与宿主侧管理器。
+这些页面覆盖侧栏、Remote 调用与 Host 侧管理器。
 
-- [ui-sidebar](../ui-sidebar/README.zh.md)——「插件」入口注册进的面板列表;[ui-layout](../ui-layout/README.zh.md)——页面所占的 main slot。
-- [api-remotes](../../api/remotes/README.zh.md)——`plugins.*` 背后的 Remote BFF 表面。
-- [plugin-manager](../../host/plugin-manager/README.zh.md)——本页面驱动的宿主侧管理器。
+- [ui-sidebar](../ui-sidebar/README.zh.md)——插件入口注册进的面板列表;[ui-layout](../ui-layout/README.zh.md)——页面占用的主 slot。
+- [api-remotes](../../api/remotes/README.zh.md)——`pluginManager.*` 与 `pluginInventory.*` 背后的 Remote BFF 面。
+- [plugin-manager](../../boot/plugin-manager/README.zh.md)——本页驱动的 Host 侧管理器。
 
 -----
 
 <a id="model-experience"></a>
 ## 模型体验
 
-无,本包是浏览器侧的管理表面,不注册任何面向模型的内容。
+无,本包是浏览器侧的管理界面,不注册任何面向模型的内容。
 
 #### KV Cache 影响
 
@@ -96,21 +88,21 @@ kind: "package-reference"
 <a id="known-limitations-and-deferred-work"></a>
 
 
-这些限制界定管理视图的范围;它们是本包当前的约束。
+这些限制界定了管理视图的范围;它们是当前包的约束。
 
-- **用户层行不按包列出**——手写 patch 中的模块引用会出现在依赖查询中,并在卸载时清理;编辑这些行仍通过文件完成。
-- **harness 模块的名字放在本页面的字典里**——新的第一方 agent 面模块在补上 `name.<slug>` 之前按短模块名显示。
-- **一次只能安装一个**——对话框运行一条 pnpm 命令;第二个 spec 要等第一个结束。
-- **没有版本选择器**——spec 按 pnpm 接受的写法输入;页面既不列出仓库版本也不提供升级。
+- **只管理组合包**——没有组合包 patch 的依赖在安装前就被拒绝,也从不列出;加载普通插件模块仍是文件操作。
+- **行只显示阶段,不显示原因**——失败的行只显示为失败,没有 Host 的错误文本;Host 日志里有。
+- **一次只能安装一个**——对话框一次运行一个 pnpm 命令;第二个 spec 要等前一个完成。
+- **没有版本选择器**——spec 按 pnpm 接受的写法输入;页面不列出注册表版本,也不提供升级。
 
 <a id="dev-note"></a>
 ### 开发备注
 
 <details>
-<summary>维护者工作上下文——点击展开</summary>
+<summary>维护者的工作上下文——点击展开</summary>
 
 无。
 
 </details>
 
-**运行时不变量:** 不发布 companion。本包拥有一个侧栏面板与一个基于宿主事实的设置贡献。
+**运行时不变式:** 不发布伴生检查。本包只拥有一个基于 Host 事实的侧栏面板。

+ 1 - 1
packages/client/ui-plugin-manager/package.json

@@ -55,7 +55,7 @@
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/dsh-host-plugin-manager": "workspace:^",
+    "@deepseek-ai/dsh-plugin-manager": "workspace:^",
     "@testing-library/react": "^16.1.0",
     "@types/react": "~18.3.1",
     "clsx": "^2.1.1",

+ 139 - 299
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -1,13 +1,13 @@
 /**
- * Global plugin management: installed package cards, bundle component switches,
- * entry diagnostics, the guided install dialog with its folded pnpm output,
- * dependency confirmations, and the toasts an action's refusal becomes.
- * Package details expose module names and runtime failures; non-bundle packages
- * retain package information and uninstall without automatic composition actions.
+ * Global plugin management: installed bundle cards, their row switches, the
+ * guided install dialog with its folded pnpm output, the uninstall
+ * confirmation, and the toasts an action's outcome becomes. A bundle's page
+ * lists the rows it contributes as the Host runs them and the built-in rows
+ * its patch changes.
  */
 
 import { useEffect, useId, useState, type ReactNode } from 'react'
-import type { PluginInstallFailureKind, PluginInstallRejection, PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
+import type { PluginInstallFailureKind } from '@deepseek-ai/dsh-api-remotes/client'
 import {
   Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconCloseOutline16,
   IconCordisPluginOutline14, IconRefreshOutline16, IconWarningOutline16,
@@ -17,9 +17,11 @@ import {
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { PluginManagerLocaleKey } from './locales.ts'
 import {
-  isInstallPending, rowKey, type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PluginManagerFace,
+  isInstallPending, rowKey,
+  type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
+  type PluginManagerFace,
 } from './manager-store.ts'
-import { noticeText, packageOf, refusalText, rowLabel, shortName, type Translate } from './presentation.ts'
+import { noticeText, shortName, type Translate } from './presentation.ts'
 import css from './PluginManagerPage.module.css'
 
 /** Full component props assembled by the main slot renderer. */
@@ -28,25 +30,7 @@ export type PluginManagerPageProps =
   & PropsLocale<'pluginManager'>
   & InjectFace<PluginManagerFace>
 
-type RowView = PluginPackageView['rows'][number]
-type RowPhase = NonNullable<RowView['phase']>
-
-/** The layer a pack's components are switched in: a row override always lands in the profile's global user layer. */
-
-/**
- * The list's two groups: packs, which switch as a whole, and the dependencies
- * that are not packs, which only uninstall and stay folded until opened.
- */
-const PACKAGE_GROUPS = [
-  { key: 'bundles', titleKey: 'bundlesTitle', introKey: undefined, folds: false, holds: (pkg: PluginPackageView) => pkg.kind === 'bundle' },
-  { key: 'plugins', titleKey: 'pluginsTitle', introKey: 'pluginsIntro', folds: true, holds: (pkg: PluginPackageView) => pkg.kind !== 'bundle' },
-] as const satisfies readonly {
-  key: string
-  titleKey: PluginManagerLocaleKey
-  introKey: PluginManagerLocaleKey | undefined
-  folds: boolean
-  holds: (pkg: PluginPackageView) => boolean
-}[]
+type RowPhase = NonNullable<PackageRow['phase']>
 
 /** How long the list marks a package an install just enabled. */
 const HIGHLIGHT_MS = 2_400
@@ -64,27 +48,7 @@ const PHASE_KEYS = {
   unloading: 'rowPhaseUnloading',
 } satisfies Record<RowPhase, PluginManagerLocaleKey>
 
-/** The states a card tags; running and off are what the switch shows. */
-type CardStatus = 'restart' | 'waiting' | 'problem'
-
-const STATUS_OF = {
-  'running': null,
-  'partial': 'problem',
-  'failed': 'problem',
-  'not-enableable': 'problem',
-  'disabled': null,
-  'restart-required': 'restart',
-  'plain': null,
-} satisfies Record<PluginPackageView['status'], CardStatus | null>
-
-const STATUS_KEYS = {
-  restart: 'statusRestart',
-  waiting: 'statusWaiting',
-  problem: 'statusProblem',
-} satisfies Record<CardStatus, PluginManagerLocaleKey>
-
-/** Status dot naming a live root-fiber phase. */
-/** Pending and unloading fibers do nothing; only loading is in progress. */
+/** Status dot naming a live root-fiber phase: pending and unloading fibers do nothing; only loading is in progress. */
 const PHASE_STATES = {
   pending: 'idle',
   loading: 'ongoing',
@@ -93,102 +57,66 @@ const PHASE_STATES = {
   unloading: 'idle',
 } satisfies Record<RowPhase, StateDotState>
 
-/**
- * A row waiting for a service another row provides: a fiber left pending by
- * a live recomposition, or the failure a fresh composition records for the
- * same wait. Both read as one state, since the person sees one situation.
- */
-function isWaitingRow(row: RowView): boolean {
-  if (row.phase === 'active') return false
-  return row.failure?.stage === 'inject-pending' || (row.failure === undefined && row.phase === 'pending')
-}
-
-/** A row with a recorded startup failure or a failed fiber; a wait for a service is not a failure. */
-function isFailedRow(row: RowView): boolean {
-  if (row.phase === 'active' && row.failure?.stage === 'inject-pending') return false
-  return !isWaitingRow(row) && (row.failure !== undefined || row.phase === 'failed')
-}
-
-/** The tag one package carries: a pending restart, rows waiting for a service, a problem, or none. */
-function cardStatus(pkg: PluginPackageView): CardStatus | null {
-  const base = pkg.kind === 'bundle' ? STATUS_OF[pkg.status] : pkg.reason === undefined ? null : 'problem'
-  if (base === 'problem' && pkg.kind === 'bundle' && pkg.enabled
-    && pkg.rows.some(isWaitingRow) && !pkg.rows.some(isFailedRow)
-    && !pkg.issues?.some(issue => issue.stage !== 'inject-pending')) return 'waiting'
-  return base
-}
-
 /** The count line over a pack's components: the total, then only the states that occur. */
-function partsSummary(rows: readonly RowView[], t: Translate): string {
-  const failed = rows.filter(row => row.phase !== 'active' && isFailedRow(row)).length
-  const updateFailed = rows.filter(row => row.phase === 'active' && isFailedRow(row)).length
-  const waiting = rows.filter(isWaitingRow).length
-  const off = rows.filter(row => !row.enabled && !isFailedRow(row) && !isWaitingRow(row)).length
+function partsSummary(rows: readonly PackageRow[], t: Translate): string {
+  const failed = rows.filter(row => row.phase === 'failed').length
+  const off = rows.filter(row => !row.enabled).length
   const running = rows.filter(row => row.enabled && row.phase === 'active').length
   return [
     t('partsCountTotal', { count: String(rows.length) }),
     ...running > 0 ? [t('partsCountRunning', { count: String(running) })] : [],
-    ...waiting > 0 ? [t('partsCountWaiting', { count: String(waiting) })] : [],
     ...off > 0 ? [t('partsCountOff', { count: String(off) })] : [],
     ...failed > 0 ? [t('partsCountFailed', { count: String(failed) })] : [],
-    ...updateFailed > 0 ? [t('partsCountUpdateFailed', { count: String(updateFailed) })] : [],
   ].join(' · ')
 }
 
-/** The switch one row renders: whether a write is in flight for it, and the write it asks for. */
-interface RowToggle {
-  readonly busy: boolean
-  readonly onChange: (disabled: boolean) => void
-}
-
 /** Switching for a pack's rows: which rows have a write in flight, and the write. */
 interface RowToggles {
-  readonly busy: (rowId: string) => boolean
-  readonly onSetDisabled: (row: RowView, disabled: boolean) => void
+  readonly busy: (row: PackageRow) => boolean
+  readonly onSetEnabled: (row: PackageRow, enabled: boolean) => void
 }
 
 /** Rows beyond this count get a filter box above the list. */
 const ROW_FILTER_THRESHOLD = 10
 
-/** A row's switch: off when the person switched it off; locked, saying why, when the pack itself keeps it off. */
-function RowSwitch({ row, t, toggle }: { readonly row: RowView; readonly t: Translate; readonly toggle: RowToggle }): ReactNode {
-  const locked = row.disabledBy === 'composition'
+/** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */
+function RowSwitch({ row, t, busy, onChange }: {
+  readonly row: PackageRow
+  readonly t: Translate
+  readonly busy: boolean
+  readonly onChange: (enabled: boolean) => void
+}): ReactNode {
+  const locked = row.readOnlyReason !== undefined || row.entryId === undefined
   return (
     <Switch
       checked={row.enabled}
       label={t('partToggle', { name: row.rowId })}
-      disabled={toggle.busy || locked}
-      {...locked ? { title: t('partLockedByComposition') } : {}}
-      onChange={(next) => { toggle.onChange(!next) }}
+      disabled={busy || locked}
+      {...row.readOnlyReason === undefined ? {} : { title: row.readOnlyReason }}
+      onChange={onChange}
     />
   )
 }
 
-/** What a row's state line says: the failure, why it is off, or the phase its fiber is in. */
-function rowStateText(row: RowView, t: Translate): string {
-  if (isWaitingRow(row)) return t('rowPhasePending')
-  if (isFailedRow(row)) return t(row.phase === 'active' ? 'rowUpdateFailed' : 'rowStateFailed')
-  if (!row.enabled) return t(row.disabledBy === 'user' ? 'partDisabledByUser' : 'partDisabledByComposition')
+/** What a row's state line says: off, or the phase its fiber is in. */
+function rowStateText(row: PackageRow, t: Translate): string {
+  if (!row.enabled) return t('partOff')
   return row.phase === null ? t('rowStateIdle') : t(PHASE_KEYS[row.phase])
 }
 
-/** The dot beside a row: its failure, its fiber phase, or idle. */
-function rowDotState(row: RowView): StateDotState {
-  if (isWaitingRow(row)) return 'ongoing'
-  if (isFailedRow(row)) return 'error'
+/** The dot beside a row: its fiber phase, or idle. */
+function rowDotState(row: PackageRow): StateDotState {
   if (!row.enabled || row.phase === null) return 'idle'
   return PHASE_STATES[row.phase]
 }
 
 /**
  * A pack's rows as a list in the order the pack declares them: a state dot,
- * the row id, one line saying its state, the failure when one is recorded,
- * and, when the pack is switchable, a switch — except on a row another layer
- * owns, of which nothing mounted. A pack like base carries close to a hundred
- * rows, so a long list gets a filter.
+ * the row id, one line saying its state, and, when the pack is on, a switch.
+ * A pack like base carries close to a hundred rows, so a long list gets a filter.
  */
 function RowsSection({ rows, t, toggle }: {
-  readonly rows: readonly RowView[]
+  readonly rows: readonly PackageRow[]
   readonly t: Translate
   readonly toggle?: RowToggles | undefined
 }): ReactNode {
@@ -219,33 +147,29 @@ function RowsSection({ rows, t, toggle }: {
         ? null
         : (
           <ul className={css.rows}>
-            {shown.map((row) => {
-              const rowToggle = toggle === undefined || row.failure?.stage === 'conflict'
-                ? undefined
-                : { busy: toggle.busy(row.rowId), onChange: (disabled: boolean) => { toggle.onSetDisabled(row, disabled) } }
-              return (
-                <li
-                  key={row.entryId}
-                  className={css.row}
-                  data-plugin-row={row.entryId}
-                  {...isWaitingRow(row) ? { 'data-state': 'waiting' } : isFailedRow(row) ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }}
-                >
-                  <div className={css.rowLine}>
-                    <span className={css.rowIcon} aria-hidden="true"><IconCordisPluginOutline14 /></span>
-                    <div className={css.rowMain}>
-                      <span className={css.rowId}>{row.rowId}</span>
-                      <span className={css.rowModule}>{row.moduleName}</span>
-                    </div>
-                    <span className={css.rowState}>
-                      <StateDot state={rowDotState(row)} size={8} />
-                      {rowStateText(row, t)}
-                    </span>
-                    {rowToggle === undefined ? null : <RowSwitch row={row} t={t} toggle={rowToggle} />}
+            {shown.map(row => (
+              <li
+                key={row.rowId}
+                className={css.row}
+                data-plugin-row={row.entryId ?? row.rowId}
+                {...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }}
+              >
+                <div className={css.rowLine}>
+                  <span className={css.rowIcon} aria-hidden="true"><IconCordisPluginOutline14 /></span>
+                  <div className={css.rowMain}>
+                    <span className={css.rowId}>{row.rowId}</span>
+                    <span className={css.rowModule}>{row.moduleName}</span>
                   </div>
-                  {row.failure === undefined || (row.phase === 'active' && !isFailedRow(row)) ? null : <p className={css.rowFailure}>{row.failure.message}</p>}
-                </li>
-              )
-            })}
+                  <span className={css.rowState}>
+                    <StateDot state={rowDotState(row)} size={8} />
+                    {rowStateText(row, t)}
+                  </span>
+                  {toggle === undefined
+                    ? null
+                    : <RowSwitch row={row} t={t} busy={toggle.busy(row)} onChange={(enabled) => { toggle.onSetEnabled(row, enabled) }} />}
+                </div>
+              </li>
+            ))}
           </ul>
         )}
     </section>
@@ -266,28 +190,37 @@ function OverridesSection({ overrides, t }: { readonly overrides: readonly strin
   )
 }
 
-/** A bundle's enable switch on its card and its page: locked for a built-in bundle, off and locked for one the profile cannot enable. */
+/**
+ * A bundle's enable switch on its card and its page: locked, saying why, for
+ * one the Host protects; off and locked for one it cannot read.
+ */
 function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: {
-  readonly pkg: PluginPackageView
+  readonly pkg: PackageView
   readonly title: string
   readonly t: Translate
   readonly busy: boolean
   readonly onSetEnabled: (enabled: boolean) => void
 }): ReactNode {
-  if (pkg.kind !== 'bundle') return null
   return (
     <Switch
       checked={pkg.enabled}
       label={t('enableToggle', { name: title })}
-      disabled={busy || (!pkg.enabled && pkg.status === 'not-enableable')}
+      disabled={busy || pkg.readOnlyReason !== undefined || (!pkg.enabled && pkg.error !== undefined)}
+      {...pkg.readOnlyReason === undefined ? {} : { title: pkg.readOnlyReason }}
       onChange={onSetEnabled}
     />
   )
 }
 
+/** The status one card carries: running, off, or a problem the Host reported. */
+function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' {
+  if (pkg.error !== undefined) return 'problem'
+  return pkg.enabled ? 'running' : 'disabled'
+}
+
 /** One installed package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */
 function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
-  readonly pkg: PluginPackageView
+  readonly pkg: PackageView
   readonly t: Translate
   readonly busy: boolean
   readonly highlighted: boolean
@@ -295,25 +228,24 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
   readonly onSetEnabled: (enabled: boolean) => void
 }): ReactNode {
   const title = pkg.title ?? shortName(pkg.name)
-  const status = cardStatus(pkg)
+  const status = packageStatus(pkg)
   return (
     <li
       className={`${css.card} ${css.cardLink}`}
       data-plugin-package={pkg.name}
-      data-plugin-status={pkg.status}
+      data-plugin-status={status}
       {...highlighted ? { 'data-plugin-highlight': '' } : {}}
     >
       <div className={css.cardHead}>
         <div className={css.cardMain}>
           <div className={css.titleRow}>
             <button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
-            {status === null ? null : <Tag tone={status === 'problem' ? 'danger' : 'warning'}>{t(STATUS_KEYS[status])}</Tag>}
+            {status === 'problem' ? <Tag tone="danger">{t('statusProblem')}</Tag> : null}
           </div>
           {pkg.description === undefined ? null : <span className={css.cardDesc}>{pkg.description}</span>}
         </div>
         <div className={css.cardEnd}>
           <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
-
         </div>
       </div>
     </li>
@@ -321,33 +253,26 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
 }
 
 /**
- * One package's page: the crumb back to the list; its name, tags, one-liner,
- * and its switch; the reason when one applies; its version and source; its
- * rows with their switches; the built-in rows it changes; and retry and uninstall.
+ * One package's page: the crumb back to the list; its name, tag, one-liner,
+ * and its switch; the Host's problem when it reports one; its version; its
+ * rows with their switches; the built-in rows it changes; and uninstall.
  */
 function PackageDetail({
   pkg, t, busy, rowBusy,
-  onBack, onSetEnabled, onRetry, onUninstall, onSetRowDisabled,
+  onBack, onSetEnabled, onUninstall, onSetRowEnabled,
 }: {
-  readonly pkg: PluginPackageView
+  readonly pkg: PackageView
   readonly t: Translate
   readonly busy: boolean
   /** Whether a row has a write in flight. */
-  readonly rowBusy: (rowId: string) => boolean
+  readonly rowBusy: (row: PackageRow) => boolean
   readonly onBack: () => void
   readonly onSetEnabled: (enabled: boolean) => void
-  readonly onRetry: () => void
   readonly onUninstall: () => void
-  readonly onSetRowDisabled: (row: RowView, disabled: boolean) => void
+  readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void
 }): ReactNode {
   const title = pkg.title ?? shortName(pkg.name)
-  const bundle = pkg.kind === 'bundle'
-  const status = cardStatus(pkg)
-  const retryable = bundle && pkg.enabled && (pkg.status === 'failed' || pkg.status === 'partial')
-  const affectedIssues = (pkg.issues ?? []).filter(issue => !pkg.rows.some(row => row.entryId === issue.entryId))
-  // A row's switch acts at once only on a pack that is on, composed on a
-  // profile that applies patches while it runs; elsewhere the rows stay read-only.
-  const switchable = bundle && pkg.enabled && pkg.liveReload
+  const status = packageStatus(pkg)
   return (
     <div className={css.detail} data-plugin-detail={pkg.name}>
       <button type="button" className={css.crumb} aria-label={t('backToList')} onClick={onBack}>
@@ -360,20 +285,17 @@ function PackageDetail({
         <div className={css.detailMain}>
           <div className={css.titleRow}>
             <h3 className={css.detailTitle}>{title}</h3>
-            {status === null ? null : <Tag tone={status === 'problem' ? 'danger' : 'warning'}>{t(STATUS_KEYS[status])}</Tag>}
+            {status === 'problem' ? <Tag tone="danger">{t('statusProblem')}</Tag> : null}
           </div>
           <p className={css.detailDesc}>{pkg.description ?? t('noDescription')}</p>
         </div>
         <div className={css.detailActions}>
-          {retryable
-            ? <Button variant="outline" size="sm" disabled={busy} onClick={onRetry}>{t('retryPackage')}</Button>
-            : null}
           <Button
             variant="outline"
             size="sm"
             className={css.danger}
             aria-label={t('uninstallLabel', { name: title })}
-            disabled={busy}
+            disabled={busy || pkg.readOnlyReason !== undefined}
             onClick={onUninstall}
           >
             {t('uninstall')}
@@ -381,47 +303,21 @@ function PackageDetail({
           <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
         </div>
       </div>
-      {pkg.reason === undefined || status === 'waiting' ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {pkg.reason}</p>}
-      {pkg.kind === 'unknown' ? <p className={css.detailDesc}>{t('unknownPackage')}</p> : null}
+      {pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {pkg.error}</p>}
+      {pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{pkg.readOnlyReason}</p>}
       {pkg.version === undefined ? null : <dl className={css.facts}><dt>{t('versionLabel')}</dt><dd>{pkg.version}</dd></dl>}
       <div className={css.detailSections}>
-        {affectedIssues.length === 0 ? null : (
-          <section className={css.detailSection} data-plugin-affected-issues>
-            <h4 className={css.sectionTitle}>{t('affectedIssuesLabel')}</h4>
-            <ul className={css.rows}>
-              {affectedIssues.map(issue => (
-                <li key={issue.entryId} className={css.row}>
-                  <span className={css.rowId}>{issue.entryId}</span>
-                  <span className={css.rowModule}>{issue.moduleName}</span>
-                  <p className={css.rowFailure}>{issue.message}</p>
-                </li>
-              ))}
-            </ul>
-          </section>
-        )}
-        {bundle
-          ? (
-            <RowsSection
-              rows={pkg.rows}
-              t={t}
-              toggle={switchable ? { busy: rowId => busy || rowBusy(rowId), onSetDisabled: onSetRowDisabled } : undefined}
-            />
-          )
-          : null}
+        <RowsSection
+          rows={pkg.rows}
+          t={t}
+          toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined}
+        />
         {pkg.overrides.length === 0 ? null : <OverridesSection overrides={pkg.overrides} t={t} />}
-
       </div>
     </div>
   )
 }
 
-/** The sentence for one package the Host removed again after the install. */
-function removedText(entry: PluginInstallRejection, t: Translate): string {
-  return entry.reason.startsWith('row ')
-    ? t('installRemovedConflict', { name: entry.name, reason: entry.reason })
-    : t('installRemovedInvalid', { name: entry.name, reason: entry.reason })
-}
-
 /** Output lines an install run's terminal shows before its middle folds: the first and last six of a long pnpm log. */
 const INSTALL_TERMINAL_LINES = 12
 
@@ -451,6 +347,7 @@ const INPUT_PROBLEM_KEYS = {
   'already-installed': 'installProblemInstalled',
   'not-found': 'installProblemNotFound',
   'not-a-package': 'installProblemNotPackage',
+  'not-a-bundle': 'installProblemNotBundle',
   'network': 'installProblemNetwork',
   'unknown': 'installProblemUnknown',
 } satisfies Record<InstallInputError['problem'], PluginManagerLocaleKey>
@@ -489,12 +386,12 @@ const SUBJECT_KIND_KEYS = {
 
 /**
  * The failed screen's one line: a pnpm failure by its kind, any other
- * refusal in the Host's words; the run's output stays behind the details.
+ * failure in the Host's words; the run's output stays behind the details.
  */
 function failureText(failure: InstallState['failure'], t: Translate): string {
   if (failure === null) return t('installFailureGeneric')
-  if (failure.code === 'plugins/install-failed') return t(FAILURE_KIND_KEYS[failure.kind ?? 'unknown'])
-  return refusalText(failure, t)
+  if (failure.kind !== undefined) return t(FAILURE_KIND_KEYS[failure.kind])
+  return failure.reason === '' ? t('installFailureGeneric') : failure.reason
 }
 
 /** The package the install is about: its title, one-liner, and version, as the Host read them before installing. */
@@ -567,7 +464,7 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
   const pending = isInstallPending(phase)
   // Only a run the Host acknowledged can be stopped; before that, and while it stops or applies, the controls wait.
   const stoppable = phase === 'running' || phase === 'failed'
-  const unconfirmed = install.failure?.code === 'client/cancel-unconfirmed' ? install.failure.reason : undefined
+  const unconfirmed = install.failure?.cancelUnconfirmed === true ? install.failure.reason : undefined
   const firstRun = install.runs[0]
   return (
     <Modal open={install.open} onClose={onClose} title={heading} headless className={css.installDialog as string}>
@@ -596,14 +493,11 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
           {unconfirmed === undefined ? null : <p className={css.wizardSub} role="alert">{t('installCancelUnconfirmed', { reason: unconfirmed })}</p>}
         </div>
         {install.subject === null ? null : <SubjectCard subject={install.subject} t={t} />}
-        {phase === 'done' && install.installed.length === 0 && install.removed.length === 0
+        {phase === 'done' && install.installed === null
           ? <p className={css.result} role="status">{t('installDoneNothing')}</p>
           : null}
-        {phase === 'done'
-          ? install.plain.map(name => <p key={name} className={css.resultWarn} role="status">{t('installDoneNotBundle', { name })}</p>)
-          : null}
-        {phase === 'done'
-          ? install.removed.map(entry => <p key={entry.name} className={css.resultWarn} role="status">{removedText(entry, t)}</p>)
+        {phase === 'done' && install.restartRequired
+          ? <p className={css.resultWarn} role="status">{t('installDoneRestart')}</p>
           : null}
         <div className={css.wizardFoot}>
           <button type="button" className={css.detailsToggle} aria-expanded={install.detailsOpen} onClick={onToggleDetails}>
@@ -640,7 +534,7 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
           : null}
         {phase !== 'done'
           ? null
-          : install.installedOnly.length > 0
+          : install.installed !== null
             ? <Button variant="primary" className={css.wide} disabled={install.enabling} aria-busy={install.enabling} onClick={onEnableNow}>{t('installEnableNow')}</Button>
             : <Button variant="primary" className={css.wide} onClick={onClose}>{t('installClose')}</Button>}
       </div>
@@ -648,72 +542,41 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
   )
 }
 
-/** The copy of each confirmation: its title, its one-line description, and its action button. */
-const CONFIRM_KEYS = {
-  uninstall: { titleKey: 'confirmUninstallTitle', descriptionKey: 'confirmUninstallDescription', actionKey: 'confirmUninstall' },
-  disable: { titleKey: 'confirmDisableTitle', descriptionKey: 'confirmDisableDescription', actionKey: 'confirmDisable' },
-  disableRow: { titleKey: 'confirmDisableRowTitle', descriptionKey: 'confirmDisableRowDescription', actionKey: 'confirmDisableRow' },
-} as const satisfies Record<ConfirmState['action'], Record<'titleKey' | 'descriptionKey' | 'actionKey', PluginManagerLocaleKey>>
-
-/** The confirmation a destructive action waits on, naming what still uses the package or the row. */
+/** The confirmation an uninstall waits on. */
 function ConfirmDialog({ confirm, t, packages, onConfirm, onCancel }: {
   readonly confirm: ConfirmState
   readonly t: Translate
-  readonly packages: readonly PluginPackageView[]
+  readonly packages: readonly PackageView[]
   readonly onConfirm: () => void
   readonly onCancel: () => void
 }): ReactNode {
   const pkg = packages.find(candidate => candidate.name === confirm.packageName)
-  const name = confirm.action === 'disableRow' ? confirm.rowId : pkg?.title ?? shortName(confirm.packageName)
-  const keys = CONFIRM_KEYS[confirm.action]
-  const dependents = confirm.dependents
-  const lines = dependents === undefined
-    ? []
-    : [
-      ...dependents.services.map(service => t('dependentService', { rows: service.injectedBy.map(id => rowLabel(t, id)).join(', ') })),
-      ...dependents.references.map((reference) => {
-        const owner = packageOf(reference.moduleName, packages)
-        const row = owner === undefined ? reference.rowId : owner.title ?? shortName(owner.name)
-        return t('dependentReferenceGlobal', { row })
-      }),
-    ]
+  const name = pkg?.title ?? shortName(confirm.packageName)
   return (
     <Modal
       open
       onClose={onCancel}
-      title={t(keys.titleKey, { name })}
+      title={t('confirmUninstallTitle', { name })}
       closeLabel={t('close')}
-      description={t(keys.descriptionKey)}
+      description={t('confirmUninstallDescription')}
       footer={(
         <>
           <Button variant="outline" onClick={onCancel}>{t('cancel')}</Button>
-          <Button variant="primary" className={css.dangerButton} disabled={dependents === undefined} onClick={onConfirm}>
-            {t(keys.actionKey)}
+          <Button variant="primary" className={css.dangerButton} onClick={onConfirm}>
+            {t('confirmUninstall')}
           </Button>
         </>
       )}
-    >
-      {dependents === undefined ? <p className={css.status}>{t('confirmChecking')}</p> : null}
-      {lines.length > 0
-        ? (
-          <>
-            <p className={css.status}>{t('confirmDependents')}</p>
-            <ul className={css.dependents}>{lines.map(line => <li key={line}>{line}</li>)}</ul>
-          </>
-        )
-        : null}
-    </Modal>
+    />
   )
 }
 
-/** Render the plugin manager: the installed packages, the install dialog, and the confirmation. */
+/** Render the plugin manager: the installed bundles, the install dialog, and the confirmation. */
 export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
   const { t, ensure } = props
   const state = props.usePluginManager(snapshot => snapshot)
   // The package whose page is open; one that leaves the list (uninstalled) drops back to the cards.
   const [openPackage, setOpenPackage] = useState<string | null>(null)
-  // The folded group of dependencies that are not packs.
-  const [pluginsOpen, setPluginsOpen] = useState(false)
   useEffect(() => { ensure() }, [ensure])
   // A package an install just enabled: scroll it into view and mark it for a moment.
   const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight }
@@ -726,12 +589,15 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
   }, [highlight, clearHighlight])
   const noticeLine = state.notice === null ? null : noticeText(state.notice, t)
 
-  // The page manages what the person installed; the bundles the profile
-  // template supplies are inspected in the Settings Plugins section's Plugin list tab.
+  // The page manages what the person installed; the bundles the dsh
+  // installation supplies are inspected in the Settings Plugins section's Plugin list tab.
   const listed = state.packages.filter(pkg => pkg.installed)
-  const restartPending = listed.filter(pkg => pkg.status === 'restart-required').map(pkg => pkg.title ?? shortName(pkg.name))
   const loaded = state.status === 'ready' || state.status === 'error'
   const openPkg = openPackage === null ? undefined : listed.find(pkg => pkg.name === openPackage)
+  const setRowEnabled = (row: PackageRow, enabled: boolean): void => {
+    /* v8 ignore next -- a row without a live entry has its switch disabled */
+    if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled)
+  }
 
   return (
     <section className={css.page} data-plugin-panel aria-busy={state.status === 'loading'}>
@@ -761,9 +627,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
           </div>
         )
         : null}
-      {restartPending.length > 0
-        ? <p className={css.banner} role="status">{t('restartBanner', { names: restartPending.join(', ') })}</p>
-        : null}
       {state.notice === null || noticeLine === null
         ? null
         : (
@@ -781,61 +644,38 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
             pkg={openPkg}
             t={t}
             busy={state.busy.includes(openPkg.name)}
-            rowBusy={rowId => state.busy.includes(rowKey(rowId))}
+            rowBusy={row => row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))}
             onBack={() => { setOpenPackage(null) }}
             onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
-            onRetry={() => { props.retry(openPkg.name) }}
             onUninstall={() => { props.uninstall(openPkg.name) }}
-            onSetRowDisabled={(row, disabled) => {
-              // Off asks first when other rows inject what the row provides; on has nothing to ask.
-              if (disabled) props.disableRow(openPkg.name, row.entryId, row.rowId)
-              else props.setRowDisabled(row.rowId, false)
-            }}
+            onSetRowEnabled={setRowEnabled}
           />
         )
         : null}
       {loaded && openPkg === undefined
         ? listed.length === 0
           ? <p className={css.empty}>{t('empty')}</p>
-          : PACKAGE_GROUPS.map((group) => {
-            const members = listed.filter(group.holds)
-            const open = !group.folds || pluginsOpen
-            return members.length === 0
-              ? null
-              : (
-                <section key={group.key} className={css.group} data-plugin-scope="global" data-plugin-group={group.key}>
-                  <div className={css.groupTitleRow}>
-                    {group.folds
-                      ? (
-                        <button type="button" className={css.groupToggle} aria-expanded={open} onClick={() => { setPluginsOpen(value => !value) }}>
-                          <IconChevronDownOutline14 className={css.groupChevron} aria-hidden="true" />
-                          <span className={css.groupTitle}>{t(group.titleKey)}</span>
-                        </button>
-                      )
-                      : <h3 className={css.groupTitle}>{t(group.titleKey)}</h3>}
-                    <span className={css.count} data-plugin-count={members.length}>{`${String(members.length)} ${t('countUnit')}`}</span>
-                  </div>
-                  {group.introKey === undefined ? null : <p className={css.groupIntro}>{t(group.introKey)}</p>}
-                  {open
-                    ? (
-                      <ul className={css.cards}>
-                        {members.map(pkg => (
-                          <PackageCard
-                            key={pkg.name}
-                            pkg={pkg}
-                            t={t}
-                            busy={state.busy.includes(pkg.name)}
-                            highlighted={state.highlight === pkg.name}
-                            onOpen={() => { setOpenPackage(pkg.name) }}
-                            onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
-                          />
-                        ))}
-                      </ul>
-                    )
-                    : null}
-                </section>
-              )
-          })
+          : (
+            <section className={css.group} data-plugin-scope="global" data-plugin-group="bundles">
+              <div className={css.groupTitleRow}>
+                <h3 className={css.groupTitle}>{t('bundlesTitle')}</h3>
+                <span className={css.count} data-plugin-count={listed.length}>{`${String(listed.length)} ${t('countUnit')}`}</span>
+              </div>
+              <ul className={css.cards}>
+                {listed.map(pkg => (
+                  <PackageCard
+                    key={pkg.name}
+                    pkg={pkg}
+                    t={t}
+                    busy={state.busy.includes(pkg.name)}
+                    highlighted={state.highlight === pkg.name}
+                    onOpen={() => { setOpenPackage(pkg.name) }}
+                    onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
+                  />
+                ))}
+              </ul>
+            </section>
+          )
         : null}
       <InstallDialog
         install={state.install}

+ 9 - 9
packages/client/ui-plugin-manager/src/client/index.ts

@@ -1,8 +1,8 @@
 /**
  * Plugin manager, browser half: the **Plugins** entry of the sidebar and the
- * management page it opens in the main column. The page installs, enables, disables,
- * retries, and uninstalls the packages of the Host's profile through the
- * `plugins` Remote and edits global rows in the profile's user layer.
+ * management page it opens in the main column. The page installs, enables,
+ * disables, and removes the bundles of the Host's profile through the
+ * `pluginManager` Remote and switches their rows in the profile's user layer.
  * Configuring a plugin stays in Settings.
  */
 
@@ -18,7 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 // Type-only: the forwarded events' own declaration (`$on`'s key face resolves
 // through the owning package's client-safe types subpath).
-import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
+import type {} from '@deepseek-ai/dsh-plugin-manager/types'
 import { PluginManagerPage } from './PluginManagerPage.tsx'
 import { PluginsPanelIcon } from './PluginsPanelIcon.tsx'
 import { PluginManagerController } from './manager-store.ts'
@@ -43,8 +43,8 @@ export const NS = 'pluginManager'
 /** The id shared by the sidebar entry and the main panel it opens. */
 export const PANEL_ID = 'plugins' as MainPanelId
 
-/** Services required by the Settings registration and the Remote methods. */
-export const inject = ['slots', 'locale', 'remote', 'remote.plugins']
+/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */
+export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory']
 
 /**
  * Contribute the Plugins entry to the sidebar with the management page it
@@ -64,9 +64,9 @@ export function apply(ctx: ClientContext): void {
       if (controller.getSnapshot().status !== 'idle') void controller.load()
     }
     const disposers = [
-      ctx.remote.$on('plugins/changed', refresh),
-      ctx.remote.$on('plugins/install-log', (chunk) => { controller.appendLog(chunk) }),
-      ctx.remote.$on('plugins/install-state', (progress) => { controller.installProgress(progress) }),
+      ctx.remote.$on('plugin-manager/changed', refresh),
+      ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }),
+      ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }),
       ctx.on('connection/reset', refresh),
     ]
     return () => { for (const dispose of disposers) dispose() }

+ 8 - 140
packages/client/ui-plugin-manager/src/client/locales.ts

@@ -12,35 +12,24 @@ export const zh = {
   refresh: '刷新',
   empty: '还没有安装任何插件。',
   addPlugin: '添加插件',
-  restartBanner: '以下更改会在下次启动生效:{names}',
   restartNotice: '更改会在下次启动生效。',
+  overriddenNotice: '{name} 已保存,但更高优先级的 patch 覆盖了它,当前未生效。',
   bundlesTitle: '插件包',
-  pluginsTitle: '非插件包依赖',
-  pluginsIntro: '这些包不是 dsh 插件包,不会被加载;可以在这里卸载。',
   countUnit: '个',
-  statusRestart: '需重启',
-  statusWaiting: '等待依赖',
   statusProblem: '异常',
   reasonLabel: '原因',
-  affectedIssuesLabel: '受影响组件的问题',
   versionLabel: '版本',
   noDescription: '暂无描述。',
   partsLabel: '包含的组件',
   partsEmpty: '这个插件包不包含任何组件。',
   partsCountTotal: '共 {count} 个',
   partsCountRunning: '{count} 运行中',
-  partsCountWaiting: '{count} 等待依赖',
   partsCountOff: '{count} 已停用',
+  partOff: '已关闭',
   partsCountFailed: '{count} 异常',
-  partsCountUpdateFailed: '{count} 更新失败',
-  rowUpdateFailed: '运行中,但最近更新失败',
-  unknownPackage: '此包未提供组合包 patch,可通过 Cordis 配置手动加载模块。',
   partsFilter: '筛选组件',
   partsFilterEmpty: '没有匹配的组件。',
-  partDisabledByUser: '已停用',
-  partDisabledByComposition: '默认关闭',
   partToggle: '启用组件 {name}',
-  partLockedByComposition: '插件包默认关闭了这个组件,不能在这里打开。',
   rowPhasePending: '等待依赖',
   rowPhaseLoading: '加载中',
   rowPhaseActive: '运行中',
@@ -52,10 +41,8 @@ export const zh = {
   crumbRoot: '插件管理',
   rowStateIdle: '未运行',
   overridesLabel: '改动了这些内置组件',
-  retryPackage: '重试',
   uninstall: '卸载',
   uninstallLabel: '卸载 {name}',
-  rowStateFailed: '异常',
   installTitle: '添加插件',
   installDescription: '你可以从 Git 社区中获取插件 ID,例如 dsh-better-sidebar、github:someone/dsh-plugin,或输入本地路径例如 /path/to/plugin。',
   installSpecLabel: '包名或地址',
@@ -66,6 +53,7 @@ export const zh = {
   installProblemInstalled: '该插件已安装',
   installProblemNotFound: '未找到相关插件',
   installProblemNotPackage: '该路径不存在或不是有效的插件包',
+  installProblemNotBundle: '这个包没有声明组合包,无法作为插件安装:{reason}',
   installProblemNetwork: '无法连接插件源,请检查网络后重试',
   installProblemUnknown: '无法获取插件信息:{reason}',
   installingTitle: '插件安装中…',
@@ -113,68 +101,14 @@ export const zh = {
   terminalSignal: '信号 {signal}',
   terminalNoExitCode: '未正常退出',
   installDoneNothing: '安装完成,没有新增依赖。',
-  installDoneNotBundle: '{name} 不是插件包,安装后不会自动加载。',
-  installRemovedInvalid: '{name} 的插件包声明无效,已自动移除:{reason}',
-  installRemovedConflict: '{name} 与已安装的插件包冲突,已自动移除:{reason}',
-  installFailed: '安装失败。',
-  busy: '另一个改动还在进行,等它结束再试:{reason}',
-  agentsRunning: '有会话正在运行,等它空闲后再改动插件:{reason}',
+  installDoneRestart: '已安装,下次启动后加载。',
   installClose: '完成',
   close: '关闭',
   cancel: '取消',
   confirmUninstallTitle: '卸载「{name}」?',
-  confirmDisableTitle: '停用「{name}」?',
   confirmUninstallDescription: '卸载后它提供的功能会消失。',
-  confirmDisableDescription: '停用后它提供的功能会暂停。',
-  confirmChecking: '正在检查影响…',
-  confirmDependents: '这些地方还在用它:',
-  dependentService: '{rows} 依赖它提供的功能',
-  dependentReferenceGlobal: '所有会话里加入的插件 {row}',
   confirmUninstall: '卸载',
-  confirmDisable: '停用',
-  confirmDisableRowTitle: '关闭「{name}」?',
-  confirmDisableRowDescription: '关闭后,依赖它的组件会等待依赖。',
-  confirmDisableRow: '关闭',
   actionFailed: '操作失败:{reason}',
-  notEnableable: '无法启用:{reason}',
-  enableFailed: '无法应用插件包:{reason}',
-  notInstalled: '{name} 没有安装在当前 profile。',
-  'name.persona': '人设',
-  'name.agent-instructions': '项目指令',
-  'name.tool-bash': '终端',
-  'name.tool-pwsh': 'PowerShell 终端',
-  'name.tool-fs': '文件读写',
-  'name.tool-fs-search': '文件搜索',
-  'name.tool-jobs': '后台任务',
-  'name.skill-filesystem': '技能',
-  'name.tool-skill': '技能调用',
-  'name.command-goal': '目标命令',
-  'name.tool-goal': '目标',
-  'name.plan-mode': '计划模式',
-  'name.compaction-basic': '上下文压缩',
-  'name.command-compact': '压缩命令',
-  'name.tool-result-pruner': '工具结果裁剪',
-  'name.tool-subagent-control': '子代理控制',
-  'name.tool-subagent-list-agents': '子代理列表',
-  'name.tool-subagent': '子代理',
-  'name.tool-subagent-fork': '分支子代理',
-  'name.tool-subagent-codex': 'Codex 子代理',
-  'name.tool-subagent-claude-code': 'Claude Code 子代理',
-  'name.workflow-ptc': '工作流引擎',
-  'name.tool-workflow': '工作流',
-  'name.tool-ralph': 'Ralph 循环',
-  'name.tool-ask-user': '向用户提问',
-  'name.tool-todo': '待办',
-  'name.tool-web': '网页',
-  'name.tool-cordis': '动态插件',
-  'name.tool-presentation': '工具展示',
-  'name.pty': '终端会话',
-  'name.terminal-bash': 'Bash 终端',
-  'name.persistent-bash': '持久终端工具',
-  'name.terminal-pwsh': 'PowerShell 会话',
-  'name.persistent-pwsh': '持久 PowerShell 工具',
-  'name.fs-local': '本地文件系统',
-  'name.str-replace-editor': '文本编辑器',
 } satisfies Record<string, string>
 
 /** Plugin manager locale key union. */
@@ -192,35 +126,24 @@ export const en = {
   refresh: 'Refresh',
   empty: 'No plugins are installed yet.',
   addPlugin: 'Add plugin',
-  restartBanner: 'These changes take effect at the next start: {names}',
   restartNotice: 'The change takes effect at the next start.',
+  overriddenNotice: '{name} was saved, but a higher-priority patch overrides it, so it is not in effect.',
   bundlesTitle: 'Plugin packs',
-  pluginsTitle: 'Dependencies that are not plugin packs',
-  pluginsIntro: 'These packages are not dsh plugin packs and never load; they can be uninstalled here.',
   countUnit: 'packages',
-  statusRestart: 'Restart needed',
-  statusWaiting: 'Waiting for a dependency',
   statusProblem: 'Problem',
   reasonLabel: 'Reason',
-  affectedIssuesLabel: 'Issues in affected components',
   versionLabel: 'Version',
   noDescription: 'No description.',
   partsLabel: 'Components',
   partsEmpty: 'This plugin pack contains no components.',
   partsCountTotal: '{count} total',
   partsCountRunning: '{count} running',
-  partsCountWaiting: '{count} waiting',
   partsCountOff: '{count} off',
+  partOff: 'Off',
   partsCountFailed: '{count} failed',
-  partsCountUpdateFailed: '{count} update failed',
-  rowUpdateFailed: 'Running; latest update failed',
-  unknownPackage: 'This package has no bundle patch. Load its modules through Cordis configuration.',
   partsFilter: 'Filter components',
   partsFilterEmpty: 'No component matches.',
-  partDisabledByUser: 'Off',
-  partDisabledByComposition: 'Off by default',
   partToggle: 'Enable component {name}',
-  partLockedByComposition: 'The pack keeps this component off by default; it cannot be switched on here.',
   rowPhasePending: 'Waiting for dependencies',
   rowPhaseLoading: 'Loading',
   rowPhaseActive: 'Running',
@@ -232,10 +155,8 @@ export const en = {
   crumbRoot: 'Manage plugins',
   rowStateIdle: 'Not running',
   overridesLabel: 'Changes these built-in components',
-  retryPackage: 'Retry',
   uninstall: 'Uninstall',
   uninstallLabel: 'Uninstall {name}',
-  rowStateFailed: 'Problem',
   installTitle: 'Add plugin',
   installDescription: 'Use a plugin ID from the Git community, such as dsh-better-sidebar or github:someone/dsh-plugin, or a local path such as /path/to/plugin.',
   installSpecLabel: 'Package or address',
@@ -246,6 +167,7 @@ export const en = {
   installProblemInstalled: 'This plugin is already installed',
   installProblemNotFound: 'No such plugin was found',
   installProblemNotPackage: 'The path does not exist or is not a valid plugin package',
+  installProblemNotBundle: 'This package declares no bundle, so it cannot be installed as a plugin: {reason}',
   installProblemNetwork: 'The plugin registry could not be reached; check the network and try again',
   installProblemUnknown: 'The plugin could not be looked up: {reason}',
   installingTitle: 'Installing the plugin…',
@@ -293,66 +215,12 @@ export const en = {
   terminalSignal: 'signal {signal}',
   terminalNoExitCode: 'no exit code',
   installDoneNothing: 'Install finished with no new dependency.',
-  installDoneNotBundle: '{name} is not a plugin pack and does not load on its own.',
-  installRemovedInvalid: '{name} has invalid plugin pack declarations and was removed: {reason}',
-  installRemovedConflict: '{name} conflicts with an installed plugin pack and was removed again: {reason}',
-  installFailed: 'The install failed.',
-  busy: 'Another change is still running; try again when it finishes: {reason}',
-  agentsRunning: 'A session is running; change plugins once it is idle: {reason}',
+  installDoneRestart: 'Installed; it loads at the next start.',
   installClose: 'Done',
   close: 'Close',
   cancel: 'Cancel',
   confirmUninstallTitle: 'Uninstall "{name}"?',
-  confirmDisableTitle: 'Switch off "{name}"?',
   confirmUninstallDescription: 'What it provides goes away once it is uninstalled.',
-  confirmDisableDescription: 'What it provides pauses while it is off.',
-  confirmChecking: 'Checking what depends on it…',
-  confirmDependents: 'These still use it:',
-  dependentService: '{rows} depend on what it provides',
-  dependentReferenceGlobal: 'the plugin {row} added to all sessions',
   confirmUninstall: 'Uninstall',
-  confirmDisable: 'Switch off',
-  confirmDisableRowTitle: 'Switch off "{name}"?',
-  confirmDisableRowDescription: 'Rows that depend on what it provides wait for it while it is off.',
-  confirmDisableRow: 'Switch off',
   actionFailed: 'The action failed: {reason}',
-  notEnableable: 'Cannot be enabled: {reason}',
-  enableFailed: 'Could not apply this plugin pack: {reason}',
-  notInstalled: '{name} is not installed in the current profile.',
-  'name.persona': 'Persona',
-  'name.agent-instructions': 'Project instructions',
-  'name.tool-bash': 'Terminal',
-  'name.tool-pwsh': 'PowerShell terminal',
-  'name.tool-fs': 'Files',
-  'name.tool-fs-search': 'File search',
-  'name.tool-jobs': 'Background jobs',
-  'name.skill-filesystem': 'Skills',
-  'name.tool-skill': 'Skill invocation',
-  'name.command-goal': 'Goal command',
-  'name.tool-goal': 'Goals',
-  'name.plan-mode': 'Plan mode',
-  'name.compaction-basic': 'Context compaction',
-  'name.command-compact': 'Compact command',
-  'name.tool-result-pruner': 'Tool result pruning',
-  'name.tool-subagent-control': 'Subagent control',
-  'name.tool-subagent-list-agents': 'Subagent list',
-  'name.tool-subagent': 'Subagent',
-  'name.tool-subagent-fork': 'Fork subagent',
-  'name.tool-subagent-codex': 'Codex subagent',
-  'name.tool-subagent-claude-code': 'Claude Code subagent',
-  'name.workflow-ptc': 'Workflow engine',
-  'name.tool-workflow': 'Workflows',
-  'name.tool-ralph': 'Ralph loop',
-  'name.tool-ask-user': 'Ask the user',
-  'name.tool-todo': 'To-do',
-  'name.tool-web': 'Web',
-  'name.tool-cordis': 'Dynamic plugins',
-  'name.tool-presentation': 'Tool presentation',
-  'name.pty': 'Terminal sessions',
-  'name.terminal-bash': 'Bash terminal',
-  'name.persistent-bash': 'Persistent terminal tool',
-  'name.terminal-pwsh': 'PowerShell session',
-  'name.persistent-pwsh': 'Persistent PowerShell tool',
-  'name.fs-local': 'Local filesystem',
-  'name.str-replace-editor': 'Text editor',
 } satisfies Record<PluginManagerLocaleKey, string>

+ 209 - 278
packages/client/ui-plugin-manager/src/client/manager-store.ts

@@ -1,24 +1,23 @@
 /**
- * The plugin manager's state: the Host's package views
- * the action in flight, the install run, and the
- * confirmation a destructive action waits on. Every fact comes from the Host — the store
- * re-reads after each action and after every `plugins/changed` event, so a
+ * The plugin manager's state: the Host's bundles joined with its plugin
+ * entries, the action in flight, the install run, and the confirmation an
+ * uninstall waits on. Every fact comes from the Host — the store re-reads
+ * after each action and after every `plugin-manager/changed` event, so a
  * change made on another surface shows here without a manual refresh.
  */
 
 import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {
-  PluginDependents,
-  PluginEnableResult,
+  BundleInfo,
+  ChangeResult,
+  PluginEntryId,
+  PluginInfo,
   PluginInspectProblem,
   PluginInstallFailureKind,
   PluginInstallLogChunk,
   PluginInstallProgress,
-  PluginInstallRejection,
   PluginInstallRequestId,
-  PluginInstallResult,
-  PluginPackageView,
   PluginSpecInspection,
 } from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -26,23 +25,54 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client
 /** What the last action left to say, shown as a toast; `seq` tells one showing from the next. */
 export type ManagerNotice =
   | { readonly kind: 'restart'; readonly packageName: string; readonly seq: number }
+  | { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number }
   | { readonly kind: 'cancelled'; readonly seq: number }
   | {
     readonly kind: 'failed'
-    /** The Host's failure code, which selects the copy. */
-    readonly code: string
     /** The Host's reason, shown verbatim. */
     readonly reason: string
     readonly packageName?: string
-    readonly rowId?: string
     readonly seq: number
   }
 
-/** The typed spec as the Host read it, on the installing, installed, and failed screens. */
-export interface InstallSubject extends PluginSpecInspection {
-  readonly spec: string
+/** One row a bundle contributes, as the page lists it: the patch's declaration joined with its live entry. */
+export interface PackageRow {
+  /** The Loader entry carrying the row while the bundle is on; absent for a row of a bundle that is off. */
+  readonly entryId?: PluginEntryId
+  /** The row id as the bundle declares it. */
+  readonly rowId: string
+  /** The module the row names. */
+  readonly moduleName: string
+  /** Whether the entry runs; false for a row without a live entry. */
+  readonly enabled: boolean
+  /** The entry's fiber phase, null without a live fiber. */
+  readonly phase: PluginInfo['fiberPhase']
+  /** Why the Host refuses to switch the row, when it does. */
+  readonly readOnlyReason?: string
+}
+
+/** One bundle as the page shows it: the Host's bundle joined with the entries its rows run as. */
+export interface PackageView {
+  readonly name: string
+  readonly version?: string
+  readonly title?: string
+  readonly description?: string
+  /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */
+  readonly installed: boolean
+  /** Whether the bundle is in the profile's layer list. */
+  readonly enabled: boolean
+  /** Why the Host refuses to switch the bundle off or remove it, when it does. */
+  readonly readOnlyReason?: string
+  /** Why the Host cannot read the bundle, when it cannot. */
+  readonly error?: string
+  readonly rows: readonly PackageRow[]
+  /** Ids of built-in rows the bundle's patch changes. */
+  readonly overrides: readonly string[]
 }
 
+/** The typed spec as the Host read it, on the installing, installed, and failed screens. */
+export type InstallSubject = Extract<PluginSpecInspection, { status: 'accepted' }> & { readonly spec: string }
+
 /** Why the typed spec was refused before anything installed. */
 export interface InstallInputError {
   readonly problem: PluginInspectProblem
@@ -66,7 +96,7 @@ export interface InstallRun {
  * The install dialog: the spec is typed while `idle`, read by the Host while
  * `checking`, then installed through the Host-owned phases — `starting` until
  * the Host acknowledges the run, `running`, `cancelling` while the Host stops
- * it, `applying` once configuration application closed the cancellation
+ * it, `applying` once the bundle is being loaded, which closed the cancellation
  * window — and the outcome shown as `done` or `failed`. A refused check, a
  * confirmed cancellation, and the back control return to `idle` with the spec kept.
  */
@@ -85,17 +115,17 @@ export interface InstallState {
   readonly runs: readonly InstallRun[]
   /** Whether the run's command and output are unfolded. */
   readonly detailsOpen: boolean
-  /** Dependencies the last run added and kept, once it finished. */
-  readonly installed: readonly string[]
-  /** Bundles the run installed and left off. */
-  readonly installedOnly: readonly string[]
-  /** Packages installed without a bundle patch. */
-  readonly plain: readonly string[]
-  /** Packages pnpm added that the Host removed again, each with its reason. */
-  readonly removed: readonly PluginInstallRejection[]
-  /** The Host's refusal or the run's failure, once one settled the dialog; `kind` classifies a pnpm failure. */
-  readonly failure: { readonly code: string; readonly reason: string; readonly kind?: PluginInstallFailureKind } | null
-  /** Enabling the newly installed bundles from the installed screen is crossing the wire. */
+  /** The bundle the finished run added, left off until enabled from the installed screen. */
+  readonly installed: string | null
+  /** Whether the finished run's bundle waits for the next start to load. */
+  readonly restartRequired: boolean
+  /**
+   * The run's failure, once one settled the dialog; `kind` classifies a pnpm
+   * failure. `cancelUnconfirmed` is a stop the Host did not confirm, shown over
+   * the running screen while the run goes on.
+   */
+  readonly failure: { readonly reason: string; readonly kind?: PluginInstallFailureKind; readonly cancelUnconfirmed?: true } | null
+  /** Enabling the newly installed bundle from the installed screen is crossing the wire. */
   readonly enabling: boolean
 }
 
@@ -108,28 +138,17 @@ export function isInstallPending(phase: InstallState['phase']): boolean {
   return phase === 'starting' || phase === 'running' || phase === 'cancelling' || phase === 'applying'
 }
 
-/** A destructive action waiting for the user's confirmation: a package's uninstall or disable, or one row's switch-off. */
-export type ConfirmState =
-  | {
-    readonly action: 'uninstall' | 'disable'
-    readonly packageName: string
-    /** What the action would strand; undefined while the Host is asked. */
-    readonly dependents: PluginDependents | undefined
-  }
-  | {
-    readonly action: 'disableRow'
-    readonly packageName: string
-    /** The row the switch-off targets. */
-    readonly rowId: string
-    /** The rows injecting what this row provides; undefined while the Host is asked. */
-    readonly dependents: PluginDependents | undefined
-  }
+/** A destructive action waiting for the user's confirmation: a package's uninstall. */
+export interface ConfirmState {
+  readonly action: 'uninstall'
+  readonly packageName: string
+}
 
 /** What the tab renders. */
 export interface PluginManagerState {
-  /** `unavailable` when the Host runs without a profile runtime; `error` keeps the last packages. */
+  /** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */
   readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable'
-  readonly packages: readonly PluginPackageView[]
+  readonly packages: readonly PackageView[]
   /** Package names and row keys with an action crossing the wire. */
   readonly busy: readonly string[]
   readonly notice: ManagerNotice | null
@@ -158,21 +177,18 @@ export interface PluginManagerFace {
   /** Leave the check or the failed screen for the spec, or ask the Host to stop the run and wait for its cleanup. */
   cancelInstall: () => void
   toggleInstallDetails: () => void
-  /** Enable the bundles the finished install added, then close the dialog and mark the first in the list. */
+  /** Enable the bundle the finished install added, then close the dialog and mark it in the list. */
   enableInstalled: () => void
   /** Drop the list mark once it has been shown. */
   clearHighlight: () => void
   /** Put a bundle into, or take it out of, the profile's layer list. */
   setEnabled: (packageName: string, enabled: boolean) => void
-  /** Compose an enabled bundle again from scratch. */
-  retry: (packageName: string) => void
   /** Ask before removing a package from the profile. */
   uninstall: (packageName: string) => void
   confirm: () => void
   cancelConfirm: () => void
-  setRowDisabled: (rowId: string, disabled: boolean) => void
-  /** Switch one of a package's rows off in the global layer, after asking when other rows inject what it provides. */
-  disableRow: (packageName: string, entryId: string, rowId: string) => void
+  /** Switch one of a bundle's rows on or off in the profile's user layer. */
+  setRowEnabled: (entryId: PluginEntryId, enabled: boolean) => void
   dismissNotice: () => void
 }
 
@@ -181,24 +197,12 @@ type Answer<T> =
   | { readonly ok: true; readonly value: T }
   | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details?: unknown } }
 
-/** One string field of a failure's details, when the details carry it. */
-function detailOf(error: { details?: unknown }, field: string): string | undefined {
-  const details = error.details
-  if (typeof details === 'object' && details !== null && field in details) {
-    const value = (details as Record<string, unknown>)[field]
-    if (typeof value === 'string') return value
-  }
-  return undefined
-}
-
-/** The exit code a pnpm failure's details carry: a number, null for no exit code, undefined when the details carry none. */
-function exitCodeOf(error: { details?: unknown }): number | null | undefined {
-  const details = error.details
-  if (typeof details === 'object' && details !== null && 'exitCode' in details) {
-    const value = (details as Record<string, unknown>).exitCode
-    if (typeof value === 'number' || value === null) return value
+/** A refused answer or a change the Host could not apply, carrying what it said. */
+class RemoteAnswerError extends Error {
+  constructor(readonly reason: string) {
+    super(reason)
+    this.name = 'RemoteAnswerError'
   }
-  return undefined
 }
 
 /** The runs with every one still open settled at `exitCode`. */
@@ -206,29 +210,53 @@ function settledRuns(runs: readonly InstallRun[], exitCode: number | null): read
   return runs.map(run => run.exitCode === undefined ? { ...run, exitCode } : run)
 }
 
-/** The Host reason a failure carries, when its details name one; else its message. */
-function reasonOf(error: { message: string; details?: unknown }): string {
-  return detailOf(error, 'reason') ?? error.message
-}
-
 /**
  * The key one row occupies in the busy list.
- * @param rowId - the row's id in that layer.
+ * @param entryId - the row's Loader entry id.
  * @returns the busy key.
  */
-export function rowKey(rowId: string): string {
-  return `global:${rowId}`
+export function rowKey(entryId: string): string {
+  return `row:${entryId}`
+}
+
+/**
+ * One bundle as the page shows it: its rows joined with the Host's entries.
+ * @param bundle - the Host's bundle.
+ * @param plugins - the Host's plugin entries.
+ * @returns the package view.
+ */
+export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): PackageView {
+  const rows = bundle.rows.map((row): PackageRow => {
+    const live = row.entryId === undefined ? undefined : plugins.find(plugin => plugin.entryId === row.entryId)
+    return {
+      rowId: row.rowId,
+      moduleName: row.moduleName,
+      enabled: live?.enabled ?? false,
+      phase: live?.fiberPhase ?? null,
+      ...row.entryId === undefined ? {} : { entryId: row.entryId },
+      ...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason },
+    }
+  })
+  return {
+    name: bundle.name,
+    installed: bundle.installed,
+    enabled: bundle.enabled,
+    rows,
+    overrides: bundle.overrides,
+    ...bundle.version === undefined ? {} : { version: bundle.version },
+    ...bundle.title === undefined ? {} : { title: bundle.title },
+    ...bundle.description === undefined ? {} : { description: bundle.description },
+    ...bundle.readOnlyReason === undefined ? {} : { readOnlyReason: bundle.readOnlyReason },
+    ...bundle.error === undefined ? {} : { error: bundle.error },
+  }
 }
 
 const IDLE_INSTALL: InstallState = {
   open: false, spec: '', phase: 'idle', inputError: null, subject: null, runs: [], detailsOpen: false,
-  installed: [], installedOnly: [], plain: [], removed: [], failure: null, enabling: false,
+  installed: null, restartRequired: false, failure: null, enabling: false,
 }
 
-/** Refusals of an install that are about the moment, not the spec: they toast and leave the spec to try again. */
-const TRANSIENT_REFUSALS = new Set(['plugins/busy', 'plugins/agents-running'])
-
-/** Reads and mutates the profile's plugins through the `plugins` Remote. */
+/** Reads and mutates the profile's plugins through the `pluginManager` Remote. */
 export class PluginManagerController {
   private readonly store: SnapshotStore<PluginManagerState>
   private inFlight: Promise<void> | undefined
@@ -241,7 +269,7 @@ export class PluginManagerController {
   private noticeSeq = 0
 
   /**
-   * @param ctx - the tab plugin's context, whose `remote.plugins` namespace answers.
+   * @param ctx - the tab plugin's context, whose `remote.pluginManager` and `remote.pluginInventory` namespaces answer.
    */
   constructor(
     private readonly ctx: ClientContext,
@@ -294,21 +322,24 @@ export class PluginManagerController {
       toggleInstallDetails: () => { this.patchInstall({ detailsOpen: !this.getSnapshot().install.detailsOpen }) },
       enableInstalled: () => { void this.enableInstalled() },
       clearHighlight: () => { if (this.getSnapshot().highlight !== null) this.patch({ highlight: null }) },
-      setEnabled: (packageName, enabled) => { void this.setEnabled(packageName, enabled) },
-      retry: (packageName) => {
+      setEnabled: (packageName, enabled) => {
         void this.run(packageName, { packageName }, async () => {
-          this.effect(await this.ctx.remote.plugins.retry(packageName), packageName)
+          this.applied(await this.ctx.remote.pluginManager.setBundleEnabled(packageName, enabled), packageName)
         })
       },
-      uninstall: (packageName) => { void this.askConfirm('uninstall', packageName) },
+      uninstall: (packageName) => {
+        this.pendingConfirm = () => this.run(packageName, { packageName }, async () => {
+          this.applied(await this.ctx.remote.pluginManager.removeBundle(packageName), packageName)
+        })
+        this.patch({ confirm: { action: 'uninstall', packageName } })
+      },
       confirm: () => { void this.confirm() },
       cancelConfirm: () => { this.pendingConfirm = undefined; this.patch({ confirm: null }) },
-      setRowDisabled: (rowId, disabled) => {
-        void this.run(rowKey(rowId), { rowId }, async () => {
-          this.answer(await this.ctx.remote.plugins.setRowDisabled(rowId, disabled))
+      setRowEnabled: (entryId, enabled) => {
+        void this.run(rowKey(entryId), { packageName: entryId }, async () => {
+          this.applied(await this.ctx.remote.pluginManager.setPluginEnabled(entryId, enabled), entryId)
         })
       },
-      disableRow: (packageName, entryId, rowId) => { void this.askRowConfirm(packageName, entryId, rowId) },
       dismissNotice: () => { this.patch({ notice: null }) },
     }
   }
@@ -327,7 +358,7 @@ export class PluginManagerController {
 
   /**
    * Fold a chunk belonging to this installation into its pnpm command.
-   * A final chunk may arrive after the add response and still updates an existing run.
+   * A final chunk may arrive after the install answer and still updates an existing run.
    * @param chunk - the chunk the Host forwarded.
    */
   appendLog(chunk: PluginInstallLogChunk): void {
@@ -343,7 +374,7 @@ export class PluginManagerController {
   }
 
   /**
-   * Read the packages and the global composition. A call during an
+   * Read the bundles and the entries their rows run as. A call during an
    * in-flight read marks one rerun after it settles.
    * @returns settlement after this call's freshness is reflected.
    */
@@ -364,15 +395,30 @@ export class PluginManagerController {
         this.rerun = false
         const generation = ++this.generation
         if (this.getSnapshot().status === 'idle') this.patch({ status: 'loading' })
-        const packages = await this.ctx.remote.plugins.list()
+        // The manager Remote is mounted whether or not the Host manages a
+        // profile; the inventory says whether it does.
+        const inventory = await this.ctx.remote.pluginInventory.list()
+        if (generation !== this.generation) return
+        if (!inventory.ok) {
+          this.patch({ status: 'error' })
+          continue
+        }
+        if (inventory.value.managementAvailable !== true) {
+          this.patch({ status: 'unavailable', packages: [] })
+          continue
+        }
+        const [bundles, plugins] = await Promise.all([
+          this.ctx.remote.pluginManager.listBundles(),
+          this.ctx.remote.pluginManager.listPlugins(),
+        ])
         if (generation !== this.generation) return
-        if (!packages.ok) {
-          this.patch({ status: packages.error.code === 'plugins/unavailable' ? 'unavailable' : 'error' })
+        if (!bundles.ok || !plugins.ok) {
+          this.patch({ status: 'error' })
           continue
         }
         this.patch({
           status: 'ready',
-          packages: packages.value,
+          packages: bundles.value.map(bundle => packageView(bundle, plugins.value)),
         })
       } while (this.shouldRerun())
     } finally {
@@ -384,94 +430,6 @@ export class PluginManagerController {
     return this.rerun
   }
 
-  private async setEnabled(packageName: string, enabled: boolean): Promise<void> {
-    if (enabled) {
-      await this.run(packageName, { packageName }, async () => {
-        this.effect(await this.ctx.remote.plugins.enable(packageName), packageName)
-      })
-      return
-    }
-    await this.askConfirm('disable', packageName)
-  }
-
-  /**
-   * Open the confirmation for a destructive action, asking the Host what it
-   * would strand. A disable with nothing depending on the bundle needs no
-   * confirmation and runs at once; every other confirmation runs its action
-   * through `confirm` once the dependents are listed.
-   */
-  private async askConfirm(action: 'uninstall' | 'disable', packageName: string): Promise<void> {
-    const perform = action === 'uninstall'
-      ? async (): Promise<void> => {
-        this.answer(await this.ctx.remote.plugins.uninstall(packageName))
-      }
-      : async (): Promise<void> => {
-        this.effect(await this.ctx.remote.plugins.disable(packageName), packageName)
-      }
-    const commit = (): Promise<void> => this.run(packageName, { packageName }, perform)
-    if (action === 'uninstall') {
-      // Always confirmed: the dialog opens at once and fills in what depends on the package.
-      this.pendingConfirm = commit
-      this.patch({ confirm: { action, packageName, dependents: undefined } })
-      const answer = await this.ctx.remote.plugins.dependents(packageName).catch((): undefined => undefined)
-      // Reads run beside this ask (the Host announces changes while it is
-      // open), so liveness is the confirmation still being this one.
-      const confirm = this.getSnapshot().confirm
-      if (this.disposed || confirm?.packageName !== packageName || confirm.action !== action) return
-      this.patch({ confirm: { ...confirm, dependents: answer?.ok === true ? answer.value : { services: [], references: [] } } })
-      return
-    }
-    // A disable asks first, with the switch inert, and opens the dialog only
-    // for a package something depends on: nothing flashes for one nothing does.
-    const value = await this.dependentsOf(packageName, packageName)
-    if (value === undefined) return
-    if (value.services.length === 0 && value.references.length === 0) {
-      await commit()
-      return
-    }
-    this.pendingConfirm = commit
-    this.patch({ confirm: { action, packageName, dependents: value } })
-  }
-
-  /**
-   * Ask before switching one row off: of the package's dependents, the
-   * services this row provides that other rows inject. The switch stays
-   * inert while the Host answers; a row nothing depends on switches off at
-   * once, and only a row something depends on opens the dialog.
-   */
-  private async askRowConfirm(packageName: string, entryId: string, rowId: string): Promise<void> {
-    const key = rowKey(rowId)
-    const commit = (): Promise<void> => this.run(key, { rowId }, async () => {
-      this.answer(await this.ctx.remote.plugins.setRowDisabled(rowId, true))
-    })
-    const value = await this.dependentsOf(packageName, key)
-    if (value === undefined) return
-    const services = value.services.filter(service => service.providedBy === entryId)
-    if (services.length === 0) {
-      await commit()
-      return
-    }
-    this.pendingConfirm = commit
-    this.patch({ confirm: { action: 'disableRow', packageName, rowId, dependents: { services, references: [] } } })
-  }
-
-  /**
-   * Ask the Host what depends on a package while the control that asked
-   * stays inert under `key`, so nothing opens before the answer is known. A
-   * refused or failed answer reads as nothing: the check must not stand
-   * between the person and the action they asked for, whose own failure
-   * would still be reported.
-   * @returns the dependents, or undefined when the key is already busy or the controller was disposed meanwhile.
-   */
-  private async dependentsOf(packageName: string, key: string): Promise<PluginDependents | undefined> {
-    if (this.getSnapshot().busy.includes(key)) return undefined
-    this.patch({ busy: [...this.getSnapshot().busy, key] })
-    const answer = await this.ctx.remote.plugins.dependents(packageName).catch((): undefined => undefined)
-    this.patch({ busy: this.getSnapshot().busy.filter(entry => entry !== key) })
-    if (this.disposed) return undefined
-    return answer?.ok === true ? answer.value : { services: [], references: [] }
-  }
-
   private async confirm(): Promise<void> {
     const pending = this.pendingConfirm
     this.pendingConfirm = undefined
@@ -510,46 +468,45 @@ export class PluginManagerController {
     this.inspectAbort = controller
     this.patchInstall({
       phase: 'checking', inputError: null, subject: null, runs: [], detailsOpen: false,
-      installed: [], installedOnly: [], plain: [], removed: [], failure: null,
+      installed: null, restartRequired: false, failure: null,
     })
-    const inspected = await this.ctx.remote.plugins.inspect(spec, controller.signal)
+    const inspected = await this.ctx.remote.pluginManager.inspect(spec, controller.signal)
     if (this.gone(controller.signal)) return
     this.inspectAbort = undefined
     if (!inspected.ok) {
-      const problem = detailOf(inspected.error, 'problem')
-      this.patchInstall({
-        phase: 'idle',
-        inputError: { problem: isInspectProblem(problem) ? problem : 'unknown', reason: reasonOf(inspected.error) },
-      })
+      this.patchInstall({ phase: 'idle', inputError: { problem: 'unknown', reason: inspected.error.message } })
+      return
+    }
+    if (inspected.value.status === 'refused') {
+      this.patchInstall({ phase: 'idle', inputError: { problem: inspected.value.problem, reason: inspected.value.reason } })
       return
     }
     const requestId = randomUUID() as PluginInstallRequestId
     this.patchInstall({ phase: 'starting', requestId, subject: { spec, ...inspected.value } })
-    // The Host announces `plugins/changed` while the run is still on the
-    // wire, and every such event reads again; those reads must not cancel
+    // The Host announces `plugin-manager/changed` while the run is still on
+    // the wire, and every such event reads again; those reads must not cancel
     // the run's settlement.
-    const result = await this.ctx.remote.plugins.add(spec, { requestId })
+    const result = await this.ctx.remote.pluginManager.installBundle(spec, { enabled: false, requestId })
     if (this.disposed || this.getSnapshot().install.requestId !== requestId) return
-    // A run whose last chunk never reached the dialog settles from the answer:
-    // a finished install, and a refusal that followed pnpm — a bundle the tree
-    // rejected — both mean every run exited 0, and a pnpm failure names the
-    // code the failing run exited with.
     const runs = this.getSnapshot().install.runs
-    if (result.ok) {
-      this.patchInstall({ phase: 'done', runs: settledRuns(runs, 0), ...outcomeOf(result.value) })
-    } else if (result.error.code === 'plugins/install-cancelled') {
+    if (!result.ok) {
+      this.patchInstall({ phase: 'failed', runs: settledRuns(runs, null), failure: { reason: result.error.message } })
+    } else if (result.value.application === 'cancelled') {
       this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq })
-    } else if (TRANSIENT_REFUSALS.has(result.error.code)) {
-      // The moment refused, not the spec: say so in passing and keep the spec to try again.
-      this.offerSpecAgain(this.failedNotice(result.error, {}))
-    } else {
-      const reason = detailOf(result.error, 'reason') ?? detailOf(result.error, 'log') ?? result.error.message
-      const kind = detailOf(result.error, 'kind')
-      const exitCode = result.error.code === 'plugins/install-failed' ? exitCodeOf(result.error) ?? null : 0
+    } else if (result.value.application === 'failed') {
+      // A run whose last chunk never reached the dialog settles from the answer.
+      const packages = result.value.packageResult
       this.patchInstall({
         phase: 'failed',
-        runs: settledRuns(runs, exitCode),
-        failure: { code: result.error.code, reason, ...isFailureKind(kind) ? { kind } : {} },
+        runs: settledRuns(runs, packages?.exitCode ?? null),
+        failure: { reason: result.value.message, ...packages?.kind === undefined ? {} : { kind: packages.kind } },
+      })
+    } else {
+      this.patchInstall({
+        phase: 'done',
+        runs: settledRuns(runs, 0),
+        installed: result.value.bundle ?? null,
+        restartRequired: result.value.application === 'restart-required',
       })
     }
     void this.load()
@@ -570,11 +527,11 @@ export class PluginManagerController {
     if (install.phase !== 'running' || install.requestId === undefined) return
     const requestId = install.requestId
     this.patchInstall({ phase: 'cancelling', failure: null })
-    const result = await this.ctx.remote.plugins.cancelInstall(requestId)
+    const result = await this.ctx.remote.pluginManager.cancelInstall(requestId)
     const current = this.getSnapshot().install
     if (this.disposed || current.requestId !== requestId || !isInstallPending(current.phase)) return
     if (!result.ok) {
-      this.patchInstall({ phase: 'running', failure: { code: 'client/cancel-unconfirmed', reason: result.error.message } })
+      this.patchInstall({ phase: 'running', failure: { reason: result.error.message, cancelUnconfirmed: true } })
       return
     }
     if (result.value.status === 'cancelled') {
@@ -583,14 +540,14 @@ export class PluginManagerController {
     } else if (result.value.status === 'too-late') {
       this.patchInstall({ phase: 'applying' })
     } else {
-      this.patchInstall({ phase: 'running', failure: { code: 'client/cancel-unconfirmed', reason: '' } })
+      this.patchInstall({ phase: 'running', failure: { reason: '', cancelUnconfirmed: true } })
     }
   }
 
   /**
    * Back to the spec: the check, the run, and its request are forgotten and
    * the spec is kept, with a toast when there is something to say — the Host
-   * stopped the run, or the moment refused it.
+   * stopped the run.
    */
   private offerSpecAgain(notice: ManagerNotice | null = null): void {
     const { open, spec } = this.getSnapshot().install
@@ -598,42 +555,35 @@ export class PluginManagerController {
   }
 
   /**
-   * Enable every bundle the finished install added, one after the other, then
-   * close the dialog and mark the first of them in the list. A refusal toasts
-   * and still closes: the list shows what did and did not switch on.
+   * Enable the bundle the finished install added, then close the dialog and
+   * mark it in the list. A refusal toasts and still closes: the list shows
+   * what did not switch on.
    */
   private async enableInstalled(): Promise<void> {
     const install = this.getSnapshot().install
     if (install.phase !== 'done' || install.enabling) return
+    const name = install.installed
     this.patchInstall({ enabling: true })
-    for (const name of install.installedOnly) {
-      const result = await this.ctx.remote.plugins.enable(name)
+    if (name !== null) {
+      const result = await this.ctx.remote.pluginManager.setBundleEnabled(name, true)
       if (this.disposed) return
-      if (!result.ok) {
-        this.patch({ notice: this.failedNotice(result.error, { packageName: name }) })
-        break
+      try {
+        this.applied(result, name)
+      } catch (error) {
+        this.patch({ notice: { kind: 'failed', reason: reasonOf(error), packageName: name, seq: ++this.noticeSeq } })
       }
-      if (result.value.effect === 'restart') this.patch({ notice: { kind: 'restart', packageName: name, seq: ++this.noticeSeq } })
     }
-    this.patch({ install: IDLE_INSTALL, highlight: install.installedOnly[0] ?? null })
+    this.patch({ install: IDLE_INSTALL, highlight: name })
     await this.load()
   }
 
-  /** The toast a refused answer becomes. */
-  private failedNotice(
-    error: { code: string; message: string; details?: unknown },
-    subject: { packageName?: string; rowId?: string },
-  ): ManagerNotice {
-    return { kind: 'failed', code: error.code, reason: reasonOf(error), ...subject, seq: ++this.noticeSeq }
-  }
-
   /**
    * Run one action under a busy key, turn its failure into the notice, and
    * re-read the Host afterwards whatever happened.
    */
   private async run(
     key: string,
-    subject: { packageName?: string; rowId?: string },
+    subject: { packageName?: string },
     action: () => Promise<void>,
   ): Promise<void> {
     if (this.disposed || this.getSnapshot().busy.includes(key)) return
@@ -641,29 +591,35 @@ export class PluginManagerController {
     try {
       await action()
     } catch (error) {
-      // `patch` drops the notice after disposal; a refused answer carries the
-      // Host's code, anything else is a transport failure.
-      const failure = error instanceof RemoteAnswerError
-        ? error
-        : new RemoteAnswerError('gateway/internal', error instanceof Error ? error.message : String(error))
-      this.patch({ notice: { kind: 'failed', code: failure.code, reason: failure.reason, ...subject, seq: ++this.noticeSeq } })
+      // `patch` drops the notice after disposal.
+      this.patch({ notice: { kind: 'failed', reason: reasonOf(error), ...subject, seq: ++this.noticeSeq } })
     } finally {
       this.patch({ busy: this.getSnapshot().busy.filter(entry => entry !== key) })
     }
     await this.load()
   }
 
-  /** Publish an enable-shaped answer's effect as the notice. */
-  private effect(result: Answer<PluginEnableResult>, packageName: string): void {
-    const value = this.answer(result)
-    // Success needs no notice: the re-read list shows the new state. A restart the change waits for does.
-    if (value.effect === 'restart') this.patch({ notice: { kind: 'restart', packageName, seq: ++this.noticeSeq } })
-  }
-
-  /** Unwrap an answer, throwing its failure for {@link run} to report. */
-  private answer<T>(result: Answer<T>): T {
-    if (result.ok) return result.value
-    throw new RemoteAnswerError(result.error.code, reasonOf(result.error))
+  /**
+   * Publish a change's outcome: a refused answer or a change the Host could
+   * not apply throws for {@link run} to report; a change that waits for the
+   * next start, or that a higher layer overrides, is said in passing.
+   */
+  private applied(answer: Answer<ChangeResult>, packageName: string): void {
+    if (!answer.ok) throw new RemoteAnswerError(answer.error.message)
+    const result = answer.value
+    switch (result.application) {
+      case 'failed':
+      case 'cancelled':
+        throw new RemoteAnswerError(result.message)
+      case 'restart-required':
+        this.patch({ notice: { kind: 'restart', packageName, seq: ++this.noticeSeq } })
+        return
+      case 'overridden':
+        this.patch({ notice: { kind: 'overridden', packageName, seq: ++this.noticeSeq } })
+        return
+      case 'applied':
+        return
+    }
   }
 
   private patch(next: Partial<PluginManagerState>): void {
@@ -676,32 +632,7 @@ export class PluginManagerController {
   }
 }
 
-/** The lists one finished install run reports, as the dialog's state carries them. */
-function outcomeOf(result: PluginInstallResult): Pick<InstallState, 'installed' | 'installedOnly' | 'plain' | 'removed'> {
-  return {
-    installed: result.installed,
-    installedOnly: result.installedOnly,
-    plain: result.plain,
-    removed: result.removed,
-  }
-}
-
-const INSPECT_PROBLEMS: readonly PluginInspectProblem[] = ['invalid-spec', 'already-installed', 'not-found', 'not-a-package', 'network', 'unknown']
-const FAILURE_KINDS: readonly PluginInstallFailureKind[] = [
-  'pnpm-missing', 'timeout', 'not-found', 'no-matching-version', 'network', 'disk-full', 'permission', 'build-blocked', 'integrity', 'unknown',
-]
-
-function isInspectProblem(value: string | undefined): value is PluginInspectProblem {
-  return value !== undefined && (INSPECT_PROBLEMS as readonly string[]).includes(value)
-}
-
-function isFailureKind(value: string | undefined): value is PluginInstallFailureKind {
-  return value !== undefined && (FAILURE_KINDS as readonly string[]).includes(value)
-}
-
-/** A refused Remote answer, carried to the notice with the Host's code and reason. */
-class RemoteAnswerError extends Error {
-  constructor(readonly code: string, readonly reason: string) {
-    super(reason)
-  }
+/** What a thrown failure said: a refused answer's reason, else the error's message. */
+function reasonOf(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
 }

+ 3 - 53
packages/client/ui-plugin-manager/src/client/presentation.ts

@@ -1,8 +1,6 @@
-/** Display labels and failure messages for global plugin management. */
+/** Display labels and toast sentences for global plugin management. */
 
-import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import { zh, type PluginManagerLocaleKey } from './locales.ts'
 import type { ManagerNotice } from './manager-store.ts'
 
 /** The translate seat of the manager's dictionary. */
@@ -18,48 +16,6 @@ export function shortName(name: string): string {
   return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
 }
 
-/** The dictionary key for one slug, when the dictionary carries it. */
-function copyKey(slug: string): PluginManagerLocaleKey | undefined {
-  const key = `name.${slug}`
-  return key in zh ? key as PluginManagerLocaleKey : undefined
-}
-
-/**
- * The installed package a module belongs to: the package itself or one of its subpaths.
- * @param moduleName - the module specifier.
- * @param packages - the profile's packages.
- * @returns the owning package view, or undefined when no installed package owns it.
- */
-export function packageOf(moduleName: string, packages: readonly PluginPackageView[]): PluginPackageView | undefined {
-  return packages.find(pkg => moduleName === pkg.name || moduleName.startsWith(`${pkg.name}/`))
-}
-
-/**
- * What a person calls one tree entry: its harness name, else its row id.
- * @param t - the manager's translate seat.
- * @param entryId - the tree entry id.
- * @returns the label.
- */
-export function rowLabel(t: Translate, entryId: string): string {
-  const id = entryId.slice(entryId.lastIndexOf(':') + 1)
-  const key = copyKey(id)
-  return key === undefined ? id : t(key)
-}
-
-/**
- * The copy for a refusal every mutation can meet: the manager is busy, or a session is running.
- * @param failure - the Host's code and reason.
- * @param t - the manager's translate seat.
- * @returns the sentence.
- */
-export function refusalText(failure: { readonly code: string; readonly reason: string }, t: Translate): string {
-  switch (failure.code) {
-    case 'plugins/busy': return t('busy', { reason: failure.reason })
-    case 'plugins/agents-running': return t('agentsRunning', { reason: failure.reason })
-    default: return failure.code === 'plugins/install-failed' ? t('installFailed') : t('actionFailed', { reason: failure.reason })
-  }
-}
-
 /**
  * The sentence one notice shows.
  * @param notice - the last action's outcome.
@@ -69,14 +25,8 @@ export function refusalText(failure: { readonly code: string; readonly reason: s
 export function noticeText(notice: ManagerNotice, t: Translate): string {
   switch (notice.kind) {
     case 'restart': return t('restartNotice')
+    case 'overridden': return t('overriddenNotice', { name: notice.packageName })
     case 'cancelled': return t('installCancelled')
-    case 'failed': {
-      switch (notice.code) {
-        case 'plugins/not-enableable': return t('notEnableable', { reason: notice.reason })
-        case 'plugins/enable-failed': return t('enableFailed', { reason: notice.reason })
-        case 'plugins/not-installed': return t('notInstalled', { name: notice.packageName ?? '' })
-        default: return refusalText(notice, t)
-      }
-    }
+    case 'failed': return t('actionFailed', { reason: notice.reason })
   }
 }

+ 12 - 8
packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx

@@ -26,9 +26,13 @@ async function bench() {
     }
   }
   new LocaleHolder(ctx)
-  const list = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
+  const list = vi.fn(() => Promise.resolve({ ok: true as const, value: { entries: [], managementAvailable: true } }))
   const remote = new TestRemote(ctx, {
-    plugins: { list },
+    pluginInventory: { list },
+    pluginManager: {
+      listBundles: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })),
+      listPlugins: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })),
+    },
   })
   return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote }
 }
@@ -49,7 +53,7 @@ describe('ui-plugin-manager browser plugin', () => {
   })
 
   it('declares only the services the page and its Remote methods use', () => {
-    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.plugins'])
+    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory'])
   })
 
   it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => {
@@ -74,14 +78,14 @@ describe('ui-plugin-manager browser plugin', () => {
     expect(resolveSlotLabel(icon.options.label)).toBe('插件')
     const face = (entry.inject as unknown as () => PluginManagerFace)()
     // A Host change before the first render is not a reason to read.
-    b.remote.emit('plugins/changed', [{ reason: 'install' }])
+    b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
     b.ctx.emit('connection/reset')
     await Promise.resolve()
     expect(b.list).not.toHaveBeenCalled()
     face.ensure()
     await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') })
     expect(b.list).toHaveBeenCalledTimes(1)
-    b.remote.emit('plugins/changed', [{ reason: 'enable', packageName: 'x' }])
+    b.remote.emit('plugin-manager/changed', [{ reason: 'bundle' }])
     await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) })
     b.ctx.emit('connection/reset')
     await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(3) })
@@ -89,14 +93,14 @@ describe('ui-plugin-manager browser plugin', () => {
     // Install output folds into an open run only.
     face.openInstall()
     face.editInstallSpec('pkg')
-    b.remote.emit('plugins/install-log', [{ jobId: 'j', argv: ['pnpm', 'add', 'pkg'], cwd: '/p', spec: 'pkg', stream: 'stdout', text: 'early' }])
-    b.remote.emit('plugins/install-state', [{ requestId: 'foreign', phase: 'installing' }])
+    b.remote.emit('plugin-manager/install-log', [{ jobId: 'j', argv: ['pnpm', 'add', 'pkg'], cwd: '/p', stream: 'stdout', text: 'early' }])
+    b.remote.emit('plugin-manager/install-state', [{ requestId: 'foreign', phase: 'installing' }])
     expect(face.hooks.pluginManager.getSnapshot().install.runs).toEqual([])
 
     await fiber.dispose()
     expect(b.slots.entries('main')).toHaveLength(0)
     expect(b.slots.entries('sidebar.panellist')).toHaveLength(0)
-    b.remote.emit('plugins/changed', [{ reason: 'install' }])
+    b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
     await Promise.resolve()
     expect(b.list).toHaveBeenCalledTimes(3)
   })

+ 162 - 365
packages/client/ui-plugin-manager/tests/components.client.spec.tsx

@@ -1,12 +1,12 @@
 // @vitest-environment jsdom
 import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
+import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
 import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx'
-import { rowKey, type InstallState, type PluginManagerState } from '../src/client/manager-store.ts'
+import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts'
 import { en, type PluginManagerLocaleKey } from '../src/client/locales.ts'
 
 afterEach(cleanup)
@@ -17,25 +17,25 @@ const t = ((key: PluginManagerLocaleKey, params?: Record<string, string>): strin
     en[key],
   )) as PluginManagerPageProps['t']
 
-function pkg(overrides: Partial<PluginPackageView> = {}): PluginPackageView {
+function pkg(overrides: Partial<PackageView> = {}): PackageView {
   return {
     name: 'dsh-better-sidebar',
     version: '0.16.0',
-    kind: 'bundle',
     installed: true,
     enabled: true,
-    status: 'running',
-    cordisSameCopy: true,
     rows: [],
     overrides: [],
-    liveReload: true,
     ...overrides,
   }
 }
 
+function row(overrides: Partial<PackageRow> = {}): PackageRow {
+  return { entryId: 'include:sidebar' as PluginEntryId, rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active', ...overrides }
+}
+
 const IDLE_INSTALL: InstallState = {
   open: false, spec: '', phase: 'idle', inputError: null, subject: null, runs: [], detailsOpen: false,
-  installed: [], installedOnly: [], plain: [], removed: [], failure: null, enabling: false,
+  installed: null, restartRequired: false, failure: null, enabling: false,
 }
 
 const READY: PluginManagerState = {
@@ -62,12 +62,10 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
     enableInstalled: vi.fn(),
     clearHighlight: vi.fn(),
     setEnabled: vi.fn(),
-    retry: vi.fn(),
     uninstall: vi.fn(),
     confirm: vi.fn(),
     cancelConfirm: vi.fn(),
-    setRowDisabled: vi.fn(),
-    disableRow: vi.fn(),
+    setRowEnabled: vi.fn(),
     dismissNotice: vi.fn(),
   }
   const props = {
@@ -80,302 +78,143 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
 }
 
 describe('PluginManagerPage', () => {
-  it('keeps a non-dependency override failure visible even when owned rows are waiting', () => {
-    renderTab({ packages: [pkg({
-      name: 'overrides', title: 'Overrides', enabled: true, status: 'partial',
-      rows: [{ entryId: 'wait', rowId: 'wait', moduleName: 'overrides', enabled: true, phase: 'pending' }],
-      issues: [{ entryId: 'include:core', moduleName: 'core', stage: 'update', message: 'Rejected config' }],
-    })] })
-    expect(screen.getByText(en.statusProblem)).toBeTruthy()
-    expect(screen.queryByText(en.statusWaiting)).toBeNull()
-  })
-
-  it('shows an active old instance with its failed update and identifies affected external rows', () => {
-    const { set } = renderTab({ packages: [pkg({
-      name: 'updates', title: 'Updates', enabled: true, status: 'partial',
-      rows: [{ entryId: 'include:own', rowId: 'own', moduleName: 'updates', enabled: true, phase: 'active', failure: { stage: 'update', message: 'Invalid updated config' } }],
-      issues: [
-        { entryId: 'include:own', moduleName: 'updates', stage: 'update', message: 'Invalid updated config' },
-        { entryId: 'include:webserver', moduleName: 'webserver', stage: 'update', message: 'Invalid port' },
-      ],
-    })] })
-    fireEvent.click(screen.getByRole('button', { name: 'View Updates' }))
-    expect(screen.getByText(en.rowUpdateFailed)).toBeTruthy()
-    expect(screen.getByText('Invalid updated config')).toBeTruthy()
-    expect(screen.getByText('include:webserver')).toBeTruthy()
-    expect(screen.getByText('Invalid port')).toBeTruthy()
-    expect(screen.getByText(en.partsCountRunning.replace('{count}', '1'), { exact: false })).toBeTruthy()
-    expect(screen.getByText(en.partsCountUpdateFailed.replace('{count}', '1'), { exact: false })).toBeTruthy()
-    set({ packages: [pkg({ name: 'updates', title: 'Updates', kind: 'unknown', status: 'plain', rows: [] })] })
-    expect(screen.getByText(en.unknownPackage)).toBeTruthy()
-    expect(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', 'Updates') })).toBeTruthy()
-  })
-
   it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => {
     const { actions, set } = renderTab({ status: 'loading' })
     expect(actions.ensure).toHaveBeenCalledTimes(1)
     expect(screen.getByText(en.loading)).toBeTruthy()
     expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true)
-    expect(screen.getByRole('button', { name: en.refresh })).toHaveProperty('disabled', true)
-
     set({ status: 'unavailable' })
     expect(screen.getByRole('status').textContent).toBe(en.unavailable)
-
     set({ status: 'error' })
     expect(screen.getByRole('alert').textContent).toBe(en.error)
     fireEvent.click(screen.getByRole('button', { name: en.retry }))
     expect(actions.refresh).toHaveBeenCalledTimes(1)
-    expect(screen.getByText(en.empty)).toBeTruthy()
-
-    set({ status: 'ready' })
     fireEvent.click(screen.getByRole('button', { name: en.refresh }))
-    fireEvent.click(screen.getByRole('button', { name: en.addPlugin }))
     expect(actions.refresh).toHaveBeenCalledTimes(2)
+    set({ status: 'ready' })
+    expect(screen.getByText(en.empty)).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: en.addPlugin }))
     expect(actions.openInstall).toHaveBeenCalledTimes(1)
   })
 
-  it('shows a problem for a plugin with an invalid declaration', () => {
-    renderTab({ packages: [pkg({ kind: 'unknown', status: 'plain', reason: 'invalid declaration' })] })
-    fireEvent.click(screen.getByRole('button', { name: en.pluginsTitle }))
-    expect(screen.getByText(en.statusProblem)).toBeTruthy()
-  })
-
-  it('lists the installed packages as cards, leaves built-in bundles to Settings, tags only restarts and problems, and names what waits for a restart', () => {
-    // A library with no version, as pnpm lists a linked directory.
-    const { version: _unversioned, ...someLib } = pkg({ name: 'some-lib', kind: 'unknown', status: 'plain' })
+  it('lists the installed bundles as cards, leaves the installation\'s own to Settings, and tags a problem the Host reports', () => {
     const { actions } = renderTab({
       packages: [
-        pkg({ description: 'A sidebar.' }),
-        // The profile template's own bundles and the libraries they carry are not installed by the person: not listed.
-        pkg({ name: '@deepseek-ai/dsh-bundle-first-party', title: 'First party', installed: false }),
-        pkg({ name: 'unknown', installed: false, kind: 'unknown', status: 'plain' }),
-        pkg({ name: 'dsh-untitled', enabled: false, status: 'restart-required', installed: false }),
-        pkg({ name: 'broken-bundle', enabled: false, status: 'not-enableable', reason: 'foreign cordis' }),
-        pkg({ name: 'dsh-tool-foo', kind: 'unknown', status: 'plain' }),
-        someLib,
-        pkg({ name: 'pending-bundle', title: 'Pending', enabled: true, status: 'restart-required' }),
-        pkg({ name: 'dsh-nameless', enabled: false, status: 'restart-required' }),
-        pkg({ name: 'off-bundle', enabled: false, status: 'disabled' }),
+        pkg({ title: 'Better sidebar', description: 'A sidebar.' }),
+        pkg({ name: 'dsh-broken', enabled: false, error: 'Not a bundle: dsh-broken' }),
+        pkg({ name: '@deepseek-ai/dsh-web-app', installed: false }),
+        pkg({ name: 'dsh-protected', readOnlyReason: 'This bundle provides plugin management components' }),
+        pkg({ name: '@acme/dsh-tool', enabled: false }),
       ],
+      busy: ['dsh-protected'],
     })
-    // The banner names a pack by its title, or by its short name when it has none.
-    expect(screen.getByText(en.restartBanner.replace('{names}', 'Pending, nameless'))).toBeTruthy()
-    // Packs and plugins list in their own groups; a library counts among the plugins.
-    expect(document.querySelector('[data-plugin-group="bundles"] [data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('5')
-    expect(document.querySelector('[data-plugin-group="plugins"] [data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('2')
-    expect(screen.getByRole('heading', { name: en.bundlesTitle })).toBeTruthy()
-    // The dependencies that are not packs stay folded until opened, their count and intro showing meanwhile.
-    const plugins = screen.getByRole('button', { name: en.pluginsTitle })
-    expect(plugins.getAttribute('aria-expanded')).toBe('false')
-    expect(screen.getByText(en.pluginsIntro)).toBeTruthy()
-    expect(document.querySelector('[data-plugin-package="some-lib"]')).toBeNull()
-    fireEvent.click(plugins)
-    expect(plugins.getAttribute('aria-expanded')).toBe('true')
-    expect(document.querySelector('[data-plugin-package="some-lib"]')).not.toBeNull()
-    for (const name of ['@deepseek-ai/dsh-bundle-first-party', 'unknown', 'dsh-untitled']) {
-      expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull()
-    }
-    // No kind tag, no running or off tag: the switch says that.
-    expect(screen.getByText(en.statusProblem)).toBeTruthy()
-    expect(screen.getAllByText(en.statusRestart)).toHaveLength(2)
+    const cards = screen.getAllByRole('listitem')
+    expect(cards.map(card => card.getAttribute('data-plugin-package'))).toEqual(['dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool'])
+    expect(cards.map(card => card.getAttribute('data-plugin-status'))).toEqual(['running', 'problem', 'running', 'disabled'])
+    expect(screen.getByText(`4 ${en.countUnit}`)).toBeTruthy()
+    // A scoped name reads without its scope and harness prefix.
+    expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'tool') })).toHaveProperty('disabled', false)
     expect(screen.getByText('A sidebar.')).toBeTruthy()
-    expect(document.querySelectorAll('[data-tone]')).toHaveLength(3)
-
-    const sidebar = screen.getByRole('switch', { name: 'Enable better-sidebar' }) as HTMLButtonElement
-    expect(sidebar.getAttribute('aria-checked')).toBe('true')
-    fireEvent.click(sidebar)
+    expect(screen.getAllByText(en.statusProblem)).toHaveLength(1)
+    // The switch acts on the bundle; a bundle the Host cannot read stays off, a protected one stays as it is.
+    fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'Better sidebar') }))
     expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
-    expect(screen.getByRole('switch', { name: 'Enable broken-bundle' })).toHaveProperty('disabled', true)
-    expect(screen.getByRole('switch', { name: 'Enable off-bundle' }).getAttribute('aria-checked')).toBe('false')
-    // A plugin or library has no switch.
-    expect(screen.queryByRole('switch', { name: 'Enable tool-foo' })).toBeNull()
-
-    // Uninstall lives on the package's page; a package without a version shows no version fact.
-    fireEvent.click(screen.getByRole('button', { name: 'View better-sidebar' }))
-    fireEvent.click(screen.getByRole('button', { name: 'Uninstall better-sidebar' }))
-    expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar')
-    expect(screen.getByText(en.versionLabel)).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    fireEvent.click(screen.getByRole('button', { name: 'View some-lib' }))
-    expect(screen.queryByText(en.versionLabel)).toBeNull()
+    expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'broken') })).toHaveProperty('disabled', true)
+    const locked = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'protected') })
+    expect(locked).toHaveProperty('disabled', true)
+    expect(locked.getAttribute('title')).toBe('This bundle provides plugin management components')
   })
 
-  it('opens a plugin pack\'s page with its facts, rows, and overrides, and retries a failing one', () => {
+  it('opens a bundle\'s page with its facts, rows, and overrides, and uninstalls from it', () => {
     const { actions, set } = renderTab({
-      packages: [
-        pkg({
-          status: 'partial',
-          reason: 'one row failed',
-          overrides: ['directory-picker'],
-          rows: [
-            { entryId: 'include:better-sidebar', rowId: 'better-sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active' },
-            { entryId: 'include:off', rowId: 'off', moduleName: 'dsh-better-sidebar/off', enabled: false, disabledBy: 'user', phase: null },
-            { entryId: 'include:gated', rowId: 'gated', moduleName: 'dsh-better-sidebar/gated', enabled: false, disabledBy: 'composition', phase: null },
-            { entryId: 'include:crash', rowId: 'crash', moduleName: 'dsh-better-sidebar/crash', enabled: true, phase: 'failed', failure: { stage: 'apply', message: 'boom' } },
-            { entryId: 'include:flaky', rowId: 'flaky', moduleName: 'dsh-better-sidebar/flaky', enabled: true, phase: 'failed' },
-            { entryId: 'include:idle', rowId: 'idle', moduleName: 'dsh-better-sidebar/idle', enabled: true, phase: null },
-            { entryId: 'include:wait', rowId: 'wait', moduleName: 'dsh-better-sidebar/wait', enabled: true, phase: 'pending' },
-            { entryId: 'include:recorded', rowId: 'recorded', moduleName: 'dsh-better-sidebar/recorded', enabled: true, phase: null, failure: { stage: 'inject-pending', message: 'pending (waiting for service: authorization)' } },
-            { entryId: 'include:healed', rowId: 'healed', moduleName: 'dsh-better-sidebar/healed', enabled: true, phase: 'active', failure: { stage: 'inject-pending', message: 'stale' } },
-          ],
-        }),
-        pkg({
-          name: 'waiting-pack', status: 'partial', reason: 'pending (waiting for service: authorization)',
-          rows: [{ entryId: 'include:w1', rowId: 'w1', moduleName: 'waiting-pack', enabled: true, phase: null, failure: { stage: 'inject-pending', message: 'pending (waiting for service: authorization)' } }],
-        }),
-        pkg({ name: 'no-rows', enabled: false, status: 'disabled' }),
-        pkg({ name: 'dsh-tool-foo', kind: 'unknown', status: 'plain' }),
-        pkg({
-          name: '@deepseek-ai/dsh-core-broken', title: 'Core', installed: false, status: 'failed',
-          rows: [{ entryId: 'include:core', rowId: 'core', moduleName: '@deepseek-ai/dsh-core-broken', enabled: true, phase: 'active' }],
-        }),
-      ],
+      packages: [pkg({
+        title: 'Better sidebar', description: 'A sidebar.',
+        rows: [row(), row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId, enabled: false, phase: null })],
+        overrides: ['layout'],
+      })],
     })
-    // A failing built-in pack is Settings' business, not this page's: no card, no problem tag for it.
-    expect(screen.queryByRole('button', { name: 'View Core' })).toBeNull()
-    // A pack whose only trouble is a row waiting for a service is tagged as waiting, not as a problem.
-    expect(screen.getByText(en.statusWaiting)).toBeTruthy()
-    // The list and its toolbar give way to the page; the crumb leads back.
-    fireEvent.click(screen.getByRole('button', { name: 'View better-sidebar' }))
-    expect(screen.queryByRole('button', { name: en.addPlugin })).toBeNull()
-    expect(screen.getByRole('heading', { name: 'better-sidebar' })).toBeTruthy()
-    expect(screen.getByText(`${en.reasonLabel}: one row failed`)).toBeTruthy()
-    expect(screen.getByText('0.16.0')).toBeTruthy()
-    // Every row in the pack's order: its id over the module it loads, then its state; a failure adds its message.
-    expect(screen.getByText('9 total · 2 running · 2 waiting · 2 off · 2 failed')).toBeTruthy()
-    const rowText = (id: string): string | undefined => document.querySelector(`[data-plugin-row="include:${id}"]`)?.textContent
-    expect(rowText('better-sidebar')).toBe(`better-sidebardsh-better-sidebar${en.rowPhaseActive}`)
-    expect(rowText('off')).toBe(`offdsh-better-sidebar/off${en.partDisabledByUser}`)
-    expect(rowText('gated')).toBe(`gateddsh-better-sidebar/gated${en.partDisabledByComposition}`)
-    expect(rowText('crash')).toBe(`crashdsh-better-sidebar/crash${en.rowStateFailed}boom`)
-    expect(rowText('flaky')).toBe(`flakydsh-better-sidebar/flaky${en.rowStateFailed}`)
-    expect(rowText('idle')).toBe(`idledsh-better-sidebar/idle${en.rowStateIdle}`)
-    // A wait for a service reads the same whether the fiber is pending or a composition recorded the wait.
-    expect(rowText('wait')).toBe(`waitdsh-better-sidebar/wait${en.rowPhasePending}`)
-    expect(rowText('recorded')).toBe(`recordeddsh-better-sidebar/recorded${en.rowPhasePending}pending (waiting for service: authorization)`)
-    expect(document.querySelector('[data-plugin-row="include:wait"]')?.getAttribute('data-state')).toBe('waiting')
-    expect(document.querySelector('[data-plugin-row="include:recorded"]')?.getAttribute('data-state')).toBe('waiting')
-    // A row that came to life is running, whatever record the registry still holds for it.
-    expect(rowText('healed')).toBe(`healeddsh-better-sidebar/healed${en.rowPhaseActive}`)
-    expect(document.querySelector('[data-plugin-row="include:healed"]')?.getAttribute('data-state')).toBeNull()
-    expect(document.querySelector('[data-plugin-row="include:off"]')?.getAttribute('data-state')).toBe('off')
-    expect(document.querySelector('[data-plugin-row="include:crash"]')?.getAttribute('data-state')).toBe('failed')
-    // A short list has no filter; the built-in rows the pack changes are named.
-    expect(screen.queryByRole('searchbox', { name: en.partsFilter })).toBeNull()
-    expect(document.querySelector('[data-plugin-overrides]')?.textContent).toContain('directory-picker')
-    expect(document.querySelector('[data-plugin-modules]')).toBeNull()
-
-    fireEvent.click(screen.getByRole('button', { name: en.retryPackage }))
-    expect(actions.retry).toHaveBeenCalledWith('dsh-better-sidebar')
-    fireEvent.click(screen.getByRole('switch', { name: 'Enable better-sidebar' }))
+    fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Better sidebar') }))
+    const detail = document.querySelector('[data-plugin-detail="dsh-better-sidebar"]') as HTMLElement
+    expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('Better sidebar')
+    expect(within(detail).getByText('0.16.0')).toBeTruthy()
+    expect(within(detail).getByText('A sidebar.')).toBeTruthy()
+    // The rows, in order, with their state and their module; the built-in rows the patch changes.
+    const rows = within(detail).getAllByRole('listitem').filter(item => item.hasAttribute('data-plugin-row'))
+    expect(rows.map(item => item.getAttribute('data-plugin-row'))).toEqual(['include:sidebar', 'include:theme'])
+    expect(rows[1]?.getAttribute('data-state')).toBe('off')
+    expect(within(detail).getByText(en.partsCountTotal.replace('{count}', '2'), { exact: false })).toBeTruthy()
+    expect(within(detail).getByText('dsh-better-sidebar/theme')).toBeTruthy()
+    expect(within(detail).getByText(en.rowPhaseActive)).toBeTruthy()
+    expect(within(detail).getByText(en.partOff)).toBeTruthy()
+    expect(within(detail).getByText(en.overridesLabel)).toBeTruthy()
+    expect(within(detail).getByText('layout')).toBeTruthy()
+    fireEvent.click(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'Better sidebar') }))
+    expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar')
+    fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'Better sidebar') }))
     expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
-    // A busy package keeps its controls inert.
-    set({ busy: ['dsh-better-sidebar'] })
-    expect(screen.getByRole('button', { name: en.retryPackage })).toHaveProperty('disabled', true)
-    expect(screen.getByRole('button', { name: 'Uninstall better-sidebar' })).toHaveProperty('disabled', true)
-    expect(screen.getByRole('switch', { name: 'Enable better-sidebar' })).toHaveProperty('disabled', true)
-    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    expect(screen.queryByText(en.partsLabel)).toBeNull()
-    expect(screen.getByRole('button', { name: en.addPlugin })).toBeTruthy()
-
-    // The waiting pack's page keeps the wait on its row and repeats no reason above the facts.
-    fireEvent.click(screen.getByRole('button', { name: 'View waiting-pack' }))
-    expect(screen.queryByText(`${en.reasonLabel}: pending (waiting for service: authorization)`)).toBeNull()
-    expect(screen.getByText('1 total · 1 waiting')).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    // A pack without rows says so; a plugin's page has no rows section.
-    fireEvent.click(screen.getByRole('button', { name: 'View no-rows' }))
-    expect(screen.getByText(en.partsEmpty)).toBeTruthy()
+    // A problem and a protection the Host reports read on the page; the page leaves with the crumb.
+    set({ packages: [pkg({ title: 'Better sidebar', error: 'unreadable', readOnlyReason: 'protected' })] })
+    expect(within(detail).getByText(`${en.reasonLabel}: unreadable`)).toBeTruthy()
+    expect(within(detail).getByText('protected')).toBeTruthy()
+    expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'Better sidebar') })).toHaveProperty('disabled', true)
+    expect(within(detail).getByText(en.partsEmpty)).toBeTruthy()
+    fireEvent.click(within(detail).getByRole('button', { name: en.backToList }))
+    expect(document.querySelector('[data-plugin-detail]')).toBeNull()
+    // A bundle without a description or a version says so; one that leaves the list drops back to the cards.
+    const { version: _version, ...unversioned } = pkg()
+    set({ packages: [unversioned] })
+    fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
     expect(screen.getByText(en.noDescription)).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    fireEvent.click(screen.getByRole('button', { name: en.pluginsTitle }))
-    fireEvent.click(screen.getByRole('button', { name: 'View tool-foo' }))
-    expect(screen.queryByText(en.partsLabel)).toBeNull()
-    expect(screen.queryByRole('button', { name: en.retryPackage })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    // A package that leaves the list while its page is open drops back to the cards.
+    expect(screen.queryByText(en.versionLabel)).toBeNull()
     set({ packages: [] })
+    expect(document.querySelector('[data-plugin-detail]')).toBeNull()
     expect(screen.getByText(en.empty)).toBeTruthy()
   })
 
-  it('switches the rows of a live external pack, filters a long list, and locks what the pack itself keeps off', () => {
-    const userOff = { enabled: false, disabledBy: 'user', phase: null } as const
-    const many = Array.from({ length: 11 }, (_, index) => ({
-      entryId: `include:row-${String(index)}`, rowId: `row-${String(index)}`, moduleName: 'many', enabled: true, phase: 'active' as const,
-    }))
-    const { actions, set } = renderTab({
-      packages: [
-        pkg({
-          status: 'partial',
-          rows: [
-            { entryId: 'include:better-sidebar', rowId: 'better-sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active' },
-            { entryId: 'include:off', rowId: 'off', moduleName: 'dsh-better-sidebar/off', ...userOff },
-            { entryId: 'include:gated', rowId: 'gated', moduleName: 'dsh-better-sidebar/gated', enabled: false, disabledBy: 'composition', phase: null },
-            { entryId: 'include:crash', rowId: 'crash', moduleName: 'dsh-better-sidebar/crash', enabled: true, phase: 'failed', failure: { stage: 'apply', message: 'boom' } },
-            { entryId: 'conflict:x:taken', rowId: 'taken', moduleName: 'dsh-better-sidebar/taken', enabled: true, phase: null, failure: { stage: 'conflict', message: 'x owns taken' } },
-          ],
-        }),
-        pkg({ name: 'many', rows: many }),
-        pkg({ name: 'frozen', liveReload: false, rows: [{ entryId: 'include:frozen', rowId: 'frozen', moduleName: 'frozen', ...userOff }] }),
-        pkg({ name: 'parked', enabled: false, status: 'disabled', rows: [{ entryId: 'include:parked', rowId: 'parked', moduleName: 'parked', ...userOff }] }),
-        pkg({ name: '@deepseek-ai/dsh-core', title: 'Core', installed: false, rows: [{ entryId: 'include:core', rowId: 'core', moduleName: '@deepseek-ai/dsh-core', ...userOff }] }),
-      ],
+  it('switches the rows of a bundle that is on, filters a long list, and locks what the Host will not address', () => {
+    const rows = Array.from({ length: 12 }, (_row, index): PackageRow => {
+      const live = row({
+        rowId: `row-${String(index)}`, entryId: `include:row-${String(index)}` as PluginEntryId,
+        ...index === 1 ? { readOnlyReason: 'Required for plugin management.' } : {},
+        ...index === 3 ? { phase: 'failed' as const } : {},
+        ...index === 4 ? { phase: 'loading' as const } : {},
+      })
+      if (index !== 2) return live
+      // The third row has no live entry: nothing to switch.
+      const { entryId: _entryId, ...unmounted } = live
+      return { ...unmounted, enabled: false, phase: null }
     })
-    fireEvent.click(screen.getByRole('button', { name: 'View better-sidebar' }))
-    // A row the person switched off switches back on at once; one the pack itself keeps off is locked and says why.
-    const off = screen.getByRole('switch', { name: 'Enable component off' })
-    expect(off.getAttribute('aria-checked')).toBe('false')
-    fireEvent.click(off)
-    expect(actions.setRowDisabled).toHaveBeenLastCalledWith('off', false)
-    expect(actions.disableRow).not.toHaveBeenCalled()
-    const gated = screen.getByRole('switch', { name: 'Enable component gated' })
-    expect(gated).toHaveProperty('disabled', true)
-    expect(gated.getAttribute('title')).toBe(en.partLockedByComposition)
-    // Switching a row off goes through the ask, which names the package and the row's tree-wide id;
-    // a failing row can be switched off; a row another layer owns has nothing mounted to switch.
-    fireEvent.click(screen.getByRole('switch', { name: 'Enable component crash' }))
-    expect(actions.disableRow).toHaveBeenLastCalledWith('dsh-better-sidebar', 'include:crash', 'crash')
-    expect(screen.queryByRole('switch', { name: 'Enable component taken' })).toBeNull()
-    const sidebar = screen.getByRole('switch', { name: 'Enable component better-sidebar' })
-    expect(sidebar.getAttribute('aria-checked')).toBe('true')
-    fireEvent.click(sidebar)
-    expect(actions.disableRow).toHaveBeenLastCalledWith('dsh-better-sidebar', 'include:better-sidebar', 'better-sidebar')
-    expect(actions.setRowDisabled).toHaveBeenCalledTimes(1)
-    // Only the row with a write in flight goes inert; a busy package takes every row with it.
-    set({ busy: [rowKey('better-sidebar')] })
-    expect(screen.getByRole('switch', { name: 'Enable component better-sidebar' })).toHaveProperty('disabled', true)
-    expect(screen.getByRole('switch', { name: 'Enable component off' })).toHaveProperty('disabled', false)
-    set({ busy: ['dsh-better-sidebar'] })
-    expect(screen.getByRole('switch', { name: 'Enable component off' })).toHaveProperty('disabled', true)
-    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    // A long list gets a filter over the row ids.
-    fireEvent.click(screen.getByRole('button', { name: 'View many' }))
-    const filter = screen.getByRole('searchbox', { name: en.partsFilter })
-    expect(document.querySelectorAll('[data-plugin-row]')).toHaveLength(11)
+    const { actions, set } = renderTab({ packages: [pkg({ rows })], busy: [rowKey('include:row-5')] })
+    fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
+    const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
+    expect(within(detail).getByText(`${en.partsCountTotal.replace('{count}', '12')} · ${en.partsCountRunning.replace('{count}', '9')} · ${en.partsCountOff.replace('{count}', '1')} · ${en.partsCountFailed.replace('{count}', '1')}`)).toBeTruthy()
+    fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') }))
+    expect(actions.setRowEnabled).toHaveBeenCalledWith('include:row-0', false)
+    // A protected row, a row without a live entry, and a row with a write in flight cannot be switched.
+    const locked = within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-1') })
+    expect(locked).toHaveProperty('disabled', true)
+    expect(locked.getAttribute('title')).toBe('Required for plugin management.')
+    expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') })).toHaveProperty('disabled', true)
+    fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') }))
+    expect(actions.setRowEnabled).toHaveBeenCalledTimes(1)
+    expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-5') })).toHaveProperty('disabled', true)
+    expect(within(detail).getByText(en.rowPhaseFailed)).toBeTruthy()
+    expect(within(detail).getByText(en.rowPhaseLoading)).toBeTruthy()
+    expect(document.querySelector('[data-plugin-row="include:row-3"]')?.getAttribute('data-state')).toBe('failed')
+    // A long list gets a filter; nothing matching says so.
+    const filter = within(detail).getByRole('searchbox', { name: en.partsFilter })
     fireEvent.change(filter, { target: { value: 'ROW-1' } })
-    expect(document.querySelectorAll('[data-plugin-row]')).toHaveLength(2)
-    fireEvent.change(filter, { target: { value: 'nope' } })
-    expect(screen.getByText(en.partsFilterEmpty)).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    // Rows stay read-only where a switch would not act now: a profile that
-    // applies patches at its next start and a pack that is off. The built-in
-    // pack has no page here at all.
-    expect(screen.queryByRole('button', { name: 'View Core' })).toBeNull()
-    for (const [name, rowId] of [['frozen', 'frozen'], ['parked', 'parked']] as const) {
-      fireEvent.click(screen.getByRole('button', { name: `View ${name}` }))
-      expect(document.querySelector(`[data-plugin-row="include:${rowId}"]`)?.textContent).toContain(en.partDisabledByUser)
-      expect(screen.queryByRole('switch', { name: `Enable component ${rowId}` })).toBeNull()
-      fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    }
-  })
-
-  it('shows a scoped non-bundle package with package details and uninstall', () => {
-    renderTab({ packages: [pkg({ name: '@acme/example', kind: 'unknown', status: 'plain' })] })
-    fireEvent.click(screen.getByRole('button', { name: en.pluginsTitle }))
-    fireEvent.click(screen.getByRole('button', { name: 'View example' }))
-    expect(screen.getByText(en.unknownPackage)).toBeTruthy()
-    expect(screen.getByRole('button', { name: 'Uninstall example' })).toBeTruthy()
-    expect(screen.queryByRole('switch')).toBeNull()
+    expect(within(detail).getAllByRole('listitem').filter(item => item.hasAttribute('data-plugin-row'))).toHaveLength(3)
+    fireEvent.change(filter, { target: { value: 'nothing' } })
+    expect(within(detail).getByText(en.partsFilterEmpty)).toBeTruthy()
+    fireEvent.change(filter, { target: { value: '' } })
+    // A bundle that is off shows its rows without switches.
+    set({ packages: [pkg({ enabled: false, rows: rows.slice(0, 2).map(item => ({ ...item, enabled: false, phase: null })) })] })
+    expect(within(detail).queryByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') })).toBeNull()
+    expect(within(detail).getAllByText(en.partOff)).toHaveLength(2)
+    // A row without a fiber, on a bundle that is on, reads idle.
+    set({ packages: [pkg({ rows: [row({ phase: null })] })] })
+    expect(within(detail).getByText(en.rowStateIdle)).toBeTruthy()
   })
 
   it('takes a spec, checks it, and words what the check refused', () => {
@@ -407,6 +246,7 @@ describe('PluginManagerPage', () => {
       ['already-installed', en.installProblemInstalled],
       ['not-found', en.installProblemNotFound],
       ['not-a-package', en.installProblemNotPackage],
+      ['not-a-bundle', en.installProblemNotBundle.replace('{reason}', 'r')],
       ['network', en.installProblemNetwork],
       ['unknown', en.installProblemUnknown.replace('{reason}', 'r')],
     ]
@@ -420,8 +260,8 @@ describe('PluginManagerPage', () => {
   })
 
   it('shows the subject while installing, folds the pnpm output behind the details, and stops through the Host', () => {
-    const subject = { spec: 'dsh-x', kind: 'registry', name: 'dsh-x', title: 'Sidebar', version: '1.4.2', description: 'A sidebar.', bundle: true } as const
-    const run = { jobId: 'j1', command: 'pnpm add dsh-x', cwd: '/home/u/.dsh/profiles/web', output: 'Progress: resolved \u001b[96m1\u001b[39m\n' }
+    const subject = { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', title: 'Sidebar', version: '1.4.2', description: 'A sidebar.', bundle: true } as const
+    const run = { jobId: 'j1', command: 'pnpm add dsh-x', cwd: '/home/u/.dsh/profiles/web', output: 'Progress: resolved \x1b[96m1\x1b[39m\n' }
     const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, runs: [run] } })
     expect(screen.getByRole('status').textContent).toBe(en.installingTitle)
     expect(screen.getByText('Sidebar')).toBeTruthy()
@@ -444,7 +284,7 @@ describe('PluginManagerPage', () => {
     expect(within(terminal).getByText('1').getAttribute('style')).toContain('--dsw-static-blue-500')
     expect(within(terminal).getByText(en.terminalRunning)).toBeTruthy()
     // A long log folds its middle behind an expand control, so the dialog keeps its height while pnpm talks.
-    const lines = Array.from({ length: 15 }, (_line, index) => `line ${index + 1}`).join('\n')
+    const lines = Array.from({ length: 15 }, (_line, index) => `line ${String(index + 1)}`).join('\n')
     set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, detailsOpen: true, runs: [{ ...run, output: `${lines}\n` }] } })
     expect(screen.queryByText('line 8')).toBeNull()
     fireEvent.click(screen.getByRole('button', { name: en.terminalExpandAria.replace('{n}', '3') }))
@@ -461,7 +301,7 @@ describe('PluginManagerPage', () => {
   })
 
   it('waits with the Host through starting, stopping, and applying, and words an unconfirmed stop', () => {
-    const subject = { spec: 'slow', kind: 'registry', name: 'slow', bundle: true } as const
+    const subject = { spec: 'slow', status: 'accepted', kind: 'registry', name: 'slow', bundle: true } as const
     const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'starting', subject } })
     // Before the Host acknowledges the run there is nothing to stop: cancel, back, and close all wait.
     expect(screen.getByRole('status').textContent).toBe(en.installStarting)
@@ -486,13 +326,13 @@ describe('PluginManagerPage', () => {
     expect(screen.getByRole('status').textContent).toBe(en.installApplying)
     expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true)
     // A stop the Host could not confirm says so over the running screen.
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject, failure: { code: 'client/cancel-unconfirmed', reason: 'offline' } } })
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject, failure: { reason: 'offline', cancelUnconfirmed: true } } })
     expect(screen.getByRole('alert').textContent).toContain('offline')
     expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', false)
   })
 
-  it('offers to enable what a finished install added, and words what it did not add', () => {
-    const subject = { spec: '/plugins/dsh-x', kind: 'path', name: 'dsh-x', bundle: true } as const
+  it('offers to enable what a finished install added, and says when it waits for a restart', () => {
+    const subject = { spec: '/plugins/dsh-x', status: 'accepted', kind: 'path', name: 'dsh-x', bundle: true } as const
     const { actions, set } = renderTab({
       install: {
         ...IDLE_INSTALL,
@@ -501,39 +341,32 @@ describe('PluginManagerPage', () => {
         phase: 'done',
         subject,
         runs: [{ jobId: 'j1', command: 'pnpm add /plugins/dsh-x', cwd: '/p', output: 'Done in 1s\n', exitCode: 0 }],
-        installed: ['dsh-x', 'dsh-lib'],
-        installedOnly: ['dsh-x'],
-        plain: ['dsh-lib'],
-        removed: [
-          { name: 'invalid-bundle', reason: 'invalid stage' },
-          { name: 'clash', reason: 'row "x" is already declared by y' },
-        ],
+        installed: 'dsh-x',
       },
     })
     expect(screen.getByText(en.installedTitle)).toBeTruthy()
     // A path without a description reads by its kind.
     expect(screen.getByText('dsh-x')).toBeTruthy()
     expect(screen.getByText(en.installSubjectPath)).toBeTruthy()
-    expect(screen.getByText(en.installDoneNotBundle.replace('{name}', 'dsh-lib'))).toBeTruthy()
-    expect(screen.getByText(en.installRemovedInvalid.replace('{name}', 'invalid-bundle').replace('{reason}', 'invalid stage'))).toBeTruthy()
-    expect(screen.getByText(en.installRemovedConflict.replace('{name}', 'clash').replace('{reason}', 'row "x" is already declared by y'))).toBeTruthy()
+    expect(screen.queryByText(en.installDoneRestart)).toBeNull()
     // No way back to the spec from here; enabling is the one action.
     expect(screen.queryByRole('button', { name: en.installEditAria })).toBeNull()
     fireEvent.click(screen.getByRole('button', { name: en.installEnableNow }))
     expect(actions.enableInstalled).toHaveBeenCalledTimes(1)
-    set({ install: { ...IDLE_INSTALL, open: true, spec: '/plugins/dsh-x', phase: 'done', subject, installed: ['dsh-x'], installedOnly: ['dsh-x'], enabling: true } })
+    set({ install: { ...IDLE_INSTALL, open: true, spec: '/plugins/dsh-x', phase: 'done', subject, installed: 'dsh-x', restartRequired: true, enabling: true } })
     expect(screen.getByRole('button', { name: en.installEnableNow })).toHaveProperty('disabled', true)
+    expect(screen.getByText(en.installDoneRestart)).toBeTruthy()
 
-    // Nothing new, or nothing that switches on, leaves only Done.
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject: { spec: 'dsh-x', kind: 'registry', name: 'dsh-x', bundle: false } } })
+    // A run that named no bundle leaves only Done.
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject: { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true } } })
     expect(screen.getByText(en.installDoneNothing)).toBeTruthy()
     expect(screen.queryByRole('button', { name: en.installEnableNow })).toBeNull()
     fireEvent.click(screen.getByRole('button', { name: en.installClose }))
     expect(actions.closeInstall).toHaveBeenCalledTimes(1)
   })
 
-  it('words a failed install by its kind and retries it', () => {
-    const subject = { spec: 'github:a/b', kind: 'git', bundle: null } as const
+  it('words a failed install by its kind, else in the Host\'s words, and retries it', () => {
+    const subject = { spec: 'github:a/b', status: 'accepted', kind: 'git', bundle: null } as const
     const { actions, set } = renderTab({
       install: {
         ...IDLE_INSTALL,
@@ -543,7 +376,7 @@ describe('PluginManagerPage', () => {
         subject,
         detailsOpen: true,
         runs: [{ jobId: 'j1', command: 'pnpm add github:a/b', cwd: '/p', output: 'ERR\n', exitCode: 1 }],
-        failure: { code: 'plugins/install-failed', reason: 'ERR\n', kind: 'network' },
+        failure: { reason: 'ERR\n', kind: 'network' },
       },
     })
     expect(screen.getByRole('alert').textContent).toBe(en.installFailedTitle)
@@ -564,16 +397,19 @@ describe('PluginManagerPage', () => {
       ['build-blocked', en.installFailureBuildBlocked], ['integrity', en.installFailureIntegrity], ['unknown', en.installFailureGeneric],
     ]
     for (const [kind, sentence] of kinds) {
-      set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { code: 'plugins/install-failed', reason: 'r', kind: kind as never } } })
+      set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'r', kind: kind as never } } })
       expect(screen.getByText(sentence)).toBeTruthy()
     }
-    // A pnpm failure without a kind, a refusal, and no failure at all.
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { code: 'plugins/install-failed', reason: 'r' } } })
+    // A failure without a kind reads in the Host's words; without words, or without a failure at all, generically.
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'x declares no dsh.bundle.patch' } } })
+    expect(screen.getByText('x declares no dsh.bundle.patch')).toBeTruthy()
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: '' } } })
     expect(screen.getByText(en.installFailureGeneric)).toBeTruthy()
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { code: 'plugins/enable-failed', reason: 'r' } } })
-    expect(screen.getByText(en.actionFailed.replace('{reason}', 'r'))).toBeTruthy()
     set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: null } })
     expect(screen.getByText(en.installFailureGeneric)).toBeTruthy()
+    // A tarball spec reads by its kind too.
+    set({ install: { ...IDLE_INSTALL, open: true, spec: '/p/x.tgz', phase: 'failed', subject: { spec: '/p/x.tgz', status: 'accepted', kind: 'tarball', bundle: null }, failure: null } })
+    expect(screen.getByText(en.installSubjectTarball)).toBeTruthy()
   })
 
   it('scrolls to and marks the package an install enabled, then lets the mark go', () => {
@@ -602,60 +438,33 @@ describe('PluginManagerPage', () => {
     }
   })
 
-  it('asks before switching a row off, naming the rows that inject what it provides', () => {
-    const { actions } = renderTab({
-      packages: [pkg()],
-      confirm: {
-        action: 'disableRow', packageName: 'dsh-better-sidebar', rowId: 'seam',
-        dependents: { services: [{ service: 'authorization', providedBy: 'include:seam', injectedBy: ['include:oauth'] }], references: [] },
-      },
-    })
-    expect(screen.getByRole('dialog', { name: en.confirmDisableRowTitle.replace('{name}', 'seam') })).toBeTruthy()
-    expect(screen.getByText(en.confirmDisableRowDescription)).toBeTruthy()
-    expect(screen.getByText(en.confirmDependents)).toBeTruthy()
-    expect(within(screen.getByRole('dialog')).getByRole('listitem').textContent).toContain('oauth')
-    fireEvent.click(screen.getByRole('button', { name: en.confirmDisableRow }))
-    expect(actions.confirm).toHaveBeenCalledTimes(1)
+  it('marks a card without a scrollIntoView to call', () => {
+    vi.useFakeTimers()
+    const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView')
+    delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView
+    try {
+      const { set } = renderTab({ packages: [pkg()] })
+      set({ highlight: 'dsh-better-sidebar' })
+      expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')?.hasAttribute('data-plugin-highlight')).toBe(true)
+    } finally {
+      if (descriptor !== undefined) Object.defineProperty(Element.prototype, 'scrollIntoView', descriptor)
+      vi.useRealTimers()
+    }
   })
 
-  it('names what still uses a package in the confirmation and runs the action through it', () => {
+  it('confirms an uninstall by the package\'s title and runs the action through it', () => {
     const { actions, set } = renderTab({
-      packages: [pkg(), pkg({ name: 'dsh-other', title: 'Other' })],
-      confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar', dependents: undefined },
+      packages: [pkg({ title: 'Better sidebar' }), pkg({ name: 'dsh-other' })],
+      confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar' },
     })
-    // An uninstall is always confirmed, so its dialog opens while the Host is still asked.
-    expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'better-sidebar') })).toBeTruthy()
-    expect(screen.getByText(en.confirmChecking)).toBeTruthy()
-    expect(screen.getByRole('button', { name: en.confirmUninstall })).toHaveProperty('disabled', true)
-
-    set({
-      confirm: {
-        action: 'disable',
-        packageName: 'dsh-better-sidebar',
-        dependents: {
-          services: [{ service: 'sidebar', providedBy: 'include:sidebar', injectedBy: ['include:tool-bash', 'include:custom'] }],
-          references: [
-            { rowId: 'r1', moduleName: 'dsh-other/x' },
-            { rowId: 'r2', moduleName: '@fixture/other' },
-            { rowId: 'r3', moduleName: 'dsh-better-sidebar' },
-          ],
-        },
-      },
-    })
-    expect(screen.getByText(en.dependentService.replace('{rows}', `${en['name.tool-bash']}, custom`))).toBeTruthy()
-    expect(screen.getByText(en.dependentReferenceGlobal.replace('{row}', 'Other'))).toBeTruthy()
-    expect(screen.getByText(en.dependentReferenceGlobal.replace('{row}', 'r2'))).toBeTruthy()
-    expect(screen.getByText(en.dependentReferenceGlobal.replace('{row}', 'better-sidebar'))).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: en.confirmDisable }))
-    expect(actions.confirm).toHaveBeenCalledTimes(1)
+    expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'Better sidebar') })).toBeTruthy()
+    expect(screen.getByText(en.confirmUninstallDescription)).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: en.cancel }))
     expect(actions.cancelConfirm).toHaveBeenCalledTimes(1)
-
-    set({ confirm: { action: 'uninstall', packageName: 'dsh-other', dependents: { services: [], references: [] } } })
-    expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'Other') })).toBeTruthy()
-    expect(screen.queryByText(en.confirmDependents)).toBeNull()
+    set({ confirm: { action: 'uninstall', packageName: 'dsh-other' } })
+    expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'other') })).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: en.confirmUninstall }))
-    expect(actions.confirm).toHaveBeenCalledTimes(2)
+    expect(actions.confirm).toHaveBeenCalledTimes(1)
   })
 
   it('words every notice as a toast that dismisses itself', () => {
@@ -663,24 +472,12 @@ describe('PluginManagerPage', () => {
     try {
       const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x', seq: 1 } })
       expect(screen.getByRole('alert').textContent).toContain(en.restartNotice)
-      const failures: [string, string][] = [
-        ['plugins/not-enableable', en.notEnableable.replace('{reason}', 'r')],
-        ['plugins/enable-failed', en.enableFailed.replace('{reason}', 'r')],
-        ['plugins/not-installed', en.notInstalled.replace('{name}', 'pkg-1')],
-        ['plugins/busy', en.busy.replace('{reason}', 'r')],
-        ['plugins/agents-running', en.agentsRunning.replace('{reason}', 'r')],
-        ['plugins/install-failed', en.installFailed],
-        ['gateway/internal', en.actionFailed.replace('{reason}', 'r')],
-      ]
-      let seq = 1
-      for (const [code, text] of failures) {
-        set({ notice: { kind: 'failed', code, reason: 'r', packageName: 'pkg-1', rowId: 'row-1', seq: ++seq } })
-        expect(screen.getByRole('alert').textContent).toContain(text)
-      }
-      set({ notice: { kind: 'cancelled', seq: ++seq } })
+      set({ notice: { kind: 'overridden', packageName: 'pkg-1', seq: 2 } })
+      expect(screen.getByRole('alert').textContent).toContain(en.overriddenNotice.replace('{name}', 'pkg-1'))
+      set({ notice: { kind: 'cancelled', seq: 3 } })
       expect(screen.getByRole('alert').textContent).toContain(en.installCancelled)
-      set({ notice: { kind: 'failed', code: 'plugins/not-installed', reason: 'r', seq: ++seq } })
-      expect(screen.getByRole('alert').textContent).toContain(en.notInstalled.replace('{name}', ''))
+      set({ notice: { kind: 'failed', reason: 'the tree rejected it', packageName: 'pkg-1', seq: 4 } })
+      expect(screen.getByRole('alert').textContent).toContain(en.actionFailed.replace('{reason}', 'the tree rejected it'))
       // No button to press: the toast retires on its own and the store forgets it.
       expect(screen.queryByRole('button', { name: /got it/i })).toBeNull()
       expect(actions.dismissNotice).not.toHaveBeenCalled()

+ 245 - 354
packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts

@@ -1,38 +1,36 @@
 /**
  * The manager store: what it reads, how actions cross the wire, which
- * failures become notices, and how the install run folds its output.
+ * outcomes become toasts, and how the install run folds its output.
  */
 
 import { describe, expect, it, vi } from 'vitest'
-import type { PluginInstallRequestId, PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
+import type { BundleInfo, ChangeResult, PluginEntryId, PluginInfo, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
-import { PluginManagerController, rowKey } from '../src/client/manager-store.ts'
+import { packageView, PluginManagerController, rowKey } from '../src/client/manager-store.ts'
 
-const BUNDLE: PluginPackageView = {
+const ROW_ENTRY = 'include:sidebar' as PluginEntryId
+
+const BUNDLE: BundleInfo = {
   name: 'dsh-better-sidebar',
   version: '0.16.0',
-  kind: 'bundle',
-  installed: true,
+  title: 'Better sidebar',
+  description: 'A sidebar.',
   enabled: false,
-  status: 'disabled',
-  cordisSameCopy: true,
-  rows: [],
-  overrides: [],
-  liveReload: true,
+  installed: true,
+  removable: true,
+  rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme' }],
+  overrides: ['layout'],
 }
 
-/** What one install run answers. */
-type InstallValue = {
-  installed: string[]
-  removed: { name: string; reason: string }[]
-  enabled: string[]
-  installedOnly: string[]
-  plain: string[]
-  jobId: string
-}
+const PLUGINS: PluginInfo[] = [
+  { entryId: ROW_ENTRY, moduleName: 'dsh-better-sidebar', enabled: true, fiberPhase: 'active', patchId: 'sidebar' },
+  { entryId: 'include:core' as PluginEntryId, moduleName: '@deepseek-ai/dsh-base', enabled: true, fiberPhase: 'active', readOnlyReason: 'Required for plugin management.' },
+]
 
 /** What the check answers for a registry name. */
-const INSPECTED = { kind: 'registry' as const, name: 'dsh-better-sidebar', version: '1.0.0', bundle: true }
+const INSPECTED = { status: 'accepted' as const, kind: 'registry' as const, name: 'dsh-better-sidebar', version: '1.0.0', bundle: true }
+
+const APPLIED: ChangeResult = { changed: true, application: 'applied', message: 'ok' }
 
 function ok<T>(value: T) {
   return { ok: true as const, value }
@@ -50,20 +48,19 @@ function deferred<T>() {
 }
 
 function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}) {
+  const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) }
   const plugins = {
-    list: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
+    listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
+    listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))),
     inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))),
-    add: vi.fn(() => Promise.resolve(ok({ installed: ['a'], removed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j1' }))),
+    installBundle: vi.fn(() => Promise.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))),
     cancelInstall: vi.fn(() => Promise.resolve(ok({ status: 'cancelled' }))),
-    uninstall: vi.fn(() => Promise.resolve(ok(undefined))),
-    enable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'live' }))),
-    disable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'restart' }))),
-    retry: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'live' }))),
-    setRowDisabled: vi.fn(() => Promise.resolve(ok(undefined))),
-    dependents: vi.fn(() => Promise.resolve(ok({ services: [], references: [] }))),
+    removeBundle: vi.fn(() => Promise.resolve(ok(APPLIED))),
+    setBundleEnabled: vi.fn(() => Promise.resolve(ok(APPLIED))),
+    setPluginEnabled: vi.fn(() => Promise.resolve(ok(APPLIED))),
     ...overrides,
   }
-  const ctx = { remote: { plugins } } as never
+  const ctx = { remote: { pluginManager: plugins, pluginInventory: inventory } } as never
   const controller = new PluginManagerController(ctx)
   const face = controller.inject()
   const state = () => controller.getSnapshot()
@@ -72,222 +69,161 @@ function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}
     await vi.waitFor(() => { expect(state().install.phase).toBe('starting') })
     return state().install.requestId as PluginInstallRequestId
   }
-  return { plugins, controller, face, state, started }
+  return { plugins, inventory, controller, face, state, started }
 }
 
+describe('packageView', () => {
+  it('joins a bundle with the entries its rows run as', () => {
+    expect(packageView(BUNDLE, PLUGINS)).toEqual({
+      name: 'dsh-better-sidebar', version: '0.16.0', title: 'Better sidebar', description: 'A sidebar.',
+      installed: true, enabled: false, overrides: ['layout'],
+      rows: [
+        { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' },
+        { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null },
+      ],
+    })
+    // A row the inventory no longer lists, a protected row, and a bundle the Host cannot read.
+    const protectedBundle: BundleInfo = {
+      name: '@deepseek-ai/dsh-base', enabled: true, installed: false, removable: false, readOnlyReason: 'management', error: 'broken',
+      rows: [{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core' as PluginEntryId }, { rowId: 'gone', moduleName: 'x', entryId: 'include:gone' as PluginEntryId }],
+      overrides: [],
+    }
+    expect(packageView(protectedBundle, PLUGINS)).toEqual({
+      name: '@deepseek-ai/dsh-base', installed: false, enabled: true, readOnlyReason: 'management', error: 'broken', overrides: [],
+      rows: [
+        { rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'Required for plugin management.' },
+        { rowId: 'gone', moduleName: 'x', entryId: 'include:gone', enabled: false, phase: null },
+      ],
+    })
+  })
+})
+
 describe('PluginManagerController', () => {
-  it('starts idle, reads packages on first use, and folds concurrent loads', async () => {
-    const gate = deferred<ReturnType<typeof ok<PluginPackageView[]>>>()
-    const { plugins, face, state, controller } = bench({ list: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValue(ok([BUNDLE])) })
+  it('starts idle, reads the inventory then the bundles and entries on first use, and folds concurrent loads', async () => {
+    const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
+    const { plugins, inventory, face, state, controller } = bench({
+      listBundles: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValue(ok([BUNDLE])),
+    })
     expect(state().status).toBe('idle')
     face.ensure()
     face.ensure()
     await Promise.resolve()
     expect(state().status).toBe('loading')
     const mid = controller.load()
-    gate.resolve(ok([]))
+    gate.resolve(ok([BUNDLE]))
     await mid
-    // The in-flight read reran once for the load that landed mid-read.
-    expect(plugins.list).toHaveBeenCalledTimes(2)
-    expect(state()).toMatchObject({ status: 'ready', packages: [BUNDLE] })
+    expect(state().status).toBe('ready')
+    expect(state().packages).toEqual([packageView(BUNDLE, PLUGINS)])
+    expect(inventory.list).toHaveBeenCalledTimes(2)
+    expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+    expect(plugins.listPlugins).toHaveBeenCalledTimes(2)
     face.ensure()
-    expect(plugins.list).toHaveBeenCalledTimes(2)
-    face.refresh()
-    await controller.load()
-    expect(plugins.list).toHaveBeenCalledTimes(3)
+    expect(plugins.listBundles).toHaveBeenCalledTimes(2)
   })
 
-  it('reports an unavailable profile runtime and keeps the last packages across a failed read', async () => {
-    const { plugins, controller, state } = bench({
-      list: vi.fn()
-        .mockResolvedValueOnce(refused('plugins/unavailable', 'no profile', { reason: 'no profile' }))
-        .mockResolvedValueOnce(ok([BUNDLE]))
-        .mockResolvedValueOnce(refused('gateway/internal', 'boom')),
-    })
+  it('reports a Host without a managed profile as unavailable and keeps the last packages across a failed read', async () => {
+    const { inventory, plugins, face, state, controller } = bench()
     await controller.load()
-    expect(state().status).toBe('unavailable')
+    expect(state().packages).toHaveLength(1)
+    inventory.list.mockResolvedValueOnce(ok({ entries: [] }))
     await controller.load()
-    expect(state()).toMatchObject({ status: 'ready', packages: [BUNDLE] })
+    expect(state()).toMatchObject({ status: 'unavailable', packages: [] })
+    inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
     await controller.load()
-    expect(state()).toMatchObject({ status: 'error', packages: [BUNDLE] })
-    expect(plugins.list).toHaveBeenCalledTimes(3)
+    expect(state().status).toBe('error')
+    await controller.load()
+    expect(state().status).toBe('ready')
+    plugins.listPlugins.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never)
+    await controller.load()
+    expect(state()).toMatchObject({ status: 'error', packages: [packageView(BUNDLE, PLUGINS)] })
+    plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never)
+    await controller.load()
+    expect(state().status).toBe('error')
+    face.refresh()
+    await vi.waitFor(() => { expect(state().status).toBe('ready') })
   })
 
-  it('enables a bundle, marks it busy meanwhile, and says when a restart is needed', async () => {
-    const gate = deferred<ReturnType<typeof ok<{ changed: boolean; effect: 'live' | 'restart' }>>>()
-    const { plugins, face, state, controller } = bench({ enable: vi.fn().mockReturnValueOnce(gate.promise) })
+  it('enables a bundle, marks it busy meanwhile, and says when a restart is needed or a layer overrides it', async () => {
+    const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const { plugins, face, state, controller } = bench({
+      setBundleEnabled: vi.fn()
+        .mockReturnValueOnce(gate.promise)
+        .mockResolvedValueOnce(ok({ ...APPLIED, application: 'restart-required' }))
+        .mockResolvedValueOnce(ok({ ...APPLIED, application: 'overridden' })),
+    })
     await controller.load()
     face.setEnabled(BUNDLE.name, true)
     face.setEnabled(BUNDLE.name, true)
+    await Promise.resolve()
     expect(state().busy).toEqual([BUNDLE.name])
-    expect(plugins.enable).toHaveBeenCalledTimes(1)
-    gate.resolve(ok({ changed: true, effect: 'restart' }))
+    expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith(BUNDLE.name, true)
+    gate.resolve(ok(APPLIED))
     await vi.waitFor(() => { expect(state().busy).toEqual([]) })
-    expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name, seq: 1 })
-    expect(plugins.list).toHaveBeenCalledTimes(2)
-    face.dismissNotice()
     expect(state().notice).toBeNull()
-  })
-
-  it('turns a refused enable into a notice carrying the Host reason, or its message without one', async () => {
-    const { face, state, controller } = bench({
-      enable: vi.fn()
-        .mockResolvedValueOnce(refused('plugins/not-enableable', 'plugin-manager: x cannot', { reason: 'foreign cordis' }))
-        .mockResolvedValueOnce(refused('plugins/not-enableable', 'no string reason', { reason: 42 })),
-    })
-    await controller.load()
-    face.setEnabled(BUNDLE.name, true)
-    await vi.waitFor(() => { expect(state().notice).not.toBeNull() })
-    expect(state().notice).toEqual({
-      kind: 'failed', code: 'plugins/not-enableable', reason: 'foreign cordis', packageName: BUNDLE.name, seq: 1,
-    })
-    expect(state().busy).toEqual([])
-    face.setEnabled(BUNDLE.name, true)
-    await vi.waitFor(() => { expect(state().notice).toMatchObject({ reason: 'no string reason' }) })
-  })
-
-  it('disables at once when nothing depends on the bundle, and asks first when something does', async () => {
-    const { plugins, face, state, controller } = bench()
-    await controller.load()
+    expect(plugins.listBundles).toHaveBeenCalledTimes(2)
     face.setEnabled(BUNDLE.name, false)
-    await vi.waitFor(() => { expect(plugins.disable).toHaveBeenCalledWith(BUNDLE.name) })
     await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name, seq: 1 }) })
-
-    plugins.dependents.mockResolvedValueOnce(ok({
-      services: [{ service: 'sidebar', providedBy: 'dsh-better-sidebar/better-sidebar', injectedBy: ['x'] }],
-      references: [],
-    }) as never)
     face.setEnabled(BUNDLE.name, false)
-    // The switch is inert while the Host is asked; the dialog opens only with the answer in hand.
-    expect(state().busy).toEqual([BUNDLE.name])
-    expect(state().confirm).toBeNull()
-    await vi.waitFor(() => { expect(state().confirm?.dependents?.services).toHaveLength(1) })
-    expect(state().confirm).toMatchObject({ action: 'disable', packageName: BUNDLE.name })
-    expect(state().busy).toEqual([])
-    face.confirm()
-    expect(state().confirm).toBeNull()
-    await vi.waitFor(() => { expect(plugins.disable).toHaveBeenCalledTimes(2) })
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'overridden', packageName: BUNDLE.name, seq: 2 }) })
   })
 
-  it('asks before switching a row off only when other rows inject what it provides', async () => {
-    const services = [
-      { service: 'authorization', providedBy: 'include:seam', injectedBy: ['include:oauth'] },
-      { service: 'other', providedBy: 'include:elsewhere', injectedBy: ['include:x'] },
-    ]
-    const { plugins, face, state, controller } = bench({ dependents: vi.fn(() => Promise.resolve(ok({ services, references: [] }))) })
-    await controller.load()
-    face.disableRow(BUNDLE.name, 'include:seam', 'seam')
-    // The row is inert while the Host is asked, and no dialog opens before the answer.
-    expect(state().busy).toEqual([rowKey('seam')])
-    expect(state().confirm).toBeNull()
-    // Of the package's dependents, only the services this row provides are the row's.
-    await vi.waitFor(() => {
-      expect(state().confirm).toEqual({ action: 'disableRow', packageName: BUNDLE.name, rowId: 'seam', dependents: { services: [services[0]], references: [] } })
+  it('turns a change the Host could not apply, or a refused answer, into a notice carrying its words', async () => {
+    const { face, state, controller } = bench({
+      setBundleEnabled: vi.fn()
+        .mockResolvedValueOnce(ok({ changed: false, application: 'failed', message: 'the tree rejected it' }))
+        .mockResolvedValueOnce(refused('gateway/internal', 'offline'))
+        .mockRejectedValueOnce(new Error('transport down'))
+        .mockRejectedValueOnce('odd'),
     })
-    expect(plugins.setRowDisabled).not.toHaveBeenCalled()
-    face.confirm()
-    await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith('seam', true) })
-    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
-    // A row nothing depends on switches off without asking; so does one whose dependents the Host refused to name.
-    face.disableRow(BUNDLE.name, 'include:lonely', 'lonely')
-    await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith('lonely', true) })
-    expect(state().confirm).toBeNull()
-    plugins.dependents.mockResolvedValueOnce(refused('gateway/internal', 'boom') as never)
-    face.disableRow(BUNDLE.name, 'include:unknown', 'unknown')
-    await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith('unknown', true) })
-    expect(state().confirm).toBeNull()
-    // A check that fails on the wire does not stand between the person and the switch.
-    plugins.dependents.mockRejectedValueOnce(new Error('down'))
-    face.disableRow(BUNDLE.name, 'include:offline', 'offline')
-    await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith('offline', true) })
-    expect(state().confirm).toBeNull()
-  })
-
-  it('drops a row ask whose answer arrives after disposal, and refuses a second ask while one is in flight', async () => {
-    const gate = deferred<ReturnType<typeof ok<{ services: never[]; references: never[] }>>>()
-    const { plugins, face, state, controller } = bench({ dependents: vi.fn().mockReturnValueOnce(gate.promise) })
     await controller.load()
-    face.disableRow(BUNDLE.name, 'include:seam', 'seam')
-    face.disableRow(BUNDLE.name, 'include:seam', 'seam')
-    expect(plugins.dependents).toHaveBeenCalledTimes(1)
-    controller.dispose()
-    gate.resolve(ok({ services: [], references: [] }))
-    await Promise.resolve()
-    await Promise.resolve()
-    expect(state().confirm).toBeNull()
-    expect(plugins.setRowDisabled).not.toHaveBeenCalled()
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', reason: 'the tree rejected it', packageName: BUNDLE.name, seq: 1 }) })
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', reason: 'offline', packageName: BUNDLE.name, seq: 2 }) })
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', reason: 'transport down', packageName: BUNDLE.name, seq: 3 }) })
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', reason: 'odd', packageName: BUNDLE.name, seq: 4 }) })
+    face.dismissNotice()
+    expect(state().notice).toBeNull()
   })
 
   it('always asks before uninstalling, and cancelling runs nothing', async () => {
     const { plugins, face, state, controller } = bench()
     await controller.load()
-    // A check that fails on the wire still leaves the dialog open, with nothing to name.
-    plugins.dependents.mockRejectedValueOnce(new Error('down'))
     face.uninstall(BUNDLE.name)
-    await vi.waitFor(() => { expect(state().confirm?.dependents).toEqual({ services: [], references: [] }) })
+    expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name })
     face.cancelConfirm()
     expect(state().confirm).toBeNull()
     face.confirm()
-    await Promise.resolve()
-    expect(plugins.uninstall).not.toHaveBeenCalled()
-
+    expect(plugins.removeBundle).not.toHaveBeenCalled()
     face.uninstall(BUNDLE.name)
-    await vi.waitFor(() => { expect(state().confirm?.dependents).toBeDefined() })
     face.confirm()
-    await vi.waitFor(() => { expect(plugins.uninstall).toHaveBeenCalledWith(BUNDLE.name) })
-    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
-    // Success shows in the re-read list, not as a notice.
-    expect(state().notice).toBeNull()
-    expect(state().confirm).toBeNull()
-  })
-
-  it('drops a dependents answer that arrives after the confirmation changed', async () => {
-    const gate = deferred<ReturnType<typeof ok<{ services: never[]; references: never[] }>>>()
-    const { plugins, face, state, controller } = bench({ dependents: vi.fn().mockReturnValueOnce(gate.promise) })
-    await controller.load()
-    face.uninstall(BUNDLE.name)
-    face.cancelConfirm()
-    gate.resolve(ok({ services: [], references: [] }))
-    await Promise.resolve()
-    await Promise.resolve()
     expect(state().confirm).toBeNull()
-    // A refused dependents read confirms with an empty list rather than blocking.
-    plugins.dependents.mockResolvedValueOnce(refused('gateway/internal', 'boom') as never)
-    face.uninstall(BUNDLE.name)
-    await vi.waitFor(() => { expect(state().confirm?.dependents).toEqual({ services: [], references: [] }) })
-  })
-
-  it('retries and switches rows under their own busy keys', async () => {
-    const { plugins, face, state, controller } = bench()
-    await controller.load()
-    face.retry(BUNDLE.name)
-    await vi.waitFor(() => { expect(plugins.retry).toHaveBeenCalledWith(BUNDLE.name) })
-    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
-    expect(state().notice).toBeNull()
-
-    face.setRowDisabled('bash', true)
-    expect(state().busy).toEqual([rowKey('bash')])
-    await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith('bash', true) })
+    await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) })
     await vi.waitFor(() => { expect(state().busy).toEqual([]) })
-
   })
 
-  it('reports thrown row transport failures using the row id', async () => {
-    const { face, state, controller } = bench({
-      setRowDisabled: vi.fn().mockRejectedValueOnce(new Error('offline')).mockRejectedValueOnce('plain text'),
+  it('switches rows under their own busy keys and reports what the Host said', async () => {
+    const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const { plugins, face, state, controller } = bench({
+      setPluginEnabled: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValueOnce(ok({ changed: false, application: 'failed', message: 'not addressable' })),
     })
     await controller.load()
-    face.setRowDisabled('r', true)
-    await vi.waitFor(() => {
-      expect(state().notice).toEqual({ kind: 'failed', code: 'gateway/internal', reason: 'offline', rowId: 'r', seq: 1 })
-    })
-    face.setRowDisabled('r', true)
-    await vi.waitFor(() => { expect(state().notice).toMatchObject({ reason: 'plain text' }) })
+    face.setRowEnabled(ROW_ENTRY, false)
+    face.setRowEnabled(ROW_ENTRY, false)
+    await Promise.resolve()
+    expect(state().busy).toEqual([rowKey(ROW_ENTRY)])
+    expect(plugins.setPluginEnabled).toHaveBeenCalledExactlyOnceWith(ROW_ENTRY, false)
+    gate.resolve(ok(APPLIED))
+    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
+    face.setRowEnabled(ROW_ENTRY, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', reason: 'not addressable', packageName: ROW_ENTRY, seq: 1 }) })
   })
 
   it('checks the spec, hands the run to the Host, and folds the chunks that carry its request id', async () => {
-    const gate = deferred<ReturnType<typeof ok<InstallValue>>>()
-    const { plugins, face, state, controller, started } = bench({ add: vi.fn().mockReturnValueOnce(gate.promise) })
+    const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const { plugins, face, state, controller, started } = bench({ installBundle: vi.fn().mockReturnValueOnce(gate.promise) })
     await controller.load()
     face.runInstall()
     expect(plugins.inspect).not.toHaveBeenCalled()
@@ -304,53 +240,42 @@ describe('PluginManagerController', () => {
     expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', expect.any(AbortSignal))
     const requestId = await started()
     expect(state().install.subject).toEqual({ spec: 'dsh-new', ...INSPECTED })
-    expect(plugins.add).toHaveBeenCalledTimes(1)
-    expect(plugins.add).toHaveBeenCalledWith('dsh-new', { requestId })
+    expect(plugins.installBundle).toHaveBeenCalledTimes(1)
+    expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', { enabled: false, requestId })
     // The Host's acknowledgement makes the run stoppable; a chunk of another request is not this run's.
     controller.installProgress({ requestId, phase: 'installing' })
     expect(state().install.phase).toBe('running')
-    controller.appendLog({ requestId: 'other' as PluginInstallRequestId, jobId: 'j1', argv: [], cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'x' })
+    controller.appendLog({ requestId: 'other' as PluginInstallRequestId, jobId: 'j1', argv: [], cwd: '/p', stream: 'stdout', text: 'x' })
     expect(state().install.runs).toEqual([])
     const argv = ['pnpm', 'add', 'dsh-new']
-    controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'Progress\n' })
-    // The Host's second pnpm run — removing a rejected package, under that
-    // package's name — is a run of its own, and a later chunk lands on the
-    // run it names.
-    controller.appendLog({ requestId, jobId: 'jr', argv: ['pnpm', 'remove', 'lib'], cwd: '/p', spec: 'lib', stream: 'stdout', text: '- lib\n', exitCode: 0 })
-    controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'Done\n' })
+    controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stdout', text: 'Progress\n' })
+    // A second run of the same install is its own terminal; a later chunk lands on the run it names.
+    controller.appendLog({ requestId, jobId: 'j2', argv: ['pnpm', 'remove', 'lib'], cwd: '/p', stream: 'stdout', text: '- lib\n', exitCode: 0 })
+    controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stderr', text: 'Done\n' })
     expect(state().install.runs).toEqual([
       { jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\n' },
-      { jobId: 'jr', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
+      { jobId: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
     ])
     face.toggleInstallDetails()
     expect(state().install.detailsOpen).toBe(true)
-    gate.resolve(ok({
-      installed: ['dsh-new', 'dsh-tool-foo'], removed: [{ name: 'lib', reason: 'not a plugin' }],
-      enabled: [], installedOnly: ['dsh-new'], plain: ['dsh-tool-foo'], jobId: 'j1',
-    }))
+    gate.resolve(ok({ changed: true, application: 'applied', message: 'Bundle installation: dsh-new.', bundle: 'dsh-new' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
-    expect(state().install).toMatchObject({
-      installed: ['dsh-new', 'dsh-tool-foo'],
-      installedOnly: ['dsh-new'],
-      plain: ['dsh-tool-foo'],
-      removed: [{ name: 'lib', reason: 'not a plugin' }],
-      detailsOpen: true,
-    })
+    expect(state().install).toMatchObject({ installed: 'dsh-new', restartRequired: false, detailsOpen: true })
     // The finished install settled its run; a trailing last chunk still lands
     // on it, while a chunk for a run the dialog never saw is dropped.
-    controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'late', exitCode: 0 })
-    controller.appendLog({ requestId, jobId: 'j3', argv, cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'stray' })
+    controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stdout', text: '', exitCode: 0 })
+    controller.appendLog({ requestId, jobId: 'j3', argv, cwd: '/p', stream: 'stdout', text: 'stray' })
     expect(state().install.runs).toEqual([
-      { jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\nlate', exitCode: 0 },
-      { jobId: 'jr', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
+      { jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\n', exitCode: 0 },
+      { jobId: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
     ])
-    await vi.waitFor(() => { expect(plugins.list).toHaveBeenCalledTimes(2) })
+    await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) })
     // Cancelling from the finished screen does nothing, nor does the Host's late progress; a new spec after it starts over.
     face.cancelInstall()
     controller.installProgress({ requestId, phase: 'applying' })
     expect(state().install.phase).toBe('done')
     face.editInstallSpec('another')
-    expect(state().install).toMatchObject({ phase: 'idle', spec: 'another', runs: [], installed: [], subject: null })
+    expect(state().install).toMatchObject({ phase: 'idle', spec: 'another', runs: [], installed: null, subject: null })
     face.closeInstall()
     expect(state().install.open).toBe(false)
   })
@@ -358,9 +283,9 @@ describe('PluginManagerController', () => {
   it('refuses a spec the list already shows without asking the Host, and words what the Host refused', async () => {
     const { plugins, face, state, controller } = bench({
       inspect: vi.fn()
-        .mockResolvedValueOnce(refused('plugins/inspect-rejected', 'not found', { spec: 'nope', problem: 'not-found', reason: 'E404' }))
-        .mockResolvedValueOnce(refused('plugins/inspect-rejected', 'odd', { spec: 'odd', problem: 'made-up', reason: 'strange' }))
-        .mockResolvedValueOnce(refused('plugins/unavailable', 'no profile', { reason: 'no profile runtime' })),
+        .mockResolvedValueOnce(ok({ status: 'refused', problem: 'not-found', reason: 'E404' }))
+        .mockResolvedValueOnce(ok({ status: 'refused', problem: 'not-a-bundle', reason: 'plain declares no dsh.bundle' }))
+        .mockResolvedValueOnce(refused('gateway/internal', 'offline')),
     })
     await controller.load()
     face.openInstall()
@@ -374,21 +299,21 @@ describe('PluginManagerController', () => {
     face.runInstall()
     await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'not-found', reason: 'E404' }) })
     expect(state().install.phase).toBe('idle')
-    expect(plugins.add).not.toHaveBeenCalled()
-    // A problem the dialog does not know reads as unknown; a refusal without a problem too.
-    face.editInstallSpec('odd')
+    expect(plugins.installBundle).not.toHaveBeenCalled()
+    face.editInstallSpec('plain')
     face.runInstall()
-    await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'strange' }) })
+    await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'not-a-bundle', reason: 'plain declares no dsh.bundle' }) })
+    // A refused answer, rather than a refused spec, reads as unknown with the transport's words.
     face.editInstallSpec('x')
     face.runInstall()
-    await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'no profile runtime' }) })
+    await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'offline' }) })
   })
 
   it('leaves the check or the failed screen for the spec at once', async () => {
     const inspectGate = deferred<ReturnType<typeof ok<typeof INSPECTED>>>()
     const { plugins, face, state, controller } = bench({
       inspect: vi.fn().mockReturnValueOnce(inspectGate.promise).mockResolvedValue(ok(INSPECTED)),
-      add: vi.fn().mockResolvedValue(refused('plugins/install-failed', 'exit 1', { spec: 'dsh-x', exitCode: 1, log: 'ERR', kind: 'unknown' })),
+      installBundle: vi.fn().mockResolvedValue(ok({ changed: false, application: 'failed', message: 'ERR', packageResult: { exitCode: 1, output: 'ERR', truncated: false, logPath: '/l', kind: 'network' } })),
     })
     await controller.load()
     face.openInstall()
@@ -403,7 +328,7 @@ describe('PluginManagerController', () => {
     await Promise.resolve()
     await Promise.resolve()
     expect(state().install.phase).toBe('idle')
-    expect(plugins.add).not.toHaveBeenCalled()
+    expect(plugins.installBundle).not.toHaveBeenCalled()
     // Closing during a check drops it too.
     face.runInstall()
     face.closeInstall()
@@ -414,16 +339,17 @@ describe('PluginManagerController', () => {
     face.editInstallSpec('dsh-x')
     face.runInstall()
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
+    expect(state().install.failure).toEqual({ reason: 'ERR', kind: 'network' })
     face.cancelInstall()
     expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'dsh-x', failure: null, subject: null })
   })
 
   it('asks the Host to stop a run, keeps the spec once it confirms, and forgets the stopped run', async () => {
-    const first = deferred<ReturnType<typeof refused>>()
-    const second = deferred<ReturnType<typeof ok<InstallValue>>>()
+    const first = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const second = deferred<ReturnType<typeof ok<ChangeResult>>>()
     const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
     const { plugins, face, state, controller, started } = bench({
-      add: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
+      installBundle: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
       cancelInstall: vi.fn().mockReturnValueOnce(cancellation.promise),
     })
     face.openInstall()
@@ -456,21 +382,22 @@ describe('PluginManagerController', () => {
     const nextId = await started()
     expect(nextId).not.toBe(requestId)
     // The stopped run's answer, progress, and chunks belong to a request the dialog no longer has.
-    first.resolve(refused('plugins/install-cancelled', 'cancelled', { requestId }))
+    first.resolve(ok({ changed: false, application: 'cancelled', message: 'Installation cancelled' }))
     await Promise.resolve()
     await Promise.resolve()
     expect(state().install).toMatchObject({ requestId: nextId, phase: 'starting' })
     controller.installProgress({ requestId, phase: 'applying' })
-    controller.appendLog({ requestId, jobId: 'old', argv: [], cwd: '/p', spec: 'slow', stream: 'stdout', text: 'late' })
+    controller.appendLog({ requestId, jobId: 'old', argv: [], cwd: '/p', stream: 'stdout', text: 'late' })
     expect(state().install).toMatchObject({ phase: 'starting', runs: [] })
-    second.resolve(ok({ installed: [], removed: [], enabled: [], installedOnly: [], plain: [], jobId: 'new' }))
+    second.resolve(ok({ changed: true, application: 'restart-required', message: 'again', bundle: 'slow' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    expect(state().install).toMatchObject({ installed: 'slow', restartRequired: true })
   })
 
   it.each(['too-late', 'not-running', 'offline'] as const)('keeps a stop the Host answered %s apart from a stopped run', async (status) => {
-    const pending = deferred<ReturnType<typeof refused>>()
+    const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
     const { plugins, face, state, controller, started } = bench({
-      add: vi.fn().mockReturnValue(pending.promise),
+      installBundle: vi.fn().mockReturnValue(pending.promise),
       cancelInstall: vi.fn().mockResolvedValue(status === 'offline' ? refused('gateway/internal', 'offline') : ok({ status })),
     })
     face.openInstall()
@@ -483,20 +410,20 @@ describe('PluginManagerController', () => {
     expect(plugins.cancelInstall).toHaveBeenCalledOnce()
     // A stop the Host did not confirm says so over the running screen, with the transport's words when it has them.
     expect(state().install.failure).toEqual(
-      status === 'too-late' ? null : { code: 'client/cancel-unconfirmed', reason: status === 'offline' ? 'offline' : '' },
+      status === 'too-late' ? null : { reason: status === 'offline' ? 'offline' : '', cancelUnconfirmed: true },
     )
     // The Host's own word that it stopped the run still ends it.
-    pending.resolve(refused('plugins/install-cancelled', 'cancelled', { requestId }))
+    pending.resolve(ok({ changed: false, application: 'cancelled', message: 'Installation cancelled' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
     expect(state().install).toMatchObject({ open: true, spec: 'slow', failure: null })
     expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
   })
 
   it.each([false, true])('drops a stop the Host confirms once the run settled, or after disposal (%s)', async (dispose) => {
-    const answer = deferred<ReturnType<typeof ok<InstallValue>>>()
+    const answer = deferred<ReturnType<typeof ok<ChangeResult>>>()
     const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
     const { face, state, controller, started } = bench({
-      add: vi.fn().mockReturnValue(answer.promise),
+      installBundle: vi.fn().mockReturnValue(answer.promise),
       cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
     })
     await controller.load()
@@ -507,7 +434,7 @@ describe('PluginManagerController', () => {
     face.cancelInstall()
     expect(state().install.phase).toBe('cancelling')
     if (dispose) controller.dispose()
-    answer.resolve(ok({ installed: [], removed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j' }))
+    answer.resolve(ok({ ...APPLIED, bundle: 'slow' }))
     if (!dispose) await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
     cancellation.resolve(ok({ status: 'cancelled' }))
     await Promise.resolve()
@@ -516,18 +443,19 @@ describe('PluginManagerController', () => {
     expect(state().notice).toBeNull()
   })
 
-  it('enables what a finished install added from its screen, closes, and marks the first in the list', async () => {
+  it('enables what a finished install added from its screen, closes, and marks it in the list', async () => {
     const { plugins, face, state, controller } = bench({
-      add: vi.fn().mockResolvedValue(ok({ installed: ['dsh-a', 'dsh-b', 'lib'], removed: [], enabled: [], installedOnly: ['dsh-a', 'dsh-b'], plain: ['lib'], jobId: 'j' })),
-      enable: vi.fn()
-        .mockResolvedValueOnce(ok({ changed: true, effect: 'live' }))
-        .mockResolvedValueOnce(ok({ changed: true, effect: 'restart' }))
-        .mockResolvedValueOnce(ok({ changed: true, effect: 'live' }))
-        .mockResolvedValueOnce(refused('plugins/enable-failed', 'plugin-manager: dsh-b rejected', { packageName: 'dsh-b', reason: 'the tree rejected it' })),
+      installBundle: vi.fn()
+        .mockResolvedValueOnce(ok({ ...APPLIED, bundle: 'dsh-a' }))
+        .mockResolvedValueOnce(ok({ ...APPLIED, bundle: 'dsh-a' }))
+        .mockResolvedValueOnce(ok({ ...APPLIED, application: 'overridden' })),
+      setBundleEnabled: vi.fn()
+        .mockResolvedValueOnce(ok({ ...APPLIED, application: 'restart-required' }))
+        .mockResolvedValueOnce(ok({ changed: false, application: 'failed', message: 'the tree rejected it' })),
     })
     await controller.load()
     face.enableInstalled()
-    expect(plugins.enable).not.toHaveBeenCalled()
+    expect(plugins.setBundleEnabled).not.toHaveBeenCalled()
     face.openInstall()
     face.editInstallSpec('dsh-a')
     face.runInstall()
@@ -536,66 +464,59 @@ describe('PluginManagerController', () => {
     face.enableInstalled()
     expect(state().install.enabling).toBe(true)
     await vi.waitFor(() => { expect(state().install.open).toBe(false) })
-    expect(plugins.enable.mock.calls).toEqual([['dsh-a'], ['dsh-b']])
-    // A restart one of them waits for is said in passing; the list marks the first.
-    expect(state().notice).toEqual({ kind: 'restart', packageName: 'dsh-b', seq: 1 })
+    expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith('dsh-a', true)
+    // A restart it waits for is said in passing; the list marks it.
+    expect(state().notice).toEqual({ kind: 'restart', packageName: 'dsh-a', seq: 1 })
     expect(state().highlight).toBe('dsh-a')
     face.clearHighlight()
     face.clearHighlight()
     expect(state().highlight).toBeNull()
 
-    // A refusal stops at the package it refused, toasts it, and still closes.
+    // A refusal toasts it and still closes.
     face.openInstall()
     face.editInstallSpec('dsh-a')
     face.runInstall()
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
     face.enableInstalled()
     await vi.waitFor(() => { expect(state().install.open).toBe(false) })
-    expect(plugins.enable).toHaveBeenCalledTimes(4)
-    expect(state().notice).toEqual({ kind: 'failed', code: 'plugins/enable-failed', reason: 'the tree rejected it', packageName: 'dsh-b', seq: 2 })
+    expect(plugins.setBundleEnabled).toHaveBeenCalledTimes(2)
+    expect(state().notice).toEqual({ kind: 'failed', reason: 'the tree rejected it', packageName: 'dsh-a', seq: 2 })
     expect(state().highlight).toBe('dsh-a')
 
-    // An install that added no pack has nothing to enable or mark: the screen just closes.
-    plugins.add.mockResolvedValueOnce(ok({ installed: ['lib'], removed: [], enabled: [], installedOnly: [], plain: ['lib'], jobId: 'j' }) as never)
+    // An install that named no bundle has nothing to enable or mark: the screen just closes.
     face.openInstall()
     face.editInstallSpec('lib')
     face.runInstall()
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    expect(state().install.installed).toBeNull()
     face.enableInstalled()
     await vi.waitFor(() => { expect(state().install.open).toBe(false) })
-    expect(plugins.enable).toHaveBeenCalledTimes(4)
+    expect(plugins.setBundleEnabled).toHaveBeenCalledTimes(2)
     expect(state().highlight).toBeNull()
   })
 
   it('drops an enable from the installed screen that settles after disposal', async () => {
-    const enableGate = deferred<ReturnType<typeof ok<{ changed: boolean; effect: 'live' }>>>()
-    const { plugins, face, state, controller } = bench({
-      add: vi.fn().mockResolvedValue(ok({ installed: ['dsh-a'], removed: [], enabled: [], installedOnly: ['dsh-a'], plain: [], jobId: 'j' })),
-      enable: vi.fn().mockReturnValueOnce(enableGate.promise),
-    })
+    const enableGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const { plugins, face, state, controller } = bench({ setBundleEnabled: vi.fn().mockReturnValueOnce(enableGate.promise) })
     await controller.load()
     face.openInstall()
     face.editInstallSpec('dsh-a')
     face.runInstall()
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
     face.enableInstalled()
-    expect(plugins.enable).toHaveBeenCalledWith('dsh-a')
+    expect(plugins.setBundleEnabled).toHaveBeenCalledWith('dsh-new', true)
     const before = state()
     controller.dispose()
-    enableGate.resolve(ok({ changed: true, effect: 'live' }))
+    enableGate.resolve(ok(APPLIED))
     await Promise.resolve()
     await Promise.resolve()
     await Promise.resolve()
     expect(state()).toBe(before)
   })
 
-  it('settles an install and a confirmation while reads run beside them', async () => {
-    const addGate = deferred<ReturnType<typeof ok<InstallValue>>>()
-    const dependentsGate = deferred<ReturnType<typeof ok<{ services: never[]; references: never[] }>>>()
-    const { plugins, face, state, controller, started } = bench({
-      add: vi.fn().mockReturnValueOnce(addGate.promise),
-      dependents: vi.fn().mockReturnValueOnce(dependentsGate.promise),
-    })
+  it('settles an install while reads run beside it', async () => {
+    const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const { plugins, face, state, controller, started } = bench({ installBundle: vi.fn().mockReturnValueOnce(gate.promise) })
     await controller.load()
     face.openInstall()
     face.editInstallSpec('pkg')
@@ -603,22 +524,17 @@ describe('PluginManagerController', () => {
     await started()
     // The Host announces the change before the run answers; the read it triggers must not drop the answer.
     await controller.load()
-    addGate.resolve(ok({ installed: ['pkg'], removed: [], enabled: [], installedOnly: ['pkg'], plain: [], jobId: 'j' }))
+    gate.resolve(ok({ ...APPLIED, bundle: 'pkg' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
-
-    face.uninstall(BUNDLE.name)
-    await controller.load()
-    dependentsGate.resolve(ok({ services: [], references: [] }))
-    await vi.waitFor(() => { expect(state().confirm?.dependents).toEqual({ services: [], references: [] }) })
-    expect(plugins.list).toHaveBeenCalledTimes(4)
+    expect(plugins.listBundles).toHaveBeenCalledTimes(3)
   })
 
-  it('keeps the Host reason and kind of a failed install, settles a run whose last chunk never came, and toasts a refusal of the moment', async () => {
+  it('keeps the Host words and kind of a failed install, and settles a run whose last chunk never came', async () => {
     // Every run waits on its own gate, so chunks can land while it is installing.
-    const gates: ReturnType<typeof deferred<Awaited<ReturnType<typeof refused>>>>[] = []
+    const gates: ReturnType<typeof deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>>[] = []
     const { face, state, controller, plugins, started } = bench({
-      add: vi.fn(() => {
-        const gate = deferred<Awaited<ReturnType<typeof refused>>>()
+      installBundle: vi.fn(() => {
+        const gate = deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>()
         gates.push(gate)
         return gate.promise
       }),
@@ -632,62 +548,53 @@ describe('PluginManagerController', () => {
       face.runInstall()
       requestId = await started()
     }
-    const answer = (value: ReturnType<typeof refused>): void => { gates[gates.length - 1]?.resolve(value) }
-    // No chunk arrived: the Host's captured tail is the reason, and there is no run; the kind is kept.
+    const answer = (value: ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>): void => {
+      gates[gates.length - 1]?.resolve(value)
+    }
+    const failed = (message: string, packageResult?: ChangeResult['packageResult']) =>
+      ok({ changed: false, application: 'failed' as const, message, ...packageResult === undefined ? {} : { packageResult } })
+    // No chunk arrived: the Host's words are the reason, and there is no run; the kind is kept.
     await installing()
-    answer(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ERR_PNPM', kind: 'network' }))
+    answer(failed('ERR_PNPM', { exitCode: 1, output: 'ERR_PNPM', truncated: false, logPath: '/l', kind: 'network' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
     expect(state().install.runs).toEqual([])
-    expect(state().install.failure).toEqual({ code: 'plugins/install-failed', reason: 'ERR_PNPM', kind: 'network' })
+    expect(state().install.failure).toEqual({ reason: 'ERR_PNPM', kind: 'network' })
     expect(state().install.subject).toEqual({ spec: 'x', ...INSPECTED })
+    // A refused answer, not a failed change, keeps the transport's words and settles the run without an exit code.
     await installing()
+    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stderr', text: 'streamed' })
     answer(refused('gateway/internal', 'offline'))
-    await vi.waitFor(() => { expect(state().install.failure).toEqual({ code: 'gateway/internal', reason: 'offline' }) })
-    // A pnpm failure settles the open run with the code the answer names — here none; a kind the dialog does not know is dropped.
-    await installing()
-    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stderr', text: 'streamed' })
-    answer(refused('plugins/install-failed', 'killed', { spec: 'x', exitCode: null, log: 'tail', kind: 'made-up' }))
-    await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
+    await vi.waitFor(() => { expect(state().install.failure).toEqual({ reason: 'offline' }) })
     expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'streamed', exitCode: null }])
-    expect(state().install.failure).toEqual({ code: 'plugins/install-failed', reason: 'tail' })
-    // A refusal after pnpm means pnpm itself exited 0.
-    await installing()
-    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'Done' })
-    answer(refused('plugins/enable-failed', 'plugin-manager: x rejected', { packageName: 'x', reason: 'the tree rejected it' }))
-    await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
-    expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'Done', exitCode: 0 }])
-    expect(state().install.failure).toEqual({ code: 'plugins/enable-failed', reason: 'the tree rejected it' })
-    // Details without an exit code settle the run as having none.
+    // A pnpm failure settles the open run with the code the answer names.
     await installing()
-    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'partial' })
-    answer(refused('plugins/install-failed', 'exit 1', { spec: 'x' }))
+    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'Done' })
+    answer(failed('tail', { exitCode: 7, output: 'tail', truncated: false, logPath: '/l', kind: 'unknown' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
-    expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'partial', exitCode: null }])
-    // So does an exit code the answer types wrongly.
+    expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'Done', exitCode: 7 }])
+    // A failure after pnpm carries no package result: a run whose last chunk came keeps its own exit code, one still open settles without.
     await installing()
-    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'odd' })
-    answer(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 'one' }))
+    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'partial', exitCode: 0 })
+    controller.appendLog({ requestId, jobId: 'k', argv, cwd: '/p', stream: 'stdout', text: 'open' })
+    answer(failed('x declares no dsh.bundle.patch'))
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
-    expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'odd', exitCode: null }])
-    // A running session refuses the moment, not the spec: a toast, and the spec is back to try again.
-    await installing()
-    answer(refused('plugins/agents-running', 'plugin-manager: add waits for 1 running session(s)', { operation: 'add', running: 1 }))
-    await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
-    expect(state().install).toMatchObject({ open: true, spec: 'x', subject: null, runs: [], failure: null })
-    expect(state().install.requestId).toBeUndefined()
-    expect(state().notice).toEqual({ kind: 'failed', code: 'plugins/agents-running', reason: 'plugin-manager: add waits for 1 running session(s)', seq: 1 })
-    expect(plugins.add).toHaveBeenCalledTimes(7)
+    expect(state().install.runs).toEqual([
+      { jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'partial', exitCode: 0 },
+      { jobId: 'k', command: 'pnpm add x', cwd: '/p', output: 'open', exitCode: null },
+    ])
+    expect(state().install.failure).toEqual({ reason: 'x declares no dsh.bundle.patch' })
+    expect(plugins.installBundle).toHaveBeenCalledTimes(4)
     // Editing the spec after a failure starts over too.
     face.editInstallSpec('y')
     expect(state().install).toMatchObject({ phase: 'idle', spec: 'y', runs: [], failure: null })
   })
 
   it('drops every late settlement after disposal', async () => {
-    const enableGate = deferred<ReturnType<typeof ok<{ changed: boolean; effect: 'live' }>>>()
-    const installGate = deferred<ReturnType<typeof ok<InstallValue>>>()
+    const enableGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const installGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
     const { face, state, controller, started } = bench({
-      enable: vi.fn().mockReturnValueOnce(enableGate.promise),
-      add: vi.fn().mockReturnValueOnce(installGate.promise),
+      setBundleEnabled: vi.fn().mockReturnValueOnce(enableGate.promise),
+      installBundle: vi.fn().mockReturnValueOnce(installGate.promise),
     })
     await controller.load()
     face.openInstall()
@@ -697,8 +604,8 @@ describe('PluginManagerController', () => {
     face.setEnabled(BUNDLE.name, true)
     const before = state()
     controller.dispose()
-    enableGate.resolve(ok({ changed: true, effect: 'live' }))
-    installGate.resolve(ok({ installed: [], removed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j' }))
+    enableGate.resolve(ok(APPLIED))
+    installGate.resolve(ok(APPLIED))
     await Promise.resolve()
     await Promise.resolve()
     await Promise.resolve()
@@ -710,43 +617,27 @@ describe('PluginManagerController', () => {
   })
 
   it('drops a read that settles after disposal', async () => {
-    const gate = deferred<ReturnType<typeof ok<PluginPackageView[]>>>()
-    const { state, controller } = bench({ list: vi.fn().mockReturnValueOnce(gate.promise) })
+    const gate = deferred<ReturnType<typeof ok<{ entries: never[]; managementAvailable: boolean }>>>()
+    const { state, controller } = bench({ inventory: vi.fn().mockReturnValueOnce(gate.promise) })
     const loading = controller.load()
     await Promise.resolve()
     const before = state()
     controller.dispose()
-    gate.resolve(ok([BUNDLE]))
+    gate.resolve(ok({ entries: [], managementAvailable: true }))
     await loading
     expect(state()).toBe(before)
   })
 
-  it('reports a thrown enable failure after disposal to nobody', async () => {
-    const enableGate = deferred<never>()
-    const { face, state, controller } = bench({ enable: vi.fn().mockReturnValueOnce(enableGate.promise) })
-    await controller.load()
-    face.setEnabled(BUNDLE.name, true)
+  it('drops a bundle read that settles after disposal', async () => {
+    const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
+    const { state, controller } = bench({ listBundles: vi.fn().mockReturnValueOnce(gate.promise) })
+    const loading = controller.load()
+    await vi.waitFor(() => { expect(state().status).toBe('loading') })
+    await Promise.resolve()
     const before = state()
     controller.dispose()
-    enableGate.resolve(refused('gateway/internal', 'late') as never)
-    await Promise.resolve()
-    await Promise.resolve()
+    gate.resolve(ok([BUNDLE]))
+    await loading
     expect(state()).toBe(before)
   })
-
-  it('leaves a run its own exit code when its last chunk beat the answer', async () => {
-    const gate = deferred<ReturnType<typeof refused>>()
-    const { face, state, controller, started } = bench({ add: vi.fn().mockReturnValueOnce(gate.promise) })
-    await controller.load()
-    face.openInstall()
-    face.editInstallSpec('x')
-    face.runInstall()
-    const requestId = await started()
-    controller.appendLog({ requestId, jobId: 'j', argv: ['pnpm', 'add', 'x'], cwd: '/p', spec: 'x', stream: 'stderr', text: 'same tail' })
-    controller.appendLog({ requestId, jobId: 'j', argv: ['pnpm', 'add', 'x'], cwd: '/p', spec: 'x', stream: 'stdout', text: '', exitCode: 1 })
-    gate.resolve(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'same tail', kind: 'unknown' }))
-    await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
-    expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'same tail', exitCode: 1 }])
-    expect(state().install.failure).toEqual({ code: 'plugins/install-failed', reason: 'same tail', kind: 'unknown' })
-  })
 })

+ 1 - 1
packages/client/ui-plugin-manager/tsconfig.json

@@ -15,7 +15,7 @@
       "path": "../../api/remotes/tsconfig.client.json"
     },
     {
-      "path": "../../host/plugin-manager"
+      "path": "../../boot/plugin-manager"
     },
     {
       "path": "../../test-support/client-runtime"

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-inventory/README.md
-README.md: 54392425cb58028dafbfd1bf749323b93b10b56e
-README.zh.md: 4ce94dbeb11a5ceed6a346438c5c87a6a69c1071
+README.md: d2cfae5d61ba245348c114b7212582ade4c5a37e
+README.zh.md: 107c51c7c32bb7dbc5cc9e250253ad4b40c8c1ac

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

@@ -1,5 +1,5 @@
 ---
-description: "Scope-grouped plugin inventory and current-profile management tab in Web Plugins settings for the dsh web client: agent-preset compositions first, the global plane behind a disclosure, search across both."
+description: "Scope-grouped read-only plugin inventory tab in Web Plugins settings for the dsh web client: agent-preset compositions first, the global plane behind a disclosure, search across both."
 kind: "package-reference"
 ---
 
@@ -47,7 +47,7 @@ A failed read renders a generic failure state inside the tab; retrying re-runs t
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The tab reads the Host inventory on first selection, without Remote calls during plugin activation. Hosts exposing [Plugin Manager](../../boot/plugin-manager/README.md) also provide bundle installation, removal and switches for uniquely addressable global entries. Operations refresh observed state and show failures, overrides and pending restarts; a successful inventory refresh preserves the last operation error; preset compositions remain read-only.
+The tab is a read-only projection of a Host-owned snapshot; it performs no Remote read during plugin activation and takes the snapshot on first selection.
 
 ### Registration
 
@@ -89,8 +89,8 @@ None; this package neither assembles nor sends a provider request.
 
 These limits define the freshness and reach of the inventory view; they are current package constraints.
 
-- **Inventory refresh** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings or completing a management operation obtains a new one.
-- **Preset compositions remain read-only**: global controls require the current-profile manager; Desktop retains its shell-owned package controls.
+- **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one.
+- **Read-only in both planes** — the tab shows global and preset enablement but mutates neither; enable/disable controls that write a custom preset's own composition file are deliberate follow-up work.
 
 <a id="dev-note"></a>
 ### Dev Note
@@ -102,4 +102,4 @@ None.
 
 </details>
 
-**Runtime invariant:** No companion is published. This package renders Host-owned state and forwards mutations to Plugin Manager.
+**Runtime invariant:** No companion is published. This package owns a read-only Settings contribution.

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

@@ -1,5 +1,5 @@
 ---
-description: "dsh Web 客户端设置中按作用域分组的插件清单与当前 profile 管理标签页:Agent 预设组合在前,全局平面收在折叠分组里,搜索跨两组。"
+description: "dsh Web 客户端设置中按作用域分组的只读插件清单标签页:Agent 预设组合在前,全局平面收在折叠分组里,搜索跨两组。"
 kind: "package-reference"
 ---
 
@@ -47,7 +47,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-该标签页首次选择时读取宿主清单,插件激活期间不执行 Remote 调用。提供[插件管理器](../../boot/plugin-manager/README.zh.md)的宿主还支持组合包安装、删除及可唯一定位的全局条目开关。操作后刷新实际状态,并显示失败、覆盖与待重启结果;清单刷新成功时仍保留最近一次操作错误;预设组合保持只读。
+该标签页是宿主拥有快照的只读投影;插件激活期间不执行任何 Remote 读取,首次选择时才取快照。
 
 ### 注册
 
@@ -89,8 +89,8 @@ kind: "package-reference"
 
 这些限制定义清单视图的新鲜度与触达范围;它们是当前包约束。
 
-- **清单刷新**:标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 或完成管理操作则会取得新快照。
-- **预设组合保持只读**:全局控件需要当前 profile 管理器;Desktop 保留 shell 持有的包管理控件。
+- **每次 Settings 挂载或重试只读取一份快照**:标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。
+- **两个平面都只读**:标签页展示全局与预设的启停状态但都不修改;写回自定义预设组合文件的启停控件是刻意留作后续的工作。
 
 <a id="dev-note"></a>
 ### 开发备注
@@ -102,4 +102,4 @@ kind: "package-reference"
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。本包呈现宿主状态,并将修改转发给插件管理器。
+**运行时不变式:** 不发布伴生入口。本包只持有一个只读 Settings contribution。

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

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory",
-  "description": "Cordis Loader inventory and current-profile management in Web Plugins settings",
+  "description": "Read-only Cordis Loader inventory tab in Web Plugins settings",
   "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"

+ 0 - 61
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css

@@ -429,64 +429,3 @@
     grid-template-columns: minmax(0, 1fr);
   }
 }
-
-.manager {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-  font-size: 13px;
-}
-
-.manager h3,
-.manager p {
-  margin: 0;
-}
-
-.install,
-.bundleList li {
-  display: flex;
-  flex-wrap: wrap;
-  align-items: center;
-  gap: 8px;
-}
-
-.install > input {
-  flex: 1;
-  min-width: 180px;
-}
-
-.manager input:not([type='checkbox']),
-.manager button {
-  border: 0.5px solid var(--dsw-alias-border-l3);
-  border-radius: 6px;
-  padding: 6px 10px;
-  background: var(--dsw-alias-bg-layer-1);
-  color: var(--dsw-alias-label-primary);
-  font: inherit;
-}
-
-.manager button:disabled {
-  opacity: 0.5;
-}
-
-.bundleList {
-  list-style: none;
-  margin: 0;
-  padding: 0;
-}
-
-.bundleList li {
-  padding: 8px 0;
-  border-bottom: 0.5px solid var(--dsw-alias-border-l3);
-}
-
-.bundleList li > span {
-  flex: 1;
-  min-width: 200px;
-  overflow-wrap: anywhere;
-}
-
-.manager code {
-  display: block;
-  overflow-wrap: anywhere;
-}

+ 2 - 17
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx

@@ -10,7 +10,6 @@ import {
 import type { StateDotState, TagTone } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { PluginInventoryLocaleKey } from './locales.ts'
-import { BundleManager, usePluginManagement, type PluginManagement } from './management.tsx'
 import css from './PluginInventorySettingsTab.module.css'
 
 type PluginInventoryEntry = PluginInventorySnapshot['entries'][number]
@@ -19,8 +18,6 @@ type AgentPresetRow = AgentPresetGroup['rows'][number]
 
 /** Registration-side Remote face used by the section. */
 export interface PluginInventorySettingsTabInjected {
-  /** Persistent controls, available only on profile-backed Hosts. */
-  management?: PluginManagement
   /** Read a current Host inventory snapshot. */
   list: () => Promise<PluginInventorySnapshot>
   /**
@@ -200,7 +197,7 @@ function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly lab
 }
 
 /** Render the read-only plugin inventory: agent presets first, then the global plane. */
-export function PluginInventorySettingsTab({ list, presetName, management, t }: PluginInventorySettingsTabProps): ReactNode {
+export function PluginInventorySettingsTab({ list, presetName, t }: PluginInventorySettingsTabProps): ReactNode {
   const sectionId = useId()
   const [request, setRequest] = useState(0)
   const [query, setQuery] = useState('')
@@ -210,8 +207,6 @@ export function PluginInventorySettingsTab({ list, presetName, management, t }:
   const [presetOpen, setPresetOpen] = useState<boolean | null>(null)
   const [globalOpen, setGlobalOpen] = useState<boolean | null>(null)
   const [state, setState] = useState<ViewState>({ status: 'loading' })
-  const manageable = state.status === 'ready' && state.snapshot.managementAvailable === true
-  const managerState = usePluginManagement(management, manageable, request)
 
   useEffect(() => {
     let current = true
@@ -220,7 +215,7 @@ export function PluginInventorySettingsTab({ list, presetName, management, t }:
       () => { if (current) setState({ status: 'error' }) },
     )
     return () => { current = false }
-  }, [list, request, managerState.refresh])
+  }, [list, request])
 
   const normalizedQuery = query.trim().toLocaleLowerCase()
   const searching = normalizedQuery.length > 0
@@ -350,15 +345,6 @@ export function PluginInventorySettingsTab({ list, presetName, management, t }:
           </>
         )}
       >
-        {management === undefined || !manageable ? null : (() => {
-          const control = managerState.plugins.find(row => row.entryId === entry.entryId)
-          return control?.patchId === undefined
-            ? <p>{control?.readOnlyReason}</p>
-            : <label><input type="checkbox" role="switch" checked={entry.enabled}
-              disabled={managerState.busy} aria-label={t('pluginSwitch', { name: title })}
-              onChange={(event) => { void managerState.run(() => management.setPluginEnabled(entry.entryId, event.target.checked)) }} />
-            {t(entry.enabled ? 'enabledTag' : 'disabledTag')}</label>
-        })()}
         <CardFacts
           moduleName={entry.moduleName}
           moduleLabel={t('moduleLabel')}
@@ -390,7 +376,6 @@ export function PluginInventorySettingsTab({ list, presetName, management, t }:
 
   return (
     <div className={css.section} aria-busy={state.status === 'loading'}>
-      {management !== undefined && manageable ? <BundleManager manager={management} state={managerState} t={t} /> : null}
       {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
       {state.status === 'error' ? (
         <div className={css.failure}>

+ 3 - 17
packages/client/ui-settings-plugin-inventory/src/client/index.ts

@@ -1,4 +1,4 @@
-/** Host plugin inventory and current-profile management in Web Settings. */
+/** Read-only Host plugin inventory registered into Web Settings. */
 
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
@@ -10,7 +10,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
 // Inline-safe shared fold: shipped ids map to dictionary keys in one home.
 import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display'
 import { PluginInventorySettingsTab, type PluginInventorySettingsTabInjected } from './PluginInventorySettingsTab.tsx'
-import type { PluginManagement } from './management.tsx'
 import { en, zh, type PluginInventoryLocaleKey } from './locales.ts'
 
 export type { PluginInventorySettingsTabInjected, PluginInventorySettingsTabProps } from './PluginInventorySettingsTab.tsx'
@@ -27,7 +26,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 export const NS = 'settings.pluginInventory'
 
 /** Services required by the Settings registration and generated Remote face. */
-export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory', 'remote.pluginManager']
+export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
 
 /** Contribute the lazy inventory tab to the Plugins settings section. */
 export function apply(ctx: ClientContext): void {
@@ -46,20 +45,7 @@ export function apply(ctx: ClientContext): void {
   const agentPresetCopy = ctx.locale.bind('settings.agentPreset')
   const presetName: PluginInventorySettingsTabInjected['presetName'] = preset =>
     presetDisplayText(preset, agentPresetCopy).name
-  const unwrap = async <T>(response: Promise<{ ok: true; value: T } | { ok: false; error: Error }>): Promise<T> => {
-    const result = await response
-    if (!result.ok) throw result.error
-    return result.value
-  }
-  const management: PluginManagement = {
-    listPlugins: () => unwrap(ctx.remote.pluginManager.listPlugins()),
-    listBundles: () => unwrap(ctx.remote.pluginManager.listBundles()),
-    setPluginEnabled: (id, enabled) => unwrap(ctx.remote.pluginManager.setPluginEnabled(id, enabled)),
-    setBundleEnabled: (name, enabled) => unwrap(ctx.remote.pluginManager.setBundleEnabled(name, enabled)),
-    installBundle: (spec, options) => unwrap(ctx.remote.pluginManager.installBundle(spec, options)),
-    removeBundle: name => unwrap(ctx.remote.pluginManager.removeBundle(name)),
-  }
-  const injected = (): PluginInventorySettingsTabInjected => ({ list, presetName, management })
+  const injected = (): PluginInventorySettingsTabInjected => ({ list, presetName })
 
   ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
     name: 'settings.plugins.tab',

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

@@ -2,10 +2,6 @@
 
 /** Simplified Chinese dictionary and key source of truth. */
 export const zh = {
-  bundles: '组合包', packageSpec: 'npm 包名称或本地路径', install: '安装', remove: '删除',
-  enableAfterInstall: '安装后启用', applying: '正在应用更改…',
-  operationFailed: '操作失败', applied: '已生效', 'restart-required': '重启后生效', overridden: '被其他配置覆盖',
-  pluginSwitch: '启停插件 {name}', bundleSwitch: '启停组合包 {name}',
   tab: '插件列表',
   loading: '正在读取插件…',
   error: '暂时无法读取插件。',
@@ -49,10 +45,6 @@ export type PluginInventoryLocaleKey = keyof typeof zh
 
 /** English dictionary checked against the Chinese key set. */
 export const en = {
-  bundles: 'Bundles', packageSpec: 'npm package name or local path', install: 'Install', remove: 'Remove',
-  enableAfterInstall: 'Enable after installation', applying: 'Applying changes…',
-  operationFailed: 'Failed', applied: 'Applied', 'restart-required': 'Restart required', overridden: 'Overridden by another configuration',
-  pluginSwitch: 'Toggle plugin {name}', bundleSwitch: 'Toggle bundle {name}',
   tab: 'Plugin list',
   loading: 'Reading plugins…',
   error: 'Plugins are temporarily unavailable.',

+ 0 - 93
packages/client/ui-settings-plugin-inventory/src/client/management.tsx

@@ -1,93 +0,0 @@
-/** Minimal profile package controls over the shared manager Remote. */
-import { useEffect, useRef, useState } from 'react'
-import type { BundleInfo, ChangeResult, PluginEntryId, PluginInfo } from '@deepseek-ai/dsh-api-remotes/client'
-import type { PluginInventorySettingsTabProps } from './PluginInventorySettingsTab.tsx'
-import css from './PluginInventorySettingsTab.module.css'
-
-/** Operations consumed by the inventory page; all mutations execute on the Host. */
-export interface PluginManagement {
-  listPlugins(): Promise<PluginInfo[]>
-  listBundles(): Promise<BundleInfo[]>
-  setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>
-  setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
-  installBundle(spec: string, options: { enabled: boolean }): Promise<ChangeResult>
-  removeBundle(name: string): Promise<ChangeResult>
-}
-
-/** Load management state only when the Host inventory advertises the service.
- * @param manager Optional Remote operations.
- * @param available Current Host availability.
- * @param revision Inventory refresh revision.
- * @returns Current state and a serialized mutation action.
- */
-export function usePluginManagement(manager: PluginManagement | undefined, available: boolean, revision: number) {
-  const [plugins, setPlugins] = useState<PluginInfo[]>([])
-  const [bundles, setBundles] = useState<BundleInfo[]>([])
-  const [busy, setBusy] = useState(false)
-  const submitting = useRef(false)
-  const [result, setResult] = useState<ChangeResult>()
-  const [error, setError] = useState<string>()
-  const [readError, setReadError] = useState<string>()
-  const [refresh, setRefresh] = useState(0)
-  useEffect(() => {
-    if (!available || manager === undefined) return
-    let current = true
-    void Promise.all([manager.listPlugins(), manager.listBundles()]).then(([plugins, bundles]) => {
-      if (!current) return
-      setPlugins(plugins)
-      setBundles(bundles)
-      setReadError(undefined)
-    }, (error: unknown) => { if (current) setReadError(error instanceof Error ? error.message : String(error)) })
-    return () => { current = false }
-  }, [manager, available, revision, refresh])
-  const run = async (operation: () => Promise<ChangeResult>): Promise<void> => {
-    if (submitting.current) return
-    submitting.current = true
-    setBusy(true)
-    setError(undefined)
-    try { setResult(await operation()) }
-    catch (error) { setError(error instanceof Error ? error.message : String(error)) }
-    finally { submitting.current = false; setBusy(false); setRefresh(value => value + 1) }
-  }
-  return { plugins, bundles, busy, result, error: error ?? readError, run, refresh }
-}
-
-/** Minimal bundle installation and enablement form. */
-export function BundleManager({ manager, state, t }: {
-  manager: PluginManagement
-  state: ReturnType<typeof usePluginManagement>
-  t: PluginInventorySettingsTabProps['t']
-}) {
-  const [spec, setSpec] = useState('')
-  const [enabled, setEnabled] = useState(true)
-  return <section className={css.manager} aria-label={t('bundles')} aria-busy={state.busy}>
-    <h3>{t('bundles')}</h3>
-    <form className={css.install} onSubmit={(event) => {
-      event.preventDefault()
-      void state.run(() => manager.installBundle(spec, { enabled }))
-    }}>
-      <input aria-label={t('packageSpec')} placeholder={t('packageSpec')} value={spec}
-        disabled={state.busy} onChange={(event) => { setSpec(event.target.value) }} />
-      <button type="submit" disabled={state.busy || spec.trim() === ''}>{t('install')}</button>
-      <label><input type="checkbox" checked={enabled} disabled={state.busy}
-        onChange={(event) => { setEnabled(event.target.checked) }} />{t('enableAfterInstall')}</label>
-    </form>
-    {state.busy ? <p role="status">{t('applying')}</p> : null}
-    {state.error === undefined ? null : <p role="alert">{state.error}</p>}
-    {state.result === undefined ? null : <p role={state.result.application === 'failed' ? 'alert' : 'status'}>
-      {t(state.result.application === 'failed' ? 'operationFailed' : state.result.application)} {state.result.message}
-      {state.result.packageResult === undefined ? null : <code>{state.result.packageResult.logPath}</code>}
-    </p>}
-    <ul className={css.bundleList}>{state.bundles.map(bundle => <li key={bundle.name}>
-      <span><strong>{bundle.name}</strong> {bundle.version}</span>
-      <label><input type="checkbox" role="switch" aria-label={t('bundleSwitch', { name: bundle.name })}
-        checked={bundle.enabled} disabled={state.busy || bundle.error !== undefined || bundle.readOnlyReason !== undefined}
-        onChange={(event) => { void state.run(() => manager.setBundleEnabled(bundle.name, event.target.checked)) }} />
-      {t(bundle.enabled ? 'enabledTag' : 'disabledTag')}</label>
-      <button type="button" disabled={state.busy || !bundle.removable}
-        onClick={() => { void state.run(() => manager.removeBundle(bundle.name)) }}>{t('remove')}</button>
-      {bundle.error === undefined ? null : <p role="alert">{bundle.error}</p>}
-      {bundle.readOnlyReason === undefined ? null : <p>{bundle.readOnlyReason}</p>}
-    </li>)}</ul>
-  </section>
-}

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

@@ -8,7 +8,6 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply, inject, NS } from '../src/client/index.ts'
 import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
-import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { PluginInventorySettingsTabInjected } from '../src/client/PluginInventorySettingsTab.tsx'
 import { apply as hostApply } from '../src/index.ts'
 
@@ -34,16 +33,7 @@ async function bench() {
   const list = vi.fn<() => Promise<ListResult>>()
     .mockResolvedValue({ ok: true, value: EMPTY })
   ctx.provide('remote.pluginInventory', { list })
-  const listBundles = vi.fn().mockResolvedValue({ ok: true, value: [] })
-  const managed = {
-    listBundles, listPlugins: vi.fn().mockResolvedValue({ ok: true, value: [] }),
-    setPluginEnabled: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', message: 'plugin' } }),
-    setBundleEnabled: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', message: 'bundle' } }),
-    installBundle: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'restart-required', message: 'install' } }),
-    removeBundle: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', message: 'remove' } }),
-  }
-  ctx.provide('remote.pluginManager', managed)
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, listBundles, managed }
+  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list }
 }
 
 function declare(slots: SlotRegistry): () => void {
@@ -59,7 +49,7 @@ describe('ui-settings-plugin-inventory browser plugin', () => {
   })
 
   it('declares only the services used by the Settings Remote contribution', () => {
-    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory', 'remote.pluginManager'])
+    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
   })
 
   it('registers a localized tab without reading the Remote eagerly', async () => {
@@ -77,20 +67,6 @@ describe('ui-settings-plugin-inventory browser plugin', () => {
     const injected = (entry.inject as unknown as () => PluginInventorySettingsTabInjected)()
     await expect(injected.list()).resolves.toEqual(EMPTY)
     expect(b.list).toHaveBeenCalledOnce()
-    await expect(injected.management?.listBundles()).resolves.toEqual([])
-    expect(b.listBundles).toHaveBeenCalledOnce()
-    const manager = injected.management!
-    await expect(manager.listPlugins()).resolves.toEqual([])
-    await expect(manager.setPluginEnabled('include:plugin' as PluginEntryId, false)).resolves.toMatchObject({ message: 'plugin' })
-    expect(b.managed.setPluginEnabled).toHaveBeenCalledWith('include:plugin', false)
-    await expect(manager.setBundleEnabled('extra', false)).resolves.toMatchObject({ message: 'bundle' })
-    expect(b.managed.setBundleEnabled).toHaveBeenCalledWith('extra', false)
-    await expect(manager.installBundle('extra@1', { enabled: false })).resolves.toMatchObject({ application: 'restart-required' })
-    expect(b.managed.installBundle).toHaveBeenCalledWith('extra@1', { enabled: false })
-    await expect(manager.removeBundle('extra')).resolves.toMatchObject({ message: 'remove' })
-    expect(b.managed.removeBundle).toHaveBeenCalledWith('extra')
-    b.listBundles.mockResolvedValueOnce({ ok: false, error: new Error('manager unavailable') })
-    await expect(manager.listBundles()).rejects.toThrow('manager unavailable')
     b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } })
     await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable')
 

+ 3 - 120
packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx

@@ -1,5 +1,5 @@
 // @vitest-environment jsdom
-import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
 import type {
@@ -7,7 +7,6 @@ import type {
   PluginInventorySettingsTabProps,
 } from '../src/client/PluginInventorySettingsTab.tsx'
 import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
-import { BundleManager, usePluginManagement, type PluginManagement } from '../src/client/management.tsx'
 
 afterEach(cleanup)
 
@@ -34,7 +33,7 @@ const SNAPSHOT = {
   entries: [
     { entryId: 'telemetry', moduleName: '@fixture/telemetry', enabled: true, fiberPhase: 'failed' },
     { entryId: 'timer', moduleName: 'cordis:timer', enabled: true, fiberPhase: 'active' },
-    { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/dsh-hmr', enabled: true, fiberPhase: 'active' },
+    { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
     { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null },
     { entryId: 'bash-host', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: false, fiberPhase: null },
     { entryId: 'fs-host', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: false, fiberPhase: null },
@@ -325,7 +324,7 @@ describe('PluginInventorySettingsTab', () => {
   it('renders a rosterless deployment as one global list, folded until opened', async () => {
     const view = await renderReady({
       entries: [
-        { entryId: 'hmr', moduleName: '@deepseek-ai/dsh-hmr', enabled: true, fiberPhase: 'active' },
+        { entryId: 'hmr', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
         { entryId: 'off', moduleName: '@fixture/off', enabled: false, fiberPhase: null },
       ],
     } as unknown as Snapshot)
@@ -394,119 +393,3 @@ describe('PluginInventorySettingsTab', () => {
     await act(async () => { deferredFailure.reject(new Error('late failure')) })
   })
 })
-
-describe('persistent profile management', () => {
-  it('runs bundle and plugin controls through the same manager and shows failed results', async () => {
-    let enabled = true
-    let selected = true
-    let installed = true
-    const list: PluginInventorySettingsTabInjected['list'] = async () => ({
-      managementAvailable: true,
-      entries: [{ entryId: 'managed' as Snapshot['entries'][number]['entryId'], moduleName: '@fixture/managed', enabled, fiberPhase: enabled ? 'active' : null }],
-    })
-    const management = {
-      listPlugins: async () => [{ ...(await list()).entries[0]!, patchId: 'managed' }],
-      listBundles: async () => installed ? [{ name: 'extra', version: '1', enabled: selected, removable: true }] : [],
-      setPluginEnabled: vi.fn(async (_id: Snapshot['entries'][number]['entryId'], next: boolean) => {
-        enabled = next
-        return { changed: true, application: 'applied' as const, message: 'Plugin changed.' }
-      }),
-      setBundleEnabled: vi.fn(async (_name: string, next: boolean) => {
-        selected = next
-        return { changed: true, application: 'applied' as const, message: 'Bundle changed.' }
-      }),
-      installBundle: vi.fn(async () => ({ changed: false, application: 'failed' as const, message: 'Registry unavailable.' })),
-      removeBundle: vi.fn(async () => {
-        installed = false
-        return { changed: true, application: 'applied' as const, message: 'Bundle removed.' }
-      }),
-    } satisfies NonNullable<PluginInventorySettingsTabInjected['management']>
-    render(<PluginInventorySettingsTab {...props(list)} management={management} />)
-    fireEvent.click(await screen.findByRole('switch', { name: 'Toggle bundle extra' }))
-    await waitFor(() => { expect(management.setBundleEnabled).toHaveBeenCalledWith('extra', false) })
-    await waitFor(() => { expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle bundle extra' }).checked).toBe(false) })
-    fireEvent.click(screen.getByRole('button', { name: /managed, managed/ }))
-    fireEvent.click(await screen.findByRole('switch', { name: 'Toggle plugin managed' }))
-    await waitFor(() => { expect(management.setPluginEnabled).toHaveBeenCalledWith('managed', false) })
-    await waitFor(() => { expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle plugin managed' }).checked).toBe(false) })
-    fireEvent.change(screen.getByRole('textbox', { name: 'npm package name or local path' }), { target: { value: 'new-bundle' } })
-    fireEvent.click(screen.getByRole('checkbox', { name: 'Enable after installation' }))
-    fireEvent.click(screen.getByRole('button', { name: 'Install' }))
-    await waitFor(() => { expect(management.installBundle).toHaveBeenCalledWith('new-bundle', { enabled: false }) })
-    expect((await screen.findByRole('alert')).textContent).toContain('Registry unavailable.')
-    fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
-    await waitFor(() => { expect(management.removeBundle).toHaveBeenCalledWith('extra') })
-    await waitFor(() => { expect(screen.queryByRole('switch', { name: 'Toggle bundle extra' })).toBeNull() })
-  })
-})
-
-function managementFixture() {
-  const applied = { changed: true, application: 'applied' as const, message: 'saved' }
-  return {
-    listPlugins: vi.fn<PluginManagement['listPlugins']>(async () => []),
-    listBundles: vi.fn<PluginManagement['listBundles']>(async () => []),
-    setPluginEnabled: vi.fn(async () => applied), setBundleEnabled: vi.fn(async () => applied),
-    installBundle: vi.fn(async () => applied), removeBundle: vi.fn(async () => applied),
-  }
-}
-
-it('ignores inventory completion after unmount and reports current read failures', async () => {
-  for (const failure of [undefined, new Error('late failure')]) {
-    const read = Promise.withResolvers<Awaited<ReturnType<PluginManagement['listBundles']>>>()
-    const manager = managementFixture()
-    vi.mocked(manager.listBundles).mockReturnValue(read.promise)
-    const hook = renderHook(() => usePluginManagement(manager, true, 0))
-    hook.unmount()
-    await act(async () => {
-      if (failure === undefined) read.resolve([])
-      else read.reject(failure)
-      await read.promise.catch(() => {})
-    })
-  }
-  for (const failure of [new Error('inventory unavailable'), 'inventory rejected']) {
-    const manager = managementFixture()
-    vi.mocked(manager.listBundles).mockRejectedValue(failure)
-    const hook = renderHook(() => usePluginManagement(manager, true, 0))
-    await waitFor(() => { expect(hook.result.current.error).toBe(failure instanceof Error ? failure.message : failure) })
-    hook.unmount()
-  }
-})
-
-it('blocks duplicate submissions and refreshes after a rejected management request', async () => {
-  const manager = managementFixture()
-  const hook = renderHook(() => usePluginManagement(manager, true, 0))
-  const pending = Promise.withResolvers<Awaited<ReturnType<PluginManagement['removeBundle']>>>()
-  const operation = vi.fn(() => pending.promise)
-  await act(async () => {
-    const first = hook.result.current.run(operation)
-    await hook.result.current.run(operation)
-    pending.resolve({ changed: true, application: 'applied', message: 'removed' })
-    await first
-  })
-  expect(operation).toHaveBeenCalledOnce()
-  expect(hook.result.current.result?.message).toBe('removed')
-  for (const failure of [new Error('request unavailable'), 'request rejected']) {
-    await act(async () => { await hook.result.current.run(async () => { throw failure }) })
-    expect(hook.result.current.error).toBe(failure instanceof Error ? failure.message : failure)
-    expect(hook.result.current.busy).toBe(false)
-  }
-})
-
-it('shows package diagnostics, read errors and protected or missing bundles', () => {
-  const manager = managementFixture()
-  const state: ReturnType<typeof usePluginManagement> = {
-    plugins: [], bundles: [
-      { name: 'missing', enabled: false, removable: true, error: 'package files missing' },
-      { name: 'core', enabled: true, removable: false, readOnlyReason: 'required for management' },
-    ], busy: false, refresh: 0, error: 'inventory unavailable', run: async () => {},
-    result: { changed: true, application: 'failed', message: 'installation failed',
-      packageResult: { exitCode: 1, output: 'failed', truncated: false, logPath: '/profile/operation/pnpm.log' } },
-  }
-  render(<BundleManager manager={manager} state={state} t={t} />)
-  expect(screen.getByText('inventory unavailable')).toBeDefined()
-  expect(screen.getByText('/profile/operation/pnpm.log')).toBeDefined()
-  expect(screen.getByText('package files missing')).toBeDefined()
-  expect(screen.getByText('required for management')).toBeDefined()
-  expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle bundle missing' }).disabled).toBe(true)
-  expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle bundle core' }).disabled).toBe(true)
-})

+ 0 - 4
packages/extensions/cordis-client-runner/src/client/api-catalog.ts

@@ -916,10 +916,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'ThemeTokens',
     declaration: 'export type ThemeTokens = Record<string, string>;',
   },
-  {
-    name: 'Translate',
-    declaration: 'export type Translate<K extends string = string> = (key: K, params?: Record<string, unknown>) => string;',
-  },
   {
     name: 'TranslateNS',
     declaration: 'export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>;',

+ 4 - 1
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -1425,6 +1425,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted',
     occupants: [
       'client-ui-conversation ConversationPanel key \'conversation\'',
+      'client-ui-plugin-manager PluginManagerPage',
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'main\', () => ctx.slots.register(\n      { name: \'main\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
@@ -2260,7 +2261,9 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     hookContext: '',
     slotInject: '',
     declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted',
-    occupants: [],
+    occupants: [
+      'client-ui-plugin-manager PluginsPanelIcon',
+    ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.panellist\', () => ctx.slots.register(\n      { name: \'sidebar.panellist\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:32',

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

@@ -1448,7 +1448,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         signature: '@Remote listBundles(): Promise<BundleInfo[]>',
         description: 'Read installed bundles and bundles supplied by this dsh installation.',
         parameters: [],
-        returns: 'Package versions, activation selections and removal availability.',
+        returns: 'Package versions, titles, rows, activation selections and removal availability.',
+      },
+      {
+        signature: '@Remote async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection>',
+        description: 'Read what a spec names before installing it.',
+        parameters: [{ name: 'spec', description: 'One package spec: a registry name, an absolute path, a git address, or a tarball.' }, { name: 'signal', description: 'Ends a registry lookup early.' }],
+        returns: 'The package the spec names, or why it is refused.',
       },
       {
         signature: '@Remote setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>',
@@ -1464,10 +1470,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: '@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>',
-        description: 'Install a package using the same pnpm implementation as dsh plugin.',
-        parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle; defaults to true.' }],
+        description: 'Install a package using the same pnpm implementation as dsh plugin. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.',
+        parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle (defaults to true) and the request id a cancellation names.' }],
         returns: 'Package-manager diagnostics and observed activation outcome.',
       },
+      {
+        signature: '@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation>',
+        description: 'Stop an installation this manager owns and wait until its files are back.',
+        parameters: [{ name: 'requestId', description: 'The id the installation was started with.' }],
+        returns: '`cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being applied, `not-running` for any other id.',
+      },
       {
         signature: '@Remote removeBundle(name: string): Promise<ChangeResult>',
         description: 'Unload and remove a profile-owned bundle dependency through dsh plugin\'s pnpm path.',
@@ -3615,6 +3627,38 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'The selectable process catalog changed. Payload-free by design: consumers subscribe first, then re-read the complete catalog.',
     parameters: [],
   },
+  {
+    name: 'plugin-manager/changed',
+    mode: 'emit',
+    signature: '\'plugin-manager/changed\'(change: PluginChange): void',
+    summary: 'The profile\'s plugins, bundles, or composition changed: a manager operation completed, or a patch generation was applied.',
+    description: 'The profile\'s plugins, bundles, or composition changed: a manager operation completed, or a patch generation was applied.',
+    parameters: [{ name: 'change', description: 'what changed.' }],
+  },
+  {
+    name: 'plugin-manager/install-log',
+    mode: 'emit',
+    signature: '\'plugin-manager/install-log\'(chunk: PluginInstallLogChunk): void',
+    summary: 'One chunk of a pnpm run\'s output, streamed as the run produces it.',
+    description: 'One chunk of a pnpm run\'s output, streamed as the run produces it.',
+    parameters: [{ name: 'chunk', description: 'the chunk and the run it belongs to.' }],
+  },
+  {
+    name: 'plugin-manager/install-state',
+    mode: 'emit',
+    signature: '\'plugin-manager/install-state\'(progress: PluginInstallProgress): void',
+    summary: 'An installation moved between its Host phases.',
+    description: 'An installation moved between its Host phases.',
+    parameters: [{ name: 'progress', description: 'the installation\'s request id and phase.' }],
+  },
+  {
+    name: 'profile/reconciled',
+    mode: 'emit',
+    signature: '\'profile/reconciled\'(patches: readonly PatchOptions[]): void',
+    summary: 'A complete patch generation was applied to the root Include and the Loader settled, whether or not every row activated.',
+    description: 'A complete patch generation was applied to the root Include and the Loader settled, whether or not every row activated.',
+    parameters: [{ name: 'patches', description: 'the ordered patch list that was applied.' }],
+  },
   {
     name: 'session-telemetry/record',
     mode: 'waterfall',
@@ -4093,11 +4137,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'BundleInfo',
-    declaration: 'export interface BundleInfo {\n    name: string;\n    version?: string;\n    enabled: boolean;\n    removable: boolean;\n    readOnlyReason?: string;\n    error?: string;\n}',
+    declaration: 'export interface BundleInfo {\n    name: string;\n    version?: string;\n    title?: string;\n    description?: string;\n    enabled: boolean;\n    installed: boolean;\n    removable: boolean;\n    readOnlyReason?: string;\n    error?: string;\n    rows: BundleRowInfo[];\n    overrides: string[];\n}',
+  },
+  {
+    name: 'BundleRowInfo',
+    declaration: 'export interface BundleRowInfo {\n    rowId: string;\n    moduleName: string;\n    entryId?: PluginEntryId;\n}',
   },
   {
     name: 'ChangeResult',
-    declaration: 'export interface ChangeResult {\n    changed: boolean;\n    application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\';\n    message: string;\n    packageResult?: PackageResult;\n}',
+    declaration: 'export interface ChangeResult {\n    changed: boolean;\n    application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\' | \'cancelled\';\n    message: string;\n    packageResult?: PackageResult;\n    bundle?: string;\n}',
   },
   {
     name: 'ClientArtifactBaseline',
@@ -4661,7 +4709,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'InstallBundleOptions',
-    declaration: 'export interface InstallBundleOptions {\n    enabled?: boolean;\n}',
+    declaration: 'export interface InstallBundleOptions {\n    enabled?: boolean;\n    requestId?: PluginInstallRequestId;\n}',
+  },
+  {
+    name: 'InstallSpecKind',
+    declaration: 'export type InstallSpecKind = \'registry\' | \'path\' | \'git\' | \'tarball\';',
   },
   {
     name: 'InvariantFailure',
@@ -5025,12 +5077,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PackageResult',
-    declaration: 'export interface PackageResult {\n    exitCode: number;\n    output: string;\n    truncated: boolean;\n    logPath: string;\n}',
+    declaration: 'export interface PackageResult {\n    exitCode: number;\n    output: string;\n    truncated: boolean;\n    logPath: string;\n    kind?: PluginInstallFailureKind;\n}',
   },
   {
     name: 'PermissionCatalog',
     declaration: 'export interface PermissionCatalog {\n    options: PresetOption[];\n}',
   },
+  {
+    name: 'PluginChange',
+    declaration: 'export interface PluginChange {\n    readonly reason: \'plugin\' | \'bundle\' | \'install\' | \'remove\' | \'reload\';\n}',
+  },
   {
     name: 'PluginEntryId',
     declaration: 'export type PluginEntryId = Branded<\'PluginEntryId\'>;',
@@ -5043,10 +5099,38 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PluginInfo',
     declaration: 'export interface PluginInfo extends PluginInventoryEntry {\n    patchId?: string;\n    readOnlyReason?: string;\n}',
   },
+  {
+    name: 'PluginInspectProblem',
+    declaration: 'export type PluginInspectProblem = \'invalid-spec\' | \'already-installed\' | \'not-found\' | \'not-a-package\' | \'not-a-bundle\' | \'network\' | \'unknown\';',
+  },
+  {
+    name: 'PluginInstallCancellation',
+    declaration: 'export interface PluginInstallCancellation {\n    readonly status: \'cancelled\' | \'too-late\' | \'not-running\';\n}',
+  },
+  {
+    name: 'PluginInstallFailureKind',
+    declaration: 'export type PluginInstallFailureKind = \'pnpm-missing\' | \'timeout\' | \'not-found\' | \'no-matching-version\' | \'network\' | \'disk-full\' | \'permission\' | \'build-blocked\' | \'integrity\' | \'unknown\';',
+  },
+  {
+    name: 'PluginInstallLogChunk',
+    declaration: 'export interface PluginInstallLogChunk {\n    readonly requestId?: PluginInstallRequestId;\n    readonly jobId: string;\n    readonly argv: readonly string[];\n    readonly cwd: string;\n    readonly stream: \'stdout\' | \'stderr\';\n    readonly text: string;\n    readonly exitCode?: number | null;\n}',
+  },
+  {
+    name: 'PluginInstallProgress',
+    declaration: 'export interface PluginInstallProgress {\n    readonly requestId: PluginInstallRequestId;\n    readonly phase: \'installing\' | \'cancelling\' | \'applying\';\n}',
+  },
+  {
+    name: 'PluginInstallRequestId',
+    declaration: 'export type PluginInstallRequestId = Branded<\'PluginInstallRequestId\'>;',
+  },
   {
     name: 'PluginInventoryEntry',
     declaration: 'export interface PluginInventoryEntry {\n    readonly entryId: PluginEntryId;\n    readonly moduleName: string;\n    readonly enabled: boolean;\n    readonly fiberPhase: PluginFiberPhase;\n}',
   },
+  {
+    name: 'PluginSpecInspection',
+    declaration: 'export type PluginSpecInspection = {\n    readonly status: \'accepted\';\n    readonly kind: InstallSpecKind;\n    readonly name?: string;\n    readonly version?: string;\n    readonly description?: string;\n    readonly title?: string;\n    readonly bundle: boolean | null;\n} | {\n    readonly status: \'refused\';\n    readonly problem: PluginInspectProblem;\n    readonly reason: string;\n};',
+  },
   {
     name: 'PostToolDecision',
     declaration: 'export type PostToolDecision = {\n    kind: \'accept\';\n    content?: ContentBlock[];\n    value?: never;\n    additionalContexts?: UserMessage[];\n} | {\n    kind: \'accept\';\n    value: JsonValue;\n    content?: never;\n    additionalContexts?: UserMessage[];\n} | {\n    kind: \'block\';\n    feedback: ContentBlock[];\n    additionalContexts?: UserMessage[];\n};',

+ 6 - 2
packages/host/plugin-inventory/src/index.ts

@@ -17,8 +17,12 @@ import type {
 
 export type * from './types.ts'
 
-/** Brand an existing Loader-tree entry id at the owning boundary. */
-function pluginEntryId(value: string): PluginEntryId {
+/**
+ * Brand an existing Loader-tree entry id at the owning boundary.
+ * @param value - the entry id as the Loader tree spells it.
+ * @returns the same id as the inventory's branded entry id.
+ */
+export function pluginEntryId(value: string): PluginEntryId {
   return value as PluginEntryId
 }
 

+ 2 - 0
packages/util/package-manifest/src/types.ts

@@ -28,6 +28,8 @@ export interface DshPackageManifest {
 export interface DshManifest {
   /** Manifest format version, independent of the npm package and Session format versions. */
   manifestVersion?: 1
+  /** Display title of the package, read without executing package code. */
+  title?: string
   /** Bundle metadata consumed by the profile launcher. */
   bundle?: DshBundleManifest
   /** Profile metadata consumed by the profile launcher. */

+ 60 - 0
pnpm-lock.yaml

@@ -1524,6 +1524,9 @@ importers:
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
         version: link:../../core/agent
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
       '@deepseek-ai/dsh-hmr':
         specifier: workspace:^
         version: link:../hmr
@@ -2114,6 +2117,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-plan':
         specifier: workspace:^
         version: link:../../client/ui-plan
+      '@deepseek-ai/dsh-client-ui-plugin-manager':
+        specifier: workspace:^
+        version: link:../../client/ui-plugin-manager
       '@deepseek-ai/dsh-client-ui-reference':
         specifier: workspace:^
         version: link:../../client/ui-reference
@@ -3578,6 +3584,60 @@ importers:
         specifier: ^18.2.0
         version: 18.3.1
 
+  packages/client/ui-plugin-manager:
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-api-remotes':
+        specifier: workspace:^
+        version: link:../../api/remotes
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
+      '@deepseek-ai/dsh-client-store':
+        specifier: workspace:^
+        version: link:../store
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../../test-support/client-runtime
+      '@deepseek-ai/dsh-client-ui-layout':
+        specifier: workspace:^
+        version: link:../ui-layout
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
+      '@deepseek-ai/dsh-client-ui-renderer':
+        specifier: workspace:^
+        version: link:../ui-renderer
+      '@deepseek-ai/dsh-client-ui-sidebar':
+        specifier: workspace:^
+        version: link:../ui-sidebar
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@deepseek-ai/dsh-plugin-manager':
+        specifier: workspace:^
+        version: link:../../boot/plugin-manager
+      '@deepseek-ai/dsh-util-crypto':
+        specifier: workspace:^
+        version: link:../../util/crypto
+      '@testing-library/react':
+        specifier: ^16.1.0
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      clsx:
+        specifier: ^2.1.1
+        version: 2.1.1
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
+
   packages/client/ui-primitives:
     devDependencies:
       '@deepseek-ai/cordis':

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

@@ -200,6 +200,8 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
  */
 export const EVENT_SCOPE_PAGE: Record<string, string> = {
   hmr: 'boot.md',
+  'plugin-manager': 'boot.md',
+  'profile': 'boot.md',
   'agent': 'core.md',
   'agent-loop': 'core.md',
   'agent-preset': 'core.md',
@@ -263,6 +265,13 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   ChangeResult: 'boot.md',
   InstallBundleOptions: 'boot.md',
   PluginEntryId: 'boot.md',
+  BundleRowInfo: 'boot.md',
+  PluginInstallCancellation: 'boot.md',
+  PluginInstallRequestId: 'boot.md',
+  PluginSpecInspection: 'boot.md',
+  PluginChange: 'boot.md',
+  PluginInstallLogChunk: 'boot.md',
+  PluginInstallProgress: 'boot.md',
   BrowserUseProviderName: 'browser-use.md',
   ComputerUseProviderName: 'computer-use.md',
   Agent: 'core.md',
@@ -720,6 +729,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
 /** Project types deliberately documented outside the subsystems catalog. */
 export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   Profile: 'resolved profile layers are owned by packages/boot/app-boot/README.md',
+  PatchOptions: 'Include patch entries are owned by vendor/include (vendored upstream)',
   McpResourceProvider: 'scoped resource provider is owned by packages/mcp/mcp-resources/README.md',
   'z.ZodType': 'Zod response validation API is owned by https://zod.dev/packages/zod',
   Socket: 'Node.js byte stream API is owned by https://nodejs.org/api/net.html#class-netsocket',

+ 2 - 0
snapshots/web/lifecycle-chrome/hero.expected.md

@@ -4,6 +4,8 @@
 - button "New session":
   - img
   - text: New Session
+- navigation "Global panels":
+  - button "Plugins"
 - text: Workspaces
 - button "Search sessions":
   - img

+ 2 - 0
snapshots/web/lifecycle-chrome/plan-active.expected.md

@@ -4,6 +4,8 @@
 - button "New session":
   - img
   - text: New Session
+- navigation "Global panels":
+  - button "Plugins"
 - text: Workspaces
 - button "Search sessions":
   - img