Forráskód Böngészése

Merge pull request #3596 from deepseek-harness/feat/plugin-mgmt-4-web

feat(web): manage global plugins from the sidebar
Yichen Jiang 5 napja
szülő
commit
2bc7697265
100 módosított fájl, 7070 hozzáadás és 331 törlés
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
  5. 31 0
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
  6. 31 0
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  8. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  9. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml
  11. 43 0
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
  12. 43 0
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md
  13. 2 2
      .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml
  14. 1 1
      .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md
  15. 1 1
      .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md
  16. 6 0
      .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml
  17. 29 0
      .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
  18. 29 0
      .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md
  19. 3 1
      apps/cli/package.json
  20. 22 0
      apps/web/tests/expected/plugin-install-approve/blocked.expected.md
  21. 13 0
      apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md
  22. 28 0
      apps/web/tests/expected/plugin-manager/live-enabled.expected.md
  23. 28 0
      apps/web/tests/expected/plugin-manager/manager.expected.md
  24. 4 0
      apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml
  25. 5 0
      apps/web/tests/fixtures/plugins/fixture-bundle/index.js
  26. 13 0
      apps/web/tests/fixtures/plugins/fixture-bundle/package.json
  27. 0 1
      apps/web/tests/fixtures/plugins/fixture-live-client/package.json
  28. 0 3
      apps/web/tests/plugin-config.e2e.ts
  29. 78 0
      apps/web/tests/plugin-install-approve.e2e.ts
  30. 84 0
      apps/web/tests/plugin-install-cancel.e2e.ts
  31. 209 0
      apps/web/tests/plugin-manager.e2e.ts
  32. 86 24
      apps/web/tests/scaffold.ts
  33. 17 5
      apps/web/tests/settings-chrome.e2e.ts
  34. 20 0
      apps/web/tests/support.ts
  35. 3 0
      apps/web/tsconfig.json
  36. 2 2
      docs/config-catalog.i18n.yaml
  37. 7 2
      docs/config-catalog.md
  38. 29 24
      docs/config-catalog.zh.md
  39. 2 2
      docs/event-producer-consumer.i18n.yaml
  40. 3 0
      docs/event-producer-consumer.md
  41. 3 0
      docs/event-producer-consumer.zh.md
  42. 2 2
      docs/module-graph.i18n.yaml
  43. 4 1
      docs/module-graph.md
  44. 4 1
      docs/module-graph.zh.md
  45. 2 2
      docs/subsystems/boot.i18n.yaml
  46. 81 4
      docs/subsystems/boot.md
  47. 81 4
      docs/subsystems/boot.zh.md
  48. 5 1
      packages/api/remotes/src/client/index.ts
  49. 4 0
      packages/api/remotes/src/remote-events.ts
  50. 6 0
      packages/api/remotes/tsconfig.host.json
  51. 1 0
      packages/boot/app-boot/src/index.ts
  52. 12 0
      packages/boot/app-boot/src/profile.ts
  53. 3 0
      packages/boot/app-boot/tests/user-patches.spec.ts
  54. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  55. 16 9
      packages/boot/plugin-manager/README.md
  56. 17 10
      packages/boot/plugin-manager/README.zh.md
  57. 4 2
      packages/boot/plugin-manager/package.json
  58. 301 59
      packages/boot/plugin-manager/src/index.ts
  59. 43 0
      packages/boot/plugin-manager/src/install-failure.ts
  60. 74 0
      packages/boot/plugin-manager/src/install-spec.ts
  61. 47 1
      packages/boot/plugin-manager/src/operations.ts
  62. 146 9
      packages/boot/plugin-manager/src/types.ts
  63. 71 0
      packages/boot/plugin-manager/tests/install-spec.spec.ts
  64. 302 118
      packages/boot/plugin-manager/tests/manager.spec.ts
  65. 23 1
      packages/boot/plugin-manager/tests/operations.spec.ts
  66. 3 0
      packages/boot/plugin-manager/tsconfig.json
  67. 8 0
      packages/bundle/web-app/cordis.patch.yml
  68. 1 0
      packages/bundle/web-app/package.json
  69. 2 2
      packages/client/README.i18n.yaml
  70. 1 0
      packages/client/README.md
  71. 1 0
      packages/client/README.zh.md
  72. 2 0
      packages/client/ui-conversation/src/client/locales.ts
  73. 6 0
      packages/client/ui-plugin-manager/README.i18n.yaml
  74. 108 0
      packages/client/ui-plugin-manager/README.md
  75. 108 0
      packages/client/ui-plugin-manager/README.zh.md
  76. 71 0
      packages/client/ui-plugin-manager/package.json
  77. 1074 0
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
  78. 799 0
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
  79. 15 0
      packages/client/ui-plugin-manager/src/client/PluginsPanelIcon.tsx
  80. 90 0
      packages/client/ui-plugin-manager/src/client/index.ts
  81. 302 0
      packages/client/ui-plugin-manager/src/client/locales.ts
  82. 715 0
      packages/client/ui-plugin-manager/src/client/manager-store.ts
  83. 73 0
      packages/client/ui-plugin-manager/src/client/presentation.ts
  84. 6 0
      packages/client/ui-plugin-manager/src/css-modules.d.ts
  85. 4 0
      packages/client/ui-plugin-manager/src/index.ts
  86. 107 0
      packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
  87. 574 0
      packages/client/ui-plugin-manager/tests/components.client.spec.tsx
  88. 721 0
      packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
  89. 48 0
      packages/client/ui-plugin-manager/tsconfig.json
  90. 3 0
      packages/client/ui-plugin-manager/tsdown.config.ts
  91. 3 3
      packages/client/ui-primitives/src/TerminalBlock.module.css
  92. 23 9
      packages/client/ui-primitives/src/TerminalBlock.tsx
  93. 6 2
      packages/client/ui-primitives/src/ansi.ts
  94. 22 0
      packages/client/ui-primitives/src/icons/index.tsx
  95. 2 1
      packages/client/ui-primitives/tests/ansi.client.spec.ts
  96. 2 2
      packages/client/ui-primitives/tests/icons.client.spec.tsx
  97. 1 0
      packages/client/ui-primitives/tests/labels.client.ts
  98. 17 3
      packages/client/ui-primitives/tests/terminal-block.client.spec.tsx
  99. 2 3
      packages/client/ui-settings-general/src/client/SettingsRoot.module.css
  100. 2 2
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.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-08-global-main-panels.md
-2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3
-2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89
+2026-09-08-global-main-panels.md: 6aa7df2dcdd28b94cc0084a0044764efb2e749e2
+2026-09-08-global-main-panels.zh.md: bbb10b2ef5784748a165ad54c907fac2a63c9b17

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md

@@ -12,7 +12,7 @@ Plugins need application-wide views that do not belong to a Session. A Session-s
 
 The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding.
 
-The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel.
+The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registered no panel entry when this landed, so an empty list has no DOM or spacing; the web bundle's plugin manager now registers the first one ([plugin management moves to the Web sidebar](2026-09-09-plugin-management-in-the-web-sidebar.md)). Selection validates the live main entry and rejects a missing key without replacing the current panel.
 
 One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。
 
-侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
+侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。本决定落地时默认组合不注册面板条目,因此空列表没有 DOM 或间距;现在 web bundle 的插件管理器注册了第一个条目([插件管理移到 Web 侧栏](2026-09-09-plugin-management-in-the-web-sidebar.zh.md))。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
 
 渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: d2a3329e7c878f25ad5e50d98fc2e3ab39722887
+2026-09-09-plugin-management-in-the-web-sidebar.zh.md: aae035b3a93aec72e84e2792334c42c54d2bca62

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

@@ -0,0 +1,31 @@
+# Agent Note: Plugin management moves to the Web sidebar
+
+Status: implemented
+
+English | [中文](2026-09-09-plugin-management-in-the-web-sidebar.zh.md)
+
+## Problem
+
+Installed packages belong to the running profile, while Settings is a modal over a Session. The management page needs room for package details and installation output. The layout's [global main panels](2026-09-08-global-main-panels.md) provide that lifetime and space.
+
+## 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 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 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.
+
+## Alternatives considered
+
+**A settings section that opens the management page.** Rejected: the dialog covers the main column, so such an entry would have to close Settings to show the page.
+
+**Configuration on the plugin's page.** Rejected for now, for the reasons in the decision; it becomes a link from the plugin's page once Settings can be opened on one section.
+
+## Consequences
+
+The web bundle's panel list is no longer empty: the **Plugins** entry sits between New Session and the workspaces. The Settings Plugins section keeps two tabs: the configuration page and the read-only Plugin list. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar, and the `plugin-config` and `settings-chrome` scenarios and goldens follow.
+
+## Testing
+
+`packages/client/ui-plugin-manager/tests` pin the two registrations under one id and the page's rendering; `packages/client/ui-settings-plugins/tests` the tab-less single contribution; the web e2e scenarios above drive the panel and the section over a scaffold.

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

@@ -0,0 +1,31 @@
+# Agent Note:插件管理移到 Web 侧栏
+
+Status: implemented
+
+[English](2026-09-09-plugin-management-in-the-web-sidebar.md) | 中文
+
+## 问题
+
+已安装的包属于运行中的 profile,而设置是覆盖在 Session 上的弹窗。管理页面需要容纳包详情与安装输出。布局的[全局主面板](2026-09-08-global-main-panels.zh.md)提供对应的生命周期与空间。
+
+## 决定
+
+**管理位于侧栏,配置保留在设置中。** `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 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。
+
+**安装结果属于一次请求。** 每次安装或重试都生成新的请求 ID,对话框按该 ID 筛选 Host 进度、日志和返回结果。取消确认可能先于原 add 响应到达,因此旧响应不能结束随后的重试。取消采用管理器的明确清理确认;本地 RPC 取消和断线均不表示 pnpm 已停止。进入配置应用阶段后关闭取消窗口。
+
+## 考虑过的替代方案
+
+**用一个设置分区打开管理页。** 否决:对话框盖住主列,这样的入口必须先关掉设置才能显示页面。
+
+**把配置放在插件页面上。** 暂不采纳,理由见决定;等设置能按分区打开后,它会变成插件页面上的一个链接。
+
+## 后果
+
+web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区保留两个标签页:配置页与只读的「插件列表」。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。
+
+## 测试
+
+`packages/client/ui-plugin-manager/tests` 钉住同一 id 下的两处注册与页面的渲染;`packages/client/ui-settings-plugins/tests` 钉住没有标签条的单一贡献;上述 web e2e 场景在脚手架上驱动面板与分区。

+ 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: eef121bd7968547ebec83bbb52c4776f84082278
-2026-09-14-current-profile-plugin-management.zh.md: 009d1df124bfee31385c25b5c1e80696556448a4
+2026-09-14-current-profile-plugin-management.md: a59a79022bce88dcee7b1629ec35c9759489e34a
+2026-09-14-current-profile-plugin-management.zh.md: 7c3e8d470fcf87fb32cda8d9f68dd2ffe4b7fe15

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

@@ -14,7 +14,7 @@ Web and agent controls need to change a running profile without creating an inde
 
 Configuration watches use Chokidar write stabilization by default. Its ordinary change handler discards a second event within 50 ms, so a write immediately after activation can leave the previous bundle running. Stabilized delivery observes the final file instead; file-driven updates pay the stability delay, while direct manager transactions do not. A regression feeds consecutive changes through Chokidar’s real normalization and verifies both applied states.
 
-Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
+Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted.
 
 This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
 
@@ -26,7 +26,7 @@ Build approvals update pnpm 11's unresolved `allowBuilds` entries under the same
 
 **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.
 
-**Restoring existing packages after failure.** Package versions, dependency trees and install-script effects cannot be reconstructed reliably from the previous manifest. Failed installation permits one removal attempt for an unambiguously identified new dependency. Existing dependencies and successful installations whose activation fails remain in place. Invalid leftover packages stay visible and removable.
+**Restoring existing packages after failure.** Package versions, dependency trees and install-script effects cannot be reconstructed reliably from the previous manifest, so existing dependencies and successful installations whose activation fails remain in place. A failed or cancelled installation restores only the manifest and lockfile text snapshotted before pnpm ran ([guided plugin installation](2026-09-15-guided-plugin-installation.md)); downloaded files stay until the next package operation prunes them.
 
 **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.
 

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

@@ -14,7 +14,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
 
 配置监听默认使用 Chokidar 写入稳定检测。普通变化处理器会丢弃 50 ms 内的第二个事件,因此激活后立即再次写入可能让之前的组合包继续运行。稳定后交付事件会观察最终文件;文件驱动的更新承担稳定等待,直接管理器事务则不需要。回归测试通过 Chokidar 的真实规范化路径交付连续变化,验证两个状态均被应用。
 
-profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
+profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件
 
 这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
 
@@ -26,7 +26,7 @@ CLI 调用继承终端和认证环境;service 调用保留子进程凭据清
 
 **由 service 启动另一个 dsh 进程。** 这会重复生命周期协调,也无法确认当前 Loader 已完成卸载后才让 pnpm 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。
 
-**失败后恢复已有包。** 无法仅凭原 manifest 可靠重建包版本、依赖树和安装脚本的副作用。安装失败只允许对可唯一识别的新增依赖尝试一次删除。已有依赖及安装成功但激活失败的包保留原处。无效的残留依赖仍可见、可删除
+**失败后恢复已有包。** 无法仅凭原 manifest 可靠重建包版本、依赖树和安装脚本的副作用,因此已有依赖及安装成功但激活失败的包保留原处。失败或被取消的安装只恢复 pnpm 运行前快照的 manifest 与 lockfile 文本([引导式插件安装](2026-09-15-guided-plugin-installation.zh.md));已下载文件保留到下一次包操作清理为止
 
 **包更新时热替换源码模块。** 配置变化可以复用已加载模块缓存,替换已安装 JavaScript 则需要新的进程。替换已有依赖会报告需要重启。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: 26ad870856ce948e1ebb736a07004e41af2a17be
+2026-09-15-guided-plugin-installation.zh.md: 36e76c8ace0ae990ede14436b65fa00752f86bbe

+ 43 - 0
.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md

@@ -0,0 +1,43 @@
+# Agent Note: Guided plugin installation
+
+Status: implemented
+
+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. 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.** `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, 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 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.
+
+**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.
+
+**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.
+
+**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.
+
+**Blocked install scripts are approved from the failed screen.** When pnpm 11 leaves a dependency's scripts undecided, the failed run reports the pending names ([the manager's approval](2026-09-14-current-profile-plugin-management.md)), and the failed screen shows them with **Allow these scripts and retry** in place of plain retry; the store runs the same checked subject again with `approvedBuilds`, and the installed screen names what was allowed. `pnpm-workspace.yaml` is not among the restored files for this reason. Without pending names the failure falls back to the manual instruction.
+
+## Alternatives considered
+
+**Validate specs on the client.** Rejected: the rules are pnpm's, the registry's, and the profile's, and the client cannot import the Host package that owns them.
+
+**Look the package up over HTTP instead of `pnpm view`.** Rejected: the registry, proxy, and auth settings that decide whether the install can succeed live in pnpm's configuration, which `pnpm view` reads and a direct fetch would have to reimplement.
+
+**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
+
+`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; `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, and `plugin-install-approve.e2e.ts` leaves a script undecided through the fake pnpm, allows it from the dialog, and installs on the retry.

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

@@ -0,0 +1,43 @@
+# Agent Note:引导式插件安装
+
+Status: implemented
+
+[English](2026-09-15-guided-plugin-installation.md) | 中文
+
+## 问题
+
+安装对话框把 spec 直接交给 `pnpm add`,并把 pnpm 的终端当作全部说明:打错字、已装过的包、不存在的路径、注册表不可用,最后都是同一个红色退出码,人得去读 pnpm 输出才知道是哪一种,而且一旦开始就停不下来。失败的运行,或者装进来一个没有组合包 patch 的包,会把依赖留在 profile 里,列表上却没有任何东西能显示或移除它。启用是一个在知道要装什么之前就得勾的选项,装完的包散在列表某处。
+
+## 决定
+
+**宿主先读 spec,再安装。** `PluginManager.inspect` 用 `parseInstallSpec` 把 spec 分成注册表名、绝对路径、git 地址、压缩包,拒绝 pnpm 或注册表不会接受的写法,再通过 `pnpm view` 问注册表、或读目录的 `package.json`,得到名字、版本、描述和组合包声明。`pnpm view` 在 profile 目录里运行,让注册表和代理设置与安装一致。没有组合包 patch 的包在这一步、在 pnpm 运行之前就被拒绝:管理器只安装组合包。答复带七种 problem 之一;客户端把每一种渲染成输入框下的一句话,spec 保留可改。列表里已有的名字由对话框直接拒绝,不问宿主。
+
+**失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。
+
+**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
+
+**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
+
+**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
+
+**当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。
+
+**被拦下的安装脚本在失败界面批准。** pnpm 11 把依赖脚本留作未决时,失败的运行报告待决定的包名([管理器的授权](2026-09-14-current-profile-plugin-management.zh.md)),失败界面列出它们并以**允许这些脚本并重试**取代普通重试;store 带着 `approvedBuilds` 用同一个已检查的 subject 再跑一次,安装完成界面说明允许了哪些脚本。正因如此,`pnpm-workspace.yaml` 不在恢复的文件之列。没有待决定的名字时,失败退回到手动放行的提示。
+
+## 考虑过的替代方案
+
+**在客户端校验 spec。** 否决:规则属于 pnpm、注册表和 profile,客户端无法导入拥有这些规则的宿主包。
+
+**用 HTTP 直接查注册表而不是 `pnpm view`。** 否决:决定安装能否成功的注册表、代理与认证设置都在 pnpm 的配置里,`pnpm view` 读得到,直接 fetch 得重新实现一遍。
+
+**通过中止 add RPC 来停止运行。** 否决:断掉的 RPC 说不清 pnpm 是否停了、manifest 是否已恢复,对话框会在一次仍在写 profile 的运行之上把 spec 重新交回。管理器的 `cancelInstall` 只在清理完成后答复,对话框等它。
+
+**继续列出不是组合包的依赖。** 否决:管理器管理的是组合包,它拒绝安装的包无法经由它列出或移除;检查在写入任何东西之前就拒绝这样的包。
+
+## 后果
+
+`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 形式与失败分类器的输入;`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` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成,`plugin-install-approve.e2e.ts` 用假 pnpm 把脚本留作未决、从对话框允许后在重试中装上。

+ 2 - 2
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.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/process/2026-09-12-default-product-experimental-isolation.md
-2026-09-12-default-product-experimental-isolation.md: d2312d6061f372e910bfd625512230ccd48a586a
-2026-09-12-default-product-experimental-isolation.zh.md: 6ea86f1f3e859719ffd8287e66ebe63df77d64c2
+2026-09-12-default-product-experimental-isolation.md: 5c2485e4f737b822088a680b0006f395a5479fa9
+2026-09-12-default-product-experimental-isolation.zh.md: 3a412a491ab8356a4e29d706fed55226dc0f6038

+ 1 - 1
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md

@@ -10,7 +10,7 @@ Public npm availability does not make an experimental package part of the defaul
 
 ## Decision
 
-[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) runs in static CI and package hygiene. It follows runtime dependencies, optional dependencies, and peers from every app and the Python runtime, resolves workspace and npm aliases, and identifies experimental packages by their npm prefix or repository directory. Publication denylist membership has no effect on this classification.
+[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) runs in static CI and package hygiene. It follows runtime dependencies, optional dependencies, and peers from every app and the Python runtime, resolves workspace and npm aliases, and identifies experimental packages by their npm prefix or repository directory. Publication denylist membership has no effect on this classification. The bundles the launcher names in `OPTIONAL_BUNDLES` are the one declared exception ([shipped optional bundles](2026-09-15-shipped-optional-bundles.md)).
 
 The source check also reads runtime imports in the selected packages, installation-owned profile bundle lists, bundle patches, shipped agent presets, and declared configuration trees. It loads the default Web layers with the production patch parser and composes them with the same patch engine used at boot. The effective rows and patched Include trees are checked, so an id-only patch cannot hide a replacement group's plugins. Disabled plugin rows remain checked; ordinary plugin configuration data is not interpreted as another Loader entry list. Missing default roots fail the check.
 

+ 1 - 1
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## Decision
 
-[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) 在静态 CI 和包 hygiene 中运行。它从所有应用与 Python runtime 出发,遍历运行时依赖、可选依赖和 peer,解析 workspace 与 npm 别名,并按 npm 前缀或仓库目录识别实验包。发布 denylist 的成员关系不影响此分类。
+[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) 在静态 CI 和包 hygiene 中运行。它从所有应用与 Python runtime 出发,遍历运行时依赖、可选依赖和 peer,解析 workspace 与 npm 别名,并按 npm 前缀或仓库目录识别实验包。发布 denylist 的成员关系不影响此分类。启动器在 `OPTIONAL_BUNDLES` 里点名的组合包是唯一声明的例外([随安装提供的可选组合包](2026-09-15-shipped-optional-bundles.zh.md))。
 
 源码检查还读取所选包的运行时导入、安装自带的 profile bundle 列表、bundle patch、随产品提供的 Agent preset,以及声明的配置树。它使用生产 patch 解析器加载默认 Web 各层,并使用启动时的同一个 patch 引擎完成组合。检查对象包括最终 entry 和应用 patch 后的 Include 树,因此仅按 id 覆盖 group 的 patch 也无法隐藏替换后的插件。禁用的插件行仍纳入检查;普通插件配置数据不会被解释为另一个 Loader entry 列表。默认入口缺失会使检查失败。
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/process/2026-09-15-shipped-optional-bundles.md
+2026-09-15-shipped-optional-bundles.md: 6de3eb7d39b86a2e5dd6db756d2bf6561aeb8ed4
+2026-09-15-shipped-optional-bundles.zh.md: 05c23c7c0cdd6a146b938370fec64377652e31d4

+ 29 - 0
.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md

@@ -0,0 +1,29 @@
+# Agent Note: Ship optional bundles with the installation
+
+Status: implemented
+
+English | [中文](2026-09-15-shipped-optional-bundles.zh.md)
+
+## Problem
+
+The Web plugin page manages only the bundles a person installed into the profile. An official experimental layer such as Agent Teams had to be found on npm and installed by name before it could be switched on, and [default-product isolation](2026-09-12-default-product-experimental-isolation.md) kept every experimental package out of the installation's runtime dependencies, so nothing shipped with dsh could offer it.
+
+## Decision
+
+The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page lists optional bundles in a built-in group with an official tag beside the profile's own installed bundles.
+
+Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed.
+
+Agent Teams and Auto review ship this way first, as `@deepseek-ai/dsh-experimental-agent-team-profile`, `@deepseek-ai/dsh-experimental-agent-team-web-profile`, and `@deepseek-ai/dsh-experimental-auto-review`.
+
+## Alternatives considered
+
+**A catalog of installable official bundles.** The page would offer names to install from the registry on demand. That keeps the installation unchanged but needs network access at the moment of switching on and a version pin per release.
+
+**A flag on the bundle package.** A `dsh.bundle.optional` declaration would let any published bundle claim a place in the installation; the launcher's own list keeps the choice with the product.
+
+**A list in the installation's manifest.** `dsh.optionalBundles` in `apps/cli/package.json` was the first form; the maintainers keep product decisions in code, where the list is typed, read once, and shared by the manager and the gates.
+
+## Consequences
+
+Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. A bundle that needs a companion layer, such as the Agent Teams Web layer over its Host layer, says so in its description; the manager does not select companions automatically.

+ 29 - 0
.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Ship optional bundles with the installation
+
+Status: implemented
+
+[English](2026-09-15-shipped-optional-bundles.md) | 中文
+
+## 问题
+
+Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样的官方实验层,用户得先去 npm 找到包名并按名安装才能开启;而[默认产品隔离](2026-09-12-default-product-experimental-isolation.zh.md)又把所有实验包挡在安装的运行时依赖之外,所以随 dsh 一起交付的东西没有办法把它提供出来。
+
+## 决策
+
+启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页把可选组合包放在带官方标签的内置分组里,与 profile 自己安装的组合包并列。
+
+默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
+
+Agent Teams 与 Auto review 首先以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile`、`@deepseek-ai/dsh-experimental-agent-team-web-profile` 与 `@deepseek-ai/dsh-experimental-auto-review`。
+
+## 考虑过的替代方案
+
+**可安装官方组合包目录。** 页面按需提供从注册表安装的包名。安装本身不变,但开启那一刻需要网络,并且每次发布都要钉一个版本。
+
+**组合包自身的标记。** `dsh.bundle.optional` 声明会让任何已发布的组合包都能自称随安装提供;由启动器自己的列表来决定,选择权留在产品手里。
+
+**放在安装 manifest 里的列表。** `apps/cli/package.json` 的 `dsh.optionalBundles` 是最初的形式;维护者把产品决定放在代码里,列表有类型、只读一次,管理器与门禁共用。
+
+## 影响
+
+可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。需要配套层的组合包,例如 Agent Teams Web 层依赖其 Host 层,会在描述里说明;管理器不会自动选中配套层。

+ 3 - 1
apps/cli/package.json

@@ -47,6 +47,9 @@
     "@deepseek-ai/dsh-compaction-basic": "workspace:^",
     "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
     "@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
+    "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^",
+    "@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^",
+    "@deepseek-ai/dsh-experimental-auto-review": "workspace:^",
     "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-goal-round-driver": "workspace:^",
@@ -116,7 +119,6 @@
     "@deepseek-ai/dsh-credentials-local": "workspace:^",
     "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^",
     "@deepseek-ai/dsh-experimental-agent-team": "workspace:^",
-    "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^",
     "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:^",
     "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^",
     "@deepseek-ai/dsh-fs-observation-policy": "workspace:^",

+ 22 - 0
apps/web/tests/expected/plugin-install-approve/blocked.expected.md

@@ -0,0 +1,22 @@
+- dialog "插件安装失败":
+  - button "返回编辑":
+    - img
+    - text: 编辑
+  - button "关闭":
+    - img
+  - alert: 插件安装失败
+  - paragraph: 有依赖的安装脚本需要你允许后才能继续
+  - paragraph: native-package
+  - paragraph: 版本 1.0.0
+  - group "需要允许安装脚本":
+    - heading "需要允许安装脚本" [level=3]
+    - paragraph: 以下包声明了安装脚本,pnpm 默认不运行。
+    - list:
+      - listitem:
+        - code: native-package
+    - paragraph: 允许后,脚本会以你的权限在本机运行,授权保存在当前 profile,之后不再询问。
+    - paragraph: 只在信任这些包时允许。
+    - button "允许这些脚本并重试"
+  - button "查看安装详情":
+    - text: 查看安装详情
+    - img

+ 13 - 0
apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md

@@ -0,0 +1,13 @@
+- dialog "添加插件":
+  - heading "添加插件" [level=2]
+  - button "关闭":
+    - img
+  - paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。
+  - text: 包名或地址
+  - textbox "包名或地址":
+    - /placeholder: 例如 dsh-better-sidebar
+    - text: slow-package
+  - button "不知道该填什么?":
+    - img
+    - text: 不知道该填什么?
+  - button "安装"

+ 28 - 0
apps/web/tests/expected/plugin-manager/live-enabled.expected.md

@@ -0,0 +1,28 @@
+- heading "插件" [level=1]
+- paragraph: 添加和管理插件
+- button "刷新"
+- button "添加插件":
+  - img
+  - text: 添加插件
+- heading "内置" [level=3]
+- text: "3"
+- list:
+  - listitem:
+    - button "查看 experimental-agent-team-profile": experimental-agent-team-profile
+    - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base
+    - switch "启用 experimental-agent-team-profile"
+  - listitem:
+    - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile
+    - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins
+    - switch "启用 experimental-agent-team-web-profile"
+  - listitem:
+    - button "查看 experimental-auto-review": experimental-auto-review
+    - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset
+    - switch "启用 experimental-auto-review"
+- heading "已安装" [level=3]
+- text: "1"
+- list:
+  - listitem:
+    - button "查看 bundle": bundle
+    - text: "Web e2e fixture: a bundle whose one row is an inert plugin."
+    - switch "启用 bundle" [checked]

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

@@ -0,0 +1,28 @@
+- heading "插件" [level=1]
+- paragraph: 添加和管理插件
+- button "刷新"
+- button "添加插件":
+  - img
+  - text: 添加插件
+- heading "内置" [level=3]
+- text: "3"
+- list:
+  - listitem:
+    - button "查看 experimental-agent-team-profile": experimental-agent-team-profile
+    - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base
+    - switch "启用 experimental-agent-team-profile"
+  - listitem:
+    - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile
+    - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins
+    - switch "启用 experimental-agent-team-web-profile"
+  - listitem:
+    - button "查看 experimental-auto-review": experimental-auto-review
+    - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset
+    - switch "启用 experimental-auto-review"
+- heading "已安装" [level=3]
+- text: "1"
+- list:
+  - listitem:
+    - button "查看 bundle": bundle
+    - text: "Web e2e fixture: a bundle whose one row is an inert plugin."
+    - switch "启用 bundle"

+ 4 - 0
apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml

@@ -0,0 +1,4 @@
+# Web e2e fixture bundle: one inert row whose mount state exposes live layer changes.
+- insert:
+    - id: fixture-row
+      name: '@fixture/bundle'

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

@@ -0,0 +1,5 @@
+/** Web e2e fixture: an inert cordis plugin. */
+export const name = 'fixture-bundle'
+
+/** Nothing to mount. */
+export function apply() {}

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

@@ -0,0 +1,13 @@
+{
+  "name": "@fixture/bundle",
+  "version": "0.0.1",
+  "private": true,
+  "description": "Web e2e fixture: a bundle whose one row is an inert plugin.",
+  "type": "module",
+  "main": "index.js",
+  "dsh": {
+    "bundle": {
+      "patch": "./cordis.patch.yml"
+    }
+  }
+}

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

@@ -6,7 +6,6 @@
   "main": "index.js",
   "exports": { ".": "./index.js", "./client": "./client.js" },
   "dsh": {
-    "title": "动态界面示例",
     "bundle": { "patch": "./cordis.patch.yml" },
     "client": { "platform": "web" }
   }

+ 0 - 3
apps/web/tests/plugin-config.e2e.ts

@@ -60,9 +60,6 @@ describe('web e2e: plugin configuration section', () => {
     await expect
       .poll(() => dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
       .toBe('true')
-    await expect
-      .poll(() => dialog.getByRole('tab', { name: '插件配置', exact: true }).getAttribute('aria-selected'), { timeout: 5_000 })
-      .toBe('true')
     return dialog
   }
 

+ 78 - 0
apps/web/tests/plugin-install-approve.e2e.ts

@@ -0,0 +1,78 @@
+// A real Host/Remote/browser composition with a controllable package-manager process:
+// pnpm leaves an install script undecided, the dialog offers approval, and the retry runs with it.
+import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright'
+import { expect, it } from 'vitest'
+import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
+import { ZH_BROWSER_LOCALE } from './support.ts'
+
+it('offers approval for blocked install scripts and installs once they are allowed', async () => {
+  const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-approve-'))
+  const overlay = join(scratch, 'cordis.patch.yml')
+  await writeFile(overlay, `- id: plugin-manager\n  config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`)
+  let scaffold: WebScaffold | undefined
+  try {
+    scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay })
+    const browser = await chromium.launch()
+    try {
+      const profile = join(scaffold.harnessHome, 'profiles', 'scaffold')
+      const policyPath = join(profile, 'pnpm-workspace.yaml')
+      // Node stands in for pnpm: `view` answers the check, and `add` first leaves the script undecided in the
+      // profile policy the way pnpm 11 does and fails; once the policy allows it, the same `add` installs the package.
+      await writeFile(join(profile, 'view'), 'console.log(JSON.stringify({ name: "native-package", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }))\n')
+      await writeFile(join(profile, 'add'), `
+        import('node:fs').then(fs => {
+        const policy = fs.readFileSync('pnpm-workspace.yaml', 'utf8');
+        if (!/native-package: true/.test(policy)) {
+          const pending = '  native-package: set this to true or false\\n';
+          fs.writeFileSync('pnpm-workspace.yaml', /^allowBuilds:/m.test(policy)
+            ? policy.replace(/^allowBuilds:\\n/m, 'allowBuilds:\\n' + pending)
+            : policy + 'allowBuilds:\\n' + pending);
+          console.error('ERR_PNPM_IGNORED_BUILDS  Ignored build scripts: native-package');
+          process.exitCode = 1;
+          return;
+        }
+        fs.mkdirSync('node_modules/native-package', { recursive: true });
+        fs.writeFileSync('node_modules/native-package/package.json', JSON.stringify({ name: 'native-package', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }));
+        fs.writeFileSync('node_modules/native-package/cordis.patch.yml', '[]\\n');
+        fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { 'native-package': '1.0.0' } }));
+        console.log('Built native-package');
+        });
+      `)
+      const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+      const tripwire = watchConsole(page)
+      await page.goto(scaffold.authenticatedUrl)
+      await page.waitForSelector('[class*="frame"]')
+      if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape')
+      await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+      const panel = page.locator('[data-plugin-panel]')
+      await panel.getByRole('button', { name: '添加插件', exact: true }).click()
+      const dialog = page.getByRole('dialog')
+      await dialog.getByRole('textbox').fill('native-package')
+      await dialog.getByRole('button', { name: '安装', exact: true }).click()
+      // pnpm's refusal becomes the approval block, naming the package whose script waits; plain retry is not offered.
+      const approval = dialog.getByRole('group', { name: '需要允许安装脚本' })
+      await approval.waitFor({ timeout: 20_000 })
+      await expect.poll(() => approval.getByText('native-package', { exact: true }).count()).toBe(1)
+      expect(await dialog.getByRole('button', { name: '重试', exact: true }).count()).toBe(0)
+      expect(await readFile(policyPath, 'utf8')).toContain('native-package: set this to true or false')
+      const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+        .split(process.execPath).join('{{node}}')
+        .split(scaffold.harnessHome).join('{{harnessHome}}')
+      await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-approve/blocked.expected.md', import.meta.url)), snapshot, webSnapshotMode())
+      await approval.getByRole('button', { name: '允许这些脚本并重试', exact: true }).click()
+      // The Host saved the permission before running pnpm again, and the installed screen says so.
+      await dialog.getByRole('button', { name: '立即启用', exact: true }).waitFor({ timeout: 20_000 })
+      await dialog.getByText('已允许运行安装脚本:native-package', { exact: true }).waitFor()
+      expect(await readFile(policyPath, 'utf8')).toMatch(/native-package: true/)
+      expect(JSON.parse(await readFile(join(profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { 'native-package': '1.0.0' } })
+      expect(tripwire.pageErrors).toEqual([])
+    } finally { await browser.close() }
+  } finally {
+    await scaffold?.close()
+    await rm(scratch, { recursive: true, force: true })
+  }
+})

+ 84 - 0
apps/web/tests/plugin-install-cancel.e2e.ts

@@ -0,0 +1,84 @@
+// A real Host/Remote/browser composition with a controllable package-manager process.
+// No model call is needed: installation state and profile files are the observable result.
+import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright'
+import { expect, it } from 'vitest'
+import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
+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 })}\n`)
+  let scaffold: WebScaffold | undefined
+  try {
+    scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay })
+    const browser = await chromium.launch()
+    try {
+      const profile = join(scaffold.harnessHome, 'profiles', 'scaffold')
+      const manifestPath = join(profile, 'package.json')
+      const manifest = await readFile(manifestPath, 'utf8')
+      const lockPath = join(profile, 'pnpm-lock.yaml')
+      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", 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' } }));
+        fs.writeFileSync('pnpm-lock.yaml', 'partial lockfile');
+        console.log('Waiting for package download');
+        setInterval(() => {}, 1000);
+        });
+      `)
+      const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+      const tripwire = watchConsole(page)
+      await page.goto(scaffold.authenticatedUrl)
+      await page.waitForSelector('[class*="frame"]')
+      if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape')
+      await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+      const panel = page.locator('[data-plugin-panel]')
+      await panel.getByRole('button', { name: '添加插件', exact: true }).click()
+      // The dialog is named after its current screen, so it is found by role alone.
+      const dialog = page.getByRole('dialog')
+      await dialog.getByRole('textbox').fill('slow-package')
+      await dialog.getByRole('button', { name: '安装', exact: true }).click()
+      // The check passed: the running screen names the package and folds pnpm's output behind the details.
+      await dialog.getByText('版本 1.0.0', { exact: true }).waitFor()
+      await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click()
+      await dialog.getByText('Waiting for package download', { exact: true }).waitFor()
+      await dialog.getByRole('button', { name: '取消安装', exact: true }).click()
+      // The Host's confirmation returns the dialog to the spec and says so in a toast.
+      await page.getByText('已取消安装,插件未启用,下载的文件可能保留', { exact: true }).waitFor()
+      expect(await readFile(manifestPath, 'utf8')).toBe(manifest)
+      expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n')
+      expect(await dialog.getByRole('textbox').inputValue()).toBe('slow-package')
+      const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+        .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())
+      // 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 }).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 {
+    await scaffold?.close()
+    await rm(scratch, { recursive: true, force: true })
+  }
+})

+ 209 - 0
apps/web/tests/plugin-manager.e2e.ts

@@ -0,0 +1,209 @@
+// 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'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { join } from 'node:path'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
+const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
+const LIVE_EXPECTED = join(SNAPSHOT_DIR, 'live-enabled.expected.md')
+const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
+const MODE = webSnapshotMode()
+
+describe('web e2e: plugin manager', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({
+      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 })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  /** Close any open settings dialog, so the sidebar and the main column are clickable. */
+  async function closeSettings() {
+    if (await page.getByRole('dialog', { name: '设置' }).count() > 0) {
+      await page.keyboard.press('Escape')
+      await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
+    }
+  }
+
+  /** Select the sidebar's Plugins entry and wait for the management page in the main column. */
+  async function openPluginsPanel() {
+    await closeSettings()
+    await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+    const panel = page.locator('[data-plugin-panel]')
+    await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
+    return panel
+  }
+
+  /** One file under the harness home, or the empty string while it does not exist. */
+  async function homeFile(...segments: string[]): Promise<string> {
+    return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
+  }
+
+  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('bundle', { exact: true }).waitFor({ timeout: 20_000 })
+    const toggle = panel.getByRole('switch', { name: '启用 bundle' })
+    expect(await toggle.getAttribute('aria-checked')).toBe('false')
+    // The profile's own group holds its one bundle; the installation's optional bundles form the built-in
+    // group, and its other bundles stay off the page.
+    expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(1)
+    expect(await panel.locator('[data-plugin-group="builtin"] [data-plugin-package]').count()).toBe(3)
+    // A bundle that is off still shows the rows its patch declares, without switches.
+    await panel.getByRole('button', { name: '查看 bundle' }).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: '卸载 bundle' }).waitFor({ timeout: 5_000 })
+    await panel.getByRole('button', { name: '返回插件列表' }).click()
+    await expect.poll(() => panel.getByRole('button', { name: '卸载 bundle' }).count(), { timeout: 5_000 }).toBe(0)
+
+    const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('checks a spec before installing it and words what the check refused', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install'))
+    const panel = await openPluginsPanel()
+    await panel.getByRole('button', { name: '添加插件', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '添加插件' })
+    await dialog.waitFor({ timeout: 10_000 })
+    const field = dialog.getByRole('textbox', { name: '包名或地址' })
+    const install = dialog.getByRole('button', { name: '安装', exact: true })
+    expect(await install.isDisabled()).toBe(true)
+    // A name the list already shows is refused without asking the Host.
+    await field.fill('@fixture/bundle')
+    await install.click()
+    await dialog.getByRole('alert').waitFor({ timeout: 5_000 })
+    expect(await dialog.getByRole('alert').textContent()).toBe('该插件已安装')
+    // A path the Host cannot read as a package is refused with its reason, and the spec stays editable.
+    await field.fill(join(scaffold.harnessHome, 'no-such-plugin'))
+    await install.click()
+    await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toBe('该路径不存在或不是有效的插件包')
+    expect(await field.isDisabled()).toBe(false)
+    // A name the registry would refuse never reaches it.
+    await field.fill('Not A Package')
+    await install.click()
+    await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toContain('无法识别这个包名或地址')
+    await dialog.getByRole('button', { name: '关闭' }).click()
+    await expect.poll(() => page.getByRole('dialog', { name: '添加插件' }).count(), { timeout: 5_000 }).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  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: '启用 bundle' })
+    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()
+
+    const bundles = async () => (JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
+      dsh: { profile: { bundles: string[] } }
+    }).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')
+    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: '查看 bundle' }).click()
+    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([])
+  }, 90_000)
+
+  it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md'])
+  })
+})
+
+
+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({
+      profile: { hmr: false, packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
+    })
+    let browser: Browser | undefined
+    try {
+      browser = await chromium.launch()
+      const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+      const tripwire = watchConsole(page)
+      onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-live'))
+      await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+      await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+      const panel = page.locator('[data-plugin-panel]')
+      const toggle = panel.getByRole('switch', { name: '启用 bundle' })
+      await toggle.waitFor({ timeout: 20_000 })
+      const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
+      const bundles = async () => {
+        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()?.fiber?.state).toBeUndefined()
+      await toggle.click()
+      // 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')
+      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: '查看 bundle' }).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(bundles).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
+      await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
+      expect(tripwire.pageErrors).toEqual([])
+    } finally {
+      await browser?.close()
+      await scaffold.close()
+    }
+  }, 60_000)
+})

+ 86 - 24
apps/web/tests/scaffold.ts

@@ -56,16 +56,7 @@ import {
   writesCurrentSessionFixtures,
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
-import {
-  auditStartupEntries,
-  composeEntries,
-  createProfileResolutionGeneration,
-  healProfilesModuleFallback,
-  loadOverlayPatches,
-  PluginPackages,
-  type Profile,
-  type ProfileResolutionMode,
-} from '@deepseek-ai/dsh-app-boot'
+import type { Profile, ProfileContext, ProfileResolutionMode } from '@deepseek-ai/dsh-app-boot'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {
@@ -91,7 +82,22 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import type {} from '@deepseek-ai/dsh-host-webserver'
 import type {} from '@deepseek-ai/dsh-agent'
 import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
-import { REPO_ROOT, requireDist } from './support.ts'
+import { REPO_ROOT, requireBuilt, requireDist } from './support.ts'
+
+type AppBoot = typeof import('@deepseek-ai/dsh-app-boot')
+let builtAppBoot: AppBoot | undefined
+
+/**
+ * The launcher's own module, as built: the manager and HMR plugins the profile
+ * loads reload the tree through this copy's registry of the root Include, so
+ * the scaffold mounts through the same copy rather than the source import.
+ * Resolved on the first launch, which needs the build anyway, so the fixture
+ * helpers this module also exports load without one.
+ */
+function appBoot(): AppBoot {
+  builtAppBoot ??= requireBuilt('@deepseek-ai/dsh-app-boot') as AppBoot
+  return builtAppBoot
+}
 
 // Host-side web e2e cannot import a browser package: doing so would pull that
 // package's complete TS project into this graph. Mirrored from
@@ -308,6 +314,21 @@ 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.
+   * The base bundle's `hmr` row turns on with the profile context, so
+   * configuration changes apply live; `hmr: false` disables that row through
+   * an overlay, leaving changes for the next start.
+   */
+  profile?: {
+    hmr?: boolean
+    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
@@ -432,6 +453,10 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste
  */
 export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
   requireDist()
+  const {
+    auditStartupEntries, composeEntries, createProfileResolutionGeneration, healProfilesModuleFallback, initProfile,
+    mountRootInclude, readProfileManifest, readProfilePatches, loadOverlayPatches, PluginPackages,
+  } = appBoot()
   const mode = webSnapshotMode()
   const replayFixture = options.replayFixture === undefined
     ? undefined
@@ -523,9 +548,10 @@ 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[] = [
+    // Without HMR the profile applies configuration changes at its next start.
+    ...options.profile?.hmr === false ? [{ id: 'hmr', disabled: true }] : [],
     { 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 +679,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.
@@ -705,6 +732,34 @@ 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)
+      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,
+        overlays: overlayPatches, telemetryDisabledEnv: undefined,
+      }
+      // HMR gates file-driven reloads on application readiness, which the
+      // launcher commits after boot; this direct harness is ready at once.
+      ctx.provide('appReady', { onReady: (listener) => { listener(); return () => {} } })
+      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
@@ -722,16 +777,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) {

+ 17 - 5
apps/web/tests/settings-chrome.e2e.ts

@@ -97,18 +97,23 @@ describe('web e2e: settings modal and General preferences', () => {
     await dialog.getByRole('button', { name: '模型' }).click()
     await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
     expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
-    // Plugins is a read-only projection of the same assembled Loader tree.
-    // Capture one stable shipped row rather than the whole inventory so adding
-    // an unrelated plugin does not rewrite this surface's golden.
+    // Plugins: the configuration tab beside the read-only Plugin list, a
+    // projection of the same assembled Loader tree; management lives on the
+    // sidebar's Plugins panel (its own scenario file drives that page over a
+    // profile runtime). Capture one stable shipped row rather than the whole
+    // inventory so adding an unrelated plugin does not rewrite this surface's golden.
     await dialog.getByRole('button', { name: '插件', exact: true }).click()
     await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
     await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
-    // The preset group opens first with its display-only switcher; the global
-    // plane starts collapsed and expands on demand.
+    // Both groups start collapsed; the preset group's header still carries its display-only switcher.
     const presetSwitcher = dialog.getByRole('button', { name: '选择要查看的 Agent 预设' })
     await presetSwitcher.waitFor({ timeout: 10_000 })
     // The shipped default's zh display name comes from the zh dictionaries.
     expect(await presetSwitcher.textContent()).toBe('标准模式(默认)')
+    const presetToggle = dialog.getByRole('button', { name: '会话插件', exact: true })
+    expect(await presetToggle.getAttribute('aria-expanded')).toBe('false')
+    expect(await dialog.locator('[data-plugin-scope="preset"] [data-plugin-entry]').count()).toBe(0)
+    await presetToggle.click()
     await dialog.getByRole('button', { name: /^全局/ }).click()
     const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
     await pluginRow.waitFor({ timeout: 10_000 })
@@ -656,6 +661,13 @@ describe('web e2e: settings modal and General preferences', () => {
       const presetSwitcher = dialog.getByRole('button', { name: 'Choose the agent preset to inspect' })
       await presetSwitcher.waitFor({ timeout: 10_000 })
       expect(await presetSwitcher.textContent()).toBe('Standard mode (default)')
+      // The plugin manager speaks the en dictionary too: its sidebar entry
+      // and the unavailable notice a scaffold without a profile runtime shows.
+      await enPage.keyboard.press('Escape')
+      await expect.poll(() => enPage.getByRole('dialog', { name: 'Settings' }).count(), { timeout: 5_000 }).toBe(0)
+      await enPage.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
+      await enPage.getByText('This deployment runs without a manageable profile, so plugins cannot be installed or switched here.', { exact: true })
+        .waitFor({ timeout: 10_000 })
       // This page has no closing inventory spec to sweep its console, so the
       // scenario clears both tripwire channels itself.
       expect(enTripwire.pageErrors).toEqual([])

+ 20 - 0
apps/web/tests/support.ts

@@ -1,6 +1,7 @@
 // Shared plumbing for the web smoke tests (dist location, free port, failure shots).
 import { existsSync, mkdirSync } from 'node:fs'
 import { createServer } from 'node:net'
+import { createRequire } from 'node:module'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Locator, Page } from 'playwright'
@@ -10,6 +11,25 @@ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.met
 
 export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
 
+const installationRequire = createRequire(join(REPO_ROOT, 'apps/cli/package.json'))
+
+/**
+ * The built copy of a workspace package, as the dsh installation resolves it.
+ * The Host plugins a scaffold profile loads run from built packages through
+ * Node's own loader; a scaffold call that must share their module state
+ * (app-boot keeps the root Include it mounted per context) has to run that
+ * same copy, not the source a bare import gets through the tsconfig paths,
+ * and not the test runner's own inlined copy of the built file either.
+ * `require` of an ES module goes through Node's loader and shares its
+ * module map with the plugins' imports; it needs a graph without top-level
+ * await, which the built Host packages keep.
+ * @param name - the workspace package name.
+ * @returns the package's built module namespace, for the caller to type as the package's own.
+ */
+export function requireBuilt(name: string): unknown {
+  return installationRequire(name)
+}
+
 /**
  * Browser language a page must advertise to boot into the product's Chinese
  * surface: with no stored preference the client derives its initial locale

+ 3 - 0
apps/web/tsconfig.json

@@ -46,6 +46,9 @@
     "tests/details-session-lifecycle.e2e.ts",
     "tests/document-preview.e2e.ts",
     "tests/plugin-config.e2e.ts",
+    "tests/plugin-manager.e2e.ts",
+    "tests/plugin-install-cancel.e2e.ts",
+    "tests/plugin-install-approve.e2e.ts",
     "tests/client-plugin-live.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: b2a1177bc58e71fcff318613fb4781ff66b355a7
-config-catalog.zh.md: fd40eb46ed9103a5a10688f1bf75215c4ab084b2
+config-catalog.md: ba691e83e0e81bf608f090c56cca6b797074116b
+config-catalog.zh.md: 78d39e6e85329d797699b0a6e9e151e13d48fa5e

+ 7 - 2
docs/config-catalog.md

@@ -1810,16 +1810,20 @@ 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 lock acquisition. */
+/** The pnpm executable and the limits for package diagnostics and registry lookups. */
 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
   /** 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:33`](../packages/boot/plugin-manager/src/index.ts)
 
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
@@ -3663,6 +3667,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))

+ 29 - 24
docs/config-catalog.zh.md

@@ -31,7 +31,7 @@ export interface AcpConfig {
 }
 ```
 
-依赖:`Stream`(`@agentclientprotocol/sdk`)
+Depends on: `Stream` (`@agentclientprotocol/sdk`)
 
 来源:[`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts)
 
@@ -111,7 +111,7 @@ export interface Config {
 }
 ```
 
-依赖:[`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md)
+Depends on: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md)
 
 来源:[`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts)
 
@@ -181,7 +181,7 @@ export interface Config {
 }
 ```
 
-依赖:[`ToolPresentationMode`](subsystems/tools.zh.md)
+Depends on: [`ToolPresentationMode`](subsystems/tools.zh.md)
 
 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
 
@@ -382,7 +382,7 @@ export interface Config {
 export type Config = LocalConfig
 ```
 
-依赖:[`LocalConfig`](#deepseek-aidsh-bash-local)
+Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local)
 
 来源:[`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-sandbox/src/index.ts)
 
@@ -585,7 +585,7 @@ export interface Config {
 export type Config = BrowserMcpConfig
 ```
 
-依赖:`BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`)
+Depends on: `BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`)
 
 来源:[`packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts:14`](../packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts)
 
@@ -600,7 +600,7 @@ export type Config = BrowserMcpConfig
 export type Config = BrowserMcpConfig
 ```
 
-依赖:`BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`)
+Depends on: `BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`)
 
 来源:[`packages/experimental/browser-use-playwright-mcp/src/index.ts:15`](../packages/experimental/browser-use-playwright-mcp/src/index.ts)
 
@@ -642,7 +642,7 @@ export interface StagehandModelConfig {
 }
 ```
 
-依赖:`ModelConfig` (`@browserbasehq/stagehand`)
+Depends on: `ModelConfig` (`@browserbasehq/stagehand`)
 
 来源:[`packages/experimental/browser-use-stagehand-native/src/index.ts:28`](../packages/experimental/browser-use-stagehand-native/src/index.ts)
 
@@ -666,7 +666,7 @@ export interface Config {
 }
 ```
 
-依赖:[`McpClient`](../packages/mcp/mcp-client/src/index.ts)
+Depends on: [`McpClient`](../packages/mcp/mcp-client/src/index.ts)
 
 来源:[`packages/experimental/computer-use-cua-driver-mcp/src/index.ts:20`](../packages/experimental/computer-use-cua-driver-mcp/src/index.ts)
 
@@ -877,7 +877,7 @@ export interface Config {
 export type Config = LocalConfig
 ```
 
-依赖:[`LocalConfig`](#deepseek-aidsh-fs-local)
+Depends on: [`LocalConfig`](#deepseek-aidsh-fs-local)
 
 来源:[`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src/index.ts)
 
@@ -935,7 +935,7 @@ export interface HmrConfig extends ChokidarOptions {
 }
 ```
 
-依赖: `ChokidarOptions` (`chokidar`)
+Depends on: `ChokidarOptions` (`chokidar`)
 
 来源: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
 
@@ -1214,7 +1214,7 @@ export interface DeepSeekCatalogModel {
 }
 ```
 
-依赖: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
+Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
 来源:[`packages/llm/llm-deepseek/src/config.ts:25`](../packages/llm/llm-deepseek/src/config.ts)
 
@@ -1489,7 +1489,7 @@ export type PiAiThinkingFormat = NonNullable<OpenAICompletionsCompat['thinkingFo
 export type PiAiThinkingTokenBudgetField = NonNullable<OpenAICompletionsCompat['thinkingTokenBudgetField']>
 ```
 
-依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`)
+Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
 
 来源:[`packages/llm/llm-pi-ai/src/config.ts:221`](../packages/llm/llm-pi-ai/src/config.ts)
 
@@ -1568,7 +1568,7 @@ export interface ReplayModelConfig {
 }
 ```
 
-依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
+Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
 来源:[`packages/test-support/llm-replay/src/index.ts:1122`](../packages/test-support/llm-replay/src/index.ts)
 
@@ -1756,7 +1756,7 @@ export interface PresetSpec {
 }
 ```
 
-依赖:[`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md)
+Depends on: [`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md)
 
 来源:[`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts)
 
@@ -1812,16 +1812,20 @@ export interface PlanModeConfig {
 依赖: `loader` · `profileContext`
 
 ```ts config-catalog
-/** Limits for package diagnostics and lock acquisition. */
+/** The pnpm executable and the limits for package diagnostics and registry lookups. */
 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
   /** 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:33`](../packages/boot/plugin-manager/src/index.ts)
 
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
@@ -1926,7 +1930,7 @@ export interface Config {
 export type Config = LocalConfig
 ```
 
-依赖:[`LocalConfig`](#deepseek-aidsh-pwsh-local)
+Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local)
 
 来源:[`packages/shell/pwsh-sandbox/src/index.ts:40`](../packages/shell/pwsh-sandbox/src/index.ts)
 
@@ -2021,7 +2025,7 @@ export interface Config {
 }
 ```
 
-依赖:[`SandboxMode`](subsystems/sandbox.zh.md)
+Depends on: [`SandboxMode`](subsystems/sandbox.zh.md)
 
 来源:[`packages/sandbox/sandbox-policy/src/index.ts:71`](../packages/sandbox/sandbox-policy/src/index.ts)
 
@@ -2061,7 +2065,7 @@ export interface JsonRpcConfig {
 }
 ```
 
-依赖:`Readable`(`node:stream`)· `Writable`(`node:stream`)
+Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`)
 
 来源:[`packages/sdk/server/src/index.ts:25`](../packages/sdk/server/src/index.ts)
 
@@ -2193,7 +2197,7 @@ export type OpenAt = 'startup' | 'first-search' | 'never'
 export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 ```
 
-依赖:[`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts)
+Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts)
 
 来源:[`packages/session-query/session-query-sqlite/src/index.ts:92`](../packages/session-query/session-query-sqlite/src/index.ts)
 
@@ -2260,7 +2264,7 @@ export enum SessionTelemetryMode {
 }
 ```
 
-依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`)
+Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
 
 来源:[`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
 
@@ -2295,7 +2299,7 @@ export interface Config {
 export type Config = SessionTitleLlmConfig
 ```
 
-依赖:[`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
+Depends on: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
 
 来源:[`packages/session/session-title-all-prompts-llm/src/index.ts:15`](../packages/session/session-title-all-prompts-llm/src/index.ts)
 
@@ -2310,7 +2314,7 @@ export type Config = SessionTitleLlmConfig
 export type Config = SessionTitleLlmConfig
 ```
 
-依赖:[`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
+Depends on: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
 
 来源:[`packages/session/session-title-first-prompt-llm/src/index.ts:15`](../packages/session/session-title-first-prompt-llm/src/index.ts)
 
@@ -3264,7 +3268,7 @@ export interface Config {
 }
 ```
 
-依赖:[`AgentOptions`](subsystems/core.zh.md)
+Depends on: [`AgentOptions`](subsystems/core.zh.md)
 
 来源:[`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts)
 
@@ -3665,6 +3669,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: 51e5cef531312f4a0d70eb78afcd7f027c951a3b
-event-producer-consumer.zh.md: 441a07976ab42d2fb068800df004d055f7feacdd
+event-producer-consumer.md: db0f7d4bbe6a0e3a513863fb2d16f56cdeec0488
+event-producer-consumer.zh.md: e0f8db40dad9be12c0568e151dc0f40342f71b6d

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

@@ -50,6 +50,9 @@ 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:188`](../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:194`](../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:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
 | `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) |

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

@@ -52,6 +52,9 @@
 | `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:188`](../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:194`](../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:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
 | `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: 9c52f02cc990f0be4d317f1740da0a4625ac11a0
-module-graph.zh.md: 7822bc5a576a24c41f33da055f57d948d9c8ed72
+module-graph.md: 719273c14784fcf5dd98b2fddabfb7666c666139
+module-graph.zh.md: b1098cc39fdc1d44f9d7edc3555d18a2f2c26bc5

+ 4 - 1
docs/module-graph.md

@@ -169,6 +169,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"]
@@ -1085,6 +1086,7 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
+  pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
   pkg_plugin_manager --> pkg_subprocess
@@ -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` | — |
@@ -1543,7 +1546,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` | [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`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

@@ -171,6 +171,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"]
@@ -1087,6 +1088,7 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
+  pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
   pkg_plugin_manager --> pkg_subprocess
@@ -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` | — |
@@ -1545,7 +1548,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` | [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`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: 18f06789f450b0694329feb1fb8b7a2df7a9905a
-boot.zh.md: afe4c6f79c3cbe2d8ef8570e512fa4669c10b1ff
+boot.md: 6bceb5deb087d49dff6958d51549c7942d0e224a
+boot.zh.md: c3addc59008e9f7e5812f5f2eefbc751cd731fba

+ 81 - 4
docs/subsystems/boot.md

@@ -65,11 +65,20 @@ 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.
+/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
+ * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
+ * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
+ * bundle, 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 +93,24 @@ 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 Activation defaults to true; explicit build approvals persist for this profile before installation.
+ * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
+ * the pending build scripts to allow for this profile before pnpm runs.
  * @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.
@@ -143,4 +163,61 @@ 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. A patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing here.
+
+```ts cordis-catalog
+/**
+ * The profile's plugins, bundles, or composition changed: a manager
+ * operation completed. A patch generation applied outside the manager,
+ * by HMR's watcher after a CLI or hand edit, announces nothing here.
+ * @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)
 <!-- END GENERATED cordis-surface -->

+ 81 - 4
docs/subsystems/boot.zh.md

@@ -65,11 +65,20 @@ 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.
+/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
+ * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
+ * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
+ * bundle, 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 +93,24 @@ 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 Activation defaults to true; explicit build approvals persist for this profile before installation.
+ * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
+ * the pending build scripts to allow for this profile before pnpm runs.
  * @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.
@@ -143,4 +163,61 @@ 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. A patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing here.
+
+```ts cordis-catalog
+/**
+ * The profile's plugins, bundles, or composition changed: a manager
+ * operation completed. A patch generation applied outside the manager,
+ * by HMR's watcher after a CLI or hand edit, announces nothing here.
+ * @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)
 <!-- 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, InstallBundleOptions, PluginEntryId } from '@deepseek-ai/dsh-plugin-manager/types'
+export type {
+  BundleInfo, BundleRowInfo, ChangeResult, InstallBundleOptions, InstallSpecKind, ManagementError, PackageResult, PluginChange,
+  PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind, PluginInstallLogChunk,
+  PluginInstallProgress, PluginInstallRequestId, PluginSpecInspection, ReadOnlyReason,
+} 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"
     }
   ]
 }

+ 1 - 0
packages/boot/app-boot/src/index.ts

@@ -36,6 +36,7 @@ export {
   composeEntries,
   createProfileResolutionGeneration,
   DEFAULT_PROFILE_BUNDLES,
+  OPTIONAL_BUNDLES,
   healProfilesModuleFallback,
   healIsolatedProfileModuleFallback,
   unlinkProfileModuleFallback,

+ 12 - 0
packages/boot/app-boot/src/profile.ts

@@ -158,6 +158,18 @@ const INSTALLATION_OWNED_PROFILE_TUPLES: Record<string, readonly string[]> = {
 /** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
 export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base']
 
+/**
+ * The bundles the dsh installation ships for a person to switch on: each a
+ * runtime dependency of the installation that declares `dsh.bundle.patch`,
+ * selected by no shipped template, and offered switched off by the plugin
+ * manager ([rationale](../../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)).
+ */
+export const OPTIONAL_BUNDLES: readonly string[] = [
+  '@deepseek-ai/dsh-experimental-agent-team-profile',
+  '@deepseek-ai/dsh-experimental-agent-team-web-profile',
+  '@deepseek-ai/dsh-experimental-auto-review',
+]
+
 const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
 # a top-level YAML array of loader patch entries (id-targeted config
 # overrides, disables, and insert lists; \`!!js\` expressions allowed).

+ 3 - 0
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -335,6 +335,9 @@ 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('removes a previously failed entry without reporting its old activation error', 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: cce53d2d0f981128f8749e70c1442abc1ee1cc8c
-README.zh.md: f42a329342ff8dbd7404f236802a9a948d9fc45b
+README.md: 66408c85273e57d78d94c98a3e8cdcefab1286fb
+README.zh.md: 2b10860867b9ff992a96222b24432ebb99025015

+ 16 - 9
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"
 ---
 
@@ -26,7 +26,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.
 
@@ -37,12 +37,18 @@ Enable the tool explicitly in the profile patch; agents using a preset also need
 
 A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority.
 
-When pnpm 11 blocks dependency scripts, the failed installation reports all pending package names in the profile, including earlier attempts. Web offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by package name in this profile, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Installation cleanup retains these decisions. Retry preserves the original activation choice.
+`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, 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 one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read.
+
+When pnpm 11 blocks dependency scripts, the failed installation reports every pending package name in the profile under `pendingBuilds`, including names left by earlier attempts; a failed run restores `package.json` and `pnpm-lock.yaml` but deliberately not `pnpm-workspace.yaml`, where pnpm records them. The Web plugin page offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by package name in this profile, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Retry preserves the original activation choice.
 
 ### 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. |
 
@@ -56,7 +62,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports all pend
 
 The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. Dependency-only changes do not trigger configuration reloads.
 
-Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
+Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. 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. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
 
 </details>
 
@@ -67,7 +73,8 @@ Results contain the last attempted stage, target, saved-state change, applicatio
 
 - [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
@@ -94,8 +101,8 @@ Tool results append to the transcript. Enabling or disabling other tools can cha
 - 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.
-- Management results describe Host activation. Browser synchronization failures appear separately in the plugin list.
+- 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.
+- Management results describe Host activation. Browser synchronization failures appear separately in the Settings plugin list.
 - Desktop package operations remain owned by the Desktop shell.
 
 <a id="failure-behavior"></a>
@@ -105,13 +112,13 @@ Failures preserve completed steps and report the actual remaining state. Profile
 
 | Failed operation | Handling |
 |---|---|
-| Install: pnpm or bundle validation fails | Attempt to remove only dependencies newly added by this operation and identified unambiguously. Do not restore existing packages. Report installation failure. |
+| Install: pnpm or bundle validation fails | Restore `package.json` and `pnpm-lock.yaml` as snapshotted before pnpm ran; files pnpm downloaded may remain. Report installation failure. |
 | Enable: saving selection or loading fails | Keep the installed dependency and any saved selection. Report enablement failure; allow repair, disablement or removal. |
 | Remove: any step fails | Stop at the failed step. Preserve completed changes, retain remaining dependencies for retry, and report removal failure. Do not re-enable the bundle. |
 
 Installation finishes after pnpm and bundle validation succeed; subsequent enablement failure does not undo installation. Removal proceeds in order: remove the bundle from `dsh.profile.bundles`, unload its runtime contributions, then run `pnpm remove`. A failed step prevents subsequent steps.
 
-Installation cleanup is attempted once. If it fails or the new dependency cannot be identified, preserve the actual state and report the original failure together with cleanup diagnostics and remaining dependencies. Do not recursively undo cleanup. Cleanup covers dependencies and bundle selection only; user-authored patch configuration, application data and diagnostic logs remain untouched.
+Restoration rewrites only the two snapshotted files; user-authored patch configuration, application data, diagnostic logs and files pnpm downloaded remain untouched, and the next package operation prunes packages no manifest references.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 17 - 10
packages/boot/plugin-manager/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "通过 Web 设置页或 agent 启停 profile 插件,并安装、删除或选择组合包。"
+description: "通过 Web 侧边栏或 agent 启停 profile 插件,并安装、删除或选择组合包。"
 kind: "package-reference"
 ---
 
@@ -26,7 +26,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` 条目。
 
@@ -37,12 +37,18 @@ kind: "package-reference"
 
 插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
 
-pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审批包名,包括此前安装留下的条目。Web 提供“允许这些脚本并重试”;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代表用户授权。service 验证包名仍在待审批列表中,不验证对话审批。授权按包名保存在当前 profile,允许脚本以宿主用户权限执行命令,并在再次安装失败后保留。该操作只能批准当前尚未决定的包名,不能覆盖已有拒绝或通配符规则。审批拒绝 `allowBuilds` 内的 YAML 锚点或别名。安装清理保留这些决定,重试沿用原来的启用选项。
+`inspect(spec)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的注册表与代理设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式。答复携带名称、版本、描述以及该包是否声明组合包,否则给出 `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` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。
+
+pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 profile 中所有待决定的包名,包括先前尝试留下的;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 pnpm 记录这些名字的 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按包名保存在当前 profile,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。
 
 ### 配置
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
+| `pnpmCommand` | `pnpm` | pnpm 可执行文件名或路径,与 `dsh plugin` 命令一样通过 `PATH` 解析。 |
+| `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 |
 | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
 | `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 |
 
@@ -56,7 +62,7 @@ pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审
 
 服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。仅依赖字段变化不会触发配置重载。
 
-结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
+结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
 
 </details>
 
@@ -67,7 +73,8 @@ pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审
 
 - [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>
 ## 模型体验
@@ -90,12 +97,12 @@ pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- Web 一次批准整个待审批列表,暂不支持逐包选择。
+- Web 一次批准显示出来的整组待决定包,没有逐包选择。
 - 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。
 - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。
 - 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。
-- 包管理失败可能留下部分依赖改动。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。
-- 管理结果描述 Host 激活状态。浏览器同步失败会在插件列表中单独显示。
+- 失败的删除可能留下部分依赖改动,失败或被取消的安装可能在 `node_modules` 或 pnpm 缓存中留下已下载文件。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。
+- 管理结果描述 Host 激活状态。浏览器同步失败会在设置的插件列表中单独显示。
 - Desktop 包管理操作仍由 Desktop shell 负责。
 
 <a id="failure-behavior"></a>
@@ -105,13 +112,13 @@ pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审
 
 | 失败操作 | 处理方式 |
 |---|---|
-| 安装:pnpm 执行或组合包校验失败 | 只尝试删除本次新增且能够明确识别的依赖,不恢复已有包。报告安装失败。 |
+| 安装:pnpm 执行或组合包校验失败 | 恢复 pnpm 运行前快照的 `package.json` 与 `pnpm-lock.yaml`;pnpm 已下载的文件可能保留。报告安装失败。 |
 | 启用:保存选择项或加载失败 | 保留已安装的依赖和已保存的选择项。报告启用失败,允许修正、停用或卸载。 |
 | 卸载:任一步失败 | 停在失败步骤,保留已完成的改动和待重试删除的依赖,报告卸载失败。不重新启用组合包。 |
 
 pnpm 执行和组合包校验成功即完成安装,后续启用失败不撤销安装。卸载依次执行:从 `dsh.profile.bundles` 移除组合包、卸载运行时贡献、执行 `pnpm remove`。任一步失败都不继续执行后续步骤。
 
-安装补偿清理只尝试一次。清理失败或无法明确识别新增依赖时,保留实际状态,同时报告原始失败、清理诊断及残留依赖,不递归撤销清理。清理范围仅限依赖和组合包选择项,不处理用户编写的 patch 配置、应用数据或诊断日志
+恢复只重写这两份快照文件;用户编写的 patch 配置、应用数据、诊断日志以及 pnpm 已下载的文件保持原样,没有 manifest 引用的包由下一次包操作清理
 
 <a id="dev-note"></a>
 ### 开发备注

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

@@ -67,7 +67,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-hmr": {
@@ -83,6 +84,7 @@
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-hmr": "workspace:^",
-    "@deepseek-ai/cordis-plugin-timer": "workspace:^"
+    "@deepseek-ai/cordis-plugin-timer": "workspace:^",
+    "@deepseek-ai/dsh-brand": "workspace:^"
   }
 }

+ 301 - 59
packages/boot/plugin-manager/src/index.ts

@@ -1,35 +1,51 @@
 /** 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 { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches } from '@deepseek-ai/dsh-app-boot'
+import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
+import {
+  readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES,
+} from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-hmr'
-import type { ProfileContext } from '@deepseek-ai/dsh-app-boot'
-import { bundleManifest, runProfilePnpm, saveManifest } from './operations.ts'
+import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
+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 { ManagementFailure } from './failure.ts'
 import { approveBuilds, readPendingBuilds } from './build-approval.ts'
-import type { BundleInfo, ChangeResult, InstallBundleOptions, ManagementError, PackageResult, PluginEntryId, PluginInfo } from './types.ts'
+import type {
+  BundleInfo, BundleRowInfo, ChangeResult, InstallBundleOptions, ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo,
+  PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, 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 lock acquisition. */
+/** The pnpm executable and the limits for package diagnostics and registry lookups. */
 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
   /** 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/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry',
   '@deepseek-ai/dsh-api-remotes',
   '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection',
@@ -37,15 +53,71 @@ const protectedModules = new Set([
   '@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[]) : [])])
 }
 
 /** Preserve the exact observed diagnostic, including non-Error failures. */
+function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
+
+/** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
 function managementError(error: unknown): ManagementError {
-  return error instanceof ManagementFailure ? { code: error.code }
-    : { code: 'operation-error', diagnostic: error instanceof Error ? error.message : String(error) }
+  return error instanceof ManagementFailure ? { code: error.code } : { code: 'operation-error', diagnostic: messageOf(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
+}
+
+/** The fields of the dsh installation's own manifest the manager reads. */
+interface InstallationManifest {
+  dependencies?: Record<string, string>
+}
+
+/** 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 { bundle?: unknown } : undefined
+  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 },
+  }
+}
+
+function refused(problem: PluginInspectProblem, reason: string): PluginSpecInspection {
+  return { status: 'refused', problem, reason }
 }
 
 declare module '@deepseek-ai/cordis' {
@@ -59,16 +131,22 @@ 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),
     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 lockWaitMs: number
+  private readonly inspectTimeoutMs: number
+  private readonly pnpmCommand: string
   private readonly ownerContext: Context
   private readonly abort = new AbortController()
+  /** Installations by request id, from their call until it settles. */
+  private readonly installs = new Map<PluginInstallRequestId, InstallControl>()
 
   constructor(ctx: Context, config: Config) {
     super(ctx, 'pluginManager')
@@ -77,6 +155,8 @@ export class PluginManager extends TypertRemoteService {
     this.profile = ctx.profileContext
     this.outputBytes = (config as Required<Config>).outputBytes
     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])
@@ -105,40 +185,114 @@ export class PluginManager extends TypertRemoteService {
     })
   }
 
-  /** Read installed bundles and bundles supplied by this dsh installation.
-   * @returns Package versions, activation selections and removal availability.
+  /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
+   * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
+   * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
+   * bundle, and removal availability.
    */
   @Remote
   listBundles(): Promise<BundleInfo[]> {
     const manifest = readProfileManifest('dsh', this.profile.dir)
     const selected = manifest.dsh?.profile?.bundles ?? []
     const dependencies = Object.keys(manifest.dependencies ?? {})
-    const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as { dependencies?: Record<string, string> }
+    const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest
     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 optional = OPTIONAL_BUNDLES.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) || dependencies.includes(name)) {
-            bundles.push({ name, enabled: selected.includes(name), removable, error: { code: 'not-bundle' } })
-          }
+          if (enabled) bundles.push({ name, enabled, installed, optional, removable, error: { code: 'not-bundle' }, rows: [], overrides: [] })
           continue
         }
         const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined
         bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
-          enabled: selected.includes(name), removable: removable && readOnlyReason === undefined,
-          ...(readOnlyReason === undefined ? {} : { readOnlyReason }) })
+          ...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
+          enabled, installed, optional, removable: removable && readOnlyReason === undefined,
+          ...(readOnlyReason === undefined ? {} : { readOnlyReason }),
+          ...this.declaredRows(name, info) })
       } catch (error) {
-        if (selected.includes(name) || dependencies.includes(name)) {
-          bundles.push({ name, enabled: selected.includes(name), removable, error: managementError(error) })
+        if (enabled || installed) {
+          bundles.push({ name, enabled, installed, optional, removable, error: managementError(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 InstallationManifest
+    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.
@@ -154,7 +308,7 @@ export class PluginManager extends TypertRemoteService {
       result.warnings = await this.reload(enabled ? [row.patchId] : [])
       const current = (await this.listPlugins()).find(item => item.entryId === id)
       return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined
-    }), { stage: 'enable', target: id, enabled })
+    }), { stage: 'enable', target: id, enabled }, 'plugin')
   }
 
   /** Select or remove a bundle layer while retaining installed dependencies.
@@ -167,27 +321,43 @@ export class PluginManager extends TypertRemoteService {
     return this.change(result => this.configure(async () => {
       await this.selectBundle(name, enabled)
       result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : [])
-    }), { stage: 'enable', target: name, enabled })
+    }), { stage: 'enable', target: name, enabled }, '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 Activation defaults to true; explicit build approvals persist for this profile before installation.
+   * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
+   * the pending build scripts to allow for this profile before pnpm runs.
    * @returns Package-manager diagnostics and observed activation outcome.
    */
   @Remote
   installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult> {
-    return this.change(async (result) => {
+    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)
+    const announce = (phase: PluginInstallProgress['phase']): void => {
+      if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase })
+    }
+    const result = this.change(async (result) => {
       if (spec.trim() === '' || spec.startsWith('-')) throw new ManagementFailure('invalid-spec')
+      if (stopped()) throw new InstallCancelledError()
       if (options?.approvedBuilds !== undefined) {
         await approveBuilds(this.profile.dir, options.approvedBuilds)
         result.approvedBuilds = options.approvedBuilds
       }
+      const files = await this.readRestoredFiles()
       const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
+      announce('installing')
       let name: string
       try {
-        result.packageResult = await this.runPnpm(['add', spec])
+        result.packageResult = await this.runPnpm(['add', spec], control.abort.signal, requestId)
+        if (stopped()) throw new InstallCancelledError()
         if (result.packageResult.exitCode !== 0) {
+          // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval.
           try { result.pendingBuilds = await readPendingBuilds(this.profile.dir) }
           catch (error) {
             this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error)
@@ -206,9 +376,13 @@ export class PluginManager extends TypertRemoteService {
         if (manifest?.dsh?.bundle?.patch === undefined) throw new ManagementFailure('not-bundle')
         loadOverlayPatches('dsh', join(dir, manifest.dsh.bundle.patch))
       } catch (error) {
-        await this.cleanFailedInstall(before, result)
+        // pnpm has exited by now, so the files it rewrote go back as they were.
+        await this.restoreFiles(files)
         throw error
       }
+      control.phase = 'applying'
+      announce('applying')
+      result.bundle = name
       result.target = name
       result.stage = 'enable'
       return this.configure(async () => {
@@ -216,29 +390,26 @@ export class PluginManager extends TypertRemoteService {
         if (Object.hasOwn(before, name)) return 'restart-required'
         if (options?.enabled !== false) result.warnings = await this.reload()
       })
-    }, { stage: 'install', target: spec, enabled: options?.enabled !== false })
+    }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install')
+    /* 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) })
   }
 
-  private async cleanFailedInstall(before: Record<string, string>, result: ChangeResult): Promise<void> {
-    try {
-      const manifest = readProfileManifest('dsh', this.profile.dir)
-      const added = Object.keys(manifest.dependencies ?? {}).filter(name => !Object.hasOwn(before, name))
-      result.remainingDependencies = added
-      const name = added[0]
-      const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as { dependencies?: Record<string, string> }
-      if (added.length !== 1 || name === undefined || manifest.dsh?.profile?.bundles?.includes(name)
-        || Object.hasOwn(installation.dependencies ?? {}, name)) return
-      result.cleanup = { name }
-      result.cleanup.packageResult = await this.runPnpm(['remove', name])
-      if (result.cleanup.packageResult.exitCode !== 0) {
-        result.cleanup.error = { code: 'operation-error', diagnostic: result.cleanup.packageResult.output }
-      }
-      result.remainingDependencies = Object.keys(readProfileManifest('dsh', this.profile.dir).dependencies ?? {})
-        .filter(name => !Object.hasOwn(before, name))
-    } catch (error) {
-      if (result.cleanup !== undefined) result.cleanup.error = managementError(error)
-      else result.warnings = [String(error)]
-    }
+  /** 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.
@@ -268,16 +439,81 @@ export class PluginManager extends TypertRemoteService {
       })
       result.packageResult = await this.runPnpm(['remove', name])
       if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output)
-    }, { stage: 'remove', target: name })
+    }, { stage: 'remove', target: name }, 'remove')
+  }
+
+  /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
+  private declaredRows(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 }
   }
 
-  private async runPnpm(args: readonly string[]): Promise<PackageResult> {
+  /** Run one pnpm command in the profile, streaming its output as install-log chunks. */
+  private async runPnpm(
+    args: readonly string[], signal?: AbortSignal, requestId?: PluginInstallRequestId,
+  ): 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, {
-      execution: 'service', signal: this.abort.signal, outputBytes: this.outputBytes, activateNewBundles: false,
+      execution: 'service', 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> {
@@ -320,6 +556,7 @@ export class PluginManager extends TypertRemoteService {
   private async change(
     operation: (result: ChangeResult) => Promise<ChangeResult['application'] | void>,
     request: Pick<ChangeResult, 'stage' | 'target' | 'enabled'>,
+    reason: PluginChange['reason'],
   ): Promise<ChangeResult> {
     return withFileLock(join(this.profile.dir, 'package.json'), async () => {
       this.abort.signal.throwIfAborted()
@@ -329,10 +566,15 @@ export class PluginManager extends TypertRemoteService {
       try {
         result.application = await operation(result) ?? result.application
       } catch (error) {
-        result.application = 'failed'
-        result.error = managementError(error)
+        if (error instanceof InstallCancelledError) {
+          result.application = 'cancelled'
+        } else {
+          result.application = 'failed'
+          result.error = managementError(error)
+        }
       }
       result.changed = before !== this.diskState()
+      this.ownerContext.emit('plugin-manager/changed', { reason })
       return result
     }, { waitMs: this.lockWaitMs })
   }
@@ -344,7 +586,7 @@ export class PluginManager extends TypertRemoteService {
         if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
         throw error
       }
-    }).join('\u0000')
+    }).join('\0')
   }
 }
 

+ 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

@@ -23,6 +23,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
   /** CLI inherits authentication and terminal descriptors; service scrubs secrets and captures output. */
   execution: 'cli' | 'service'
   signal?: AbortSignal
@@ -109,7 +111,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: options.execution === 'cli' ? process.env : scrubbedParentEnv(), extendEnv: false, reject: false,
     stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
     stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
@@ -178,3 +180,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 },
+  }
+}

+ 146 - 9
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'
 
 /** Reasons a profile control cannot modify its target. */
 export type ReadOnlyReason = 'management-required' | 'unaddressable'
@@ -17,49 +19,184 @@ export type PluginInfo = PluginInventoryEntry & (
   | { patchId?: never; readOnlyReason: ReadOnlyReason }
 )
 
+/** 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
+  /** `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
+  /**
+   * Whether the installation ships the bundle for the person to switch on: named by the launcher's `OPTIONAL_BUNDLES`,
+   * held by the installation's dependencies, selected by no shipped template, and never removable.
+   */
+  optional: boolean
   removable: boolean
   readOnlyReason?: ReadOnlyReason
   error?: ManagementError
+  /** 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'
   /** Last attempted step; successful installation can proceed to enablement. */
   stage: 'install' | 'enable' | 'remove'
   target: string
   enabled?: boolean
   error?: ManagementError
-  /** Pre-existing inactive entries or diagnostics from inspecting installation leftovers. */
+  /** Pre-existing inactive entries the operation left as they were. */
   warnings?: string[]
-  /** Newly added dependencies still declared after failed installation cleanup. */
-  remainingDependencies?: string[]
-  /** One cleanup attempt, restricted to a newly added dependency. */
-  cleanup?: { name: string; packageResult?: PackageResult; error?: ManagementError }
   packageResult?: PackageResult
-  /** Exact package names awaiting explicit script approval in the profile's pnpm settings. */
+  /** The bundle an installation added, once pnpm and the bundle check accepted it. */
+  bundle?: string
+  /** Exact package names awaiting explicit script approval in the profile's pnpm settings, read after a failed run. */
   pendingBuilds?: string[]
   /** Package script permissions saved before this installation attempt. */
   approvedBuilds?: 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
-  /** Explicitly allow these pending packages' scripts for this profile, then retry installation. */
+  requestId?: PluginInstallRequestId
+  /** Explicitly allow these pending packages' scripts for this profile, then install; a name no longer pending refuses the call. */
   approvedBuilds?: string[]
 }
+
+/** 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
+    /** `description` of the package manifest. */
+    readonly description?: 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. */
+  readonly reason: 'plugin' | 'bundle' | 'install' | 'remove'
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Events {
+    /**
+     * The profile's plugins, bundles, or composition changed: a manager
+     * operation completed. A patch generation applied outside the manager,
+     * by HMR's watcher after a CLI or hand edit, announces nothing here.
+     * @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')
+  })
+})

+ 302 - 118
packages/boot/plugin-manager/tests/manager.spec.ts

@@ -3,13 +3,14 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
 import { realpath } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
+import { pathToFileURL } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
 import { expect, it, onTestFinished, vi } from 'vitest'
 import {
-  boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches,
+  boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches, OPTIONAL_BUNDLES,
   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 type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
@@ -62,16 +63,49 @@ 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(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBe('management-required')
   expect(await manager.listBundles()).toEqual([
-    { name: 'core', version: '1.0.0', enabled: true, removable: false, readOnlyReason: 'management-required' },
-    { name: 'extra', version: '1.0.0', enabled: true, removable: true },
+    {
+      name: 'core', version: '1.0.0', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required',
+      rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [],
+    },
+    {
+      name: 'extra', version: '1.0.0', enabled: true, installed: true, optional: false, 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: 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: { 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', description: 'Describes itself.', enabled: false, installed: true, optional: false, 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
@@ -105,16 +139,17 @@ it('saves startup-only toggles and refuses removal of currently used packages',
   expect(await manager.setPluginEnabled(id, false)).toMatchObject({ application: 'restart-required' })
   expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(true)
   await manager.setBundleEnabled('extra', false)
-  expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed' })
+  expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed', error: { code: 'stop-profile' } })
 })
 
 it('refuses self-disable, unknown entries and removal of installation-owned bundles', async () => {
   const { manager } = await fixture()
   const id = (await manager.listPlugins()).find(row => row.entryId === 'include:manager')!.entryId
-  expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed' })
-  expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed' })
-  expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed' })
-  expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed' })
+  expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed', error: { code: 'management-required' } })
+  expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'unknown-plugin' } })
+  expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed', error: { code: 'not-removable' } })
+  // A name no bundle directory answers to fails with the resolver's own diagnostic.
+  expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'operation-error' } })
 })
 
 it('installs only valid bundle declarations and honors installation without activation', async () => {
@@ -131,7 +166,9 @@ it('installs only valid bundle declarations and honors installation without acti
     return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
   })
   onTestFinished(() => { install.mockRestore() })
-  expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({ changed: true, application: 'applied', packageResult: { exitCode: 0 } })
+  expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({
+    changed: true, application: 'applied', stage: 'enable', target: 'new-bundle', bundle: 'new-bundle', packageResult: { exitCode: 0 },
+  })
   expect((await manager.listBundles()).find(row => row.name === 'new-bundle')?.enabled).toBe(false)
   expect(await manager.setBundleEnabled('new-bundle', true)).toMatchObject({ application: 'applied' })
   expect((await manager.listPlugins()).find(row => row.patchId === 'new-bundle')?.fiberPhase).toBe('active')
@@ -139,37 +176,34 @@ it('installs only valid bundle declarations and honors installation without acti
   expect((await manager.listBundles()).find(row => row.name === 'another-bundle')?.enabled).toBe(true)
 })
 
-it('cleans a blocked installation and retries only after explicit profile build approval', async () => {
+it('reports blocked scripts after a failed installation and retries only after explicit profile build approval', async () => {
   const { manager, dir, bundle } = await fixture()
   const policy = join(dir, 'pnpm-workspace.yaml')
-  const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
+  const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
     const manifest = readProfileManifest('test', dir)
     const completion = { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
-    if (args[0] === 'remove') {
-      delete manifest.dependencies?.addon
+    manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' }
+    if (run.mock.calls.length === 1) {
+      writeFileSync(policy, 'allowBuilds:\n  native: set this to true or false\n  denied: false\n')
+      completion.exitCode = 1
+      completion.output = 'ERR_PNPM_IGNORED_BUILDS'
     } else {
-      manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' }
-      if (run.mock.calls.length === 1) {
-        writeFileSync(policy, 'allowBuilds:\n  native: set this to true or false\n  denied: false\n')
-        completion.exitCode = 1
-        completion.output = 'ERR_PNPM_IGNORED_BUILDS'
-      } else {
-        expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } })
-        bundle('addon', [])
-      }
+      expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } })
+      bundle('addon', [])
     }
     writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
     return completion
   })
   onTestFinished(() => { run.mockRestore() })
-  expect(await manager.installBundle('addon')).toMatchObject({
-    application: 'failed', pendingBuilds: ['native'], cleanup: { name: 'addon' },
-  })
+  // The failed run's manifest change is put back; the policy pnpm wrote stays, so its undecided names can be offered.
+  expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', pendingBuilds: ['native'] })
   expect(readProfileManifest('test', dir).dependencies).not.toHaveProperty('addon')
   expect(parse(readFileSync(policy, 'utf8'))).toMatchObject({ allowBuilds: { native: 'set this to true or false' } })
   expect(await manager.installBundle('addon', { approvedBuilds: ['denied'] })).toMatchObject({ application: 'failed', changed: false, error: { code: 'stale-approval' } })
-  expect(run).toHaveBeenCalledTimes(2)
-  expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({ application: 'applied', changed: true })
+  expect(run).toHaveBeenCalledTimes(1)
+  expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({
+    application: 'applied', changed: true, approvedBuilds: ['native'],
+  })
   expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon')
 })
 
@@ -184,7 +218,7 @@ it('retains approved policy and reports it as changed when the registry fails be
   expect(parse(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ allowBuilds: { native: true } })
 })
 
-it('runs a real pnpm dependency script only after approval and cleanup retry', async () => {
+it('runs a real pnpm dependency script only after approval and retry', async () => {
   const { manager, dir, profile } = await fixture('startup')
   const addon = join(profile.cwd, 'addon')
   mkdirSync(addon)
@@ -198,7 +232,7 @@ it('runs a real pnpm dependency script only after approval and cleanup retry', a
   policy.set('storeDir', join(profile.cwd, 'store'))
   writeFileSync(join(dir, 'pnpm-workspace.yaml'), String(policy))
   const blocked = await manager.installBundle('file:./addon', { enabled: false })
-  expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', cleanup: { name: 'approval-fixture-addon' } })
+  expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', packageResult: { kind: 'build-blocked' } })
   expect(blocked.pendingBuilds).toHaveLength(1)
   const built = join(dir, 'node_modules', 'approval-fixture-addon', 'built.txt')
   expect(existsSync(built)).toBe(false)
@@ -208,6 +242,14 @@ it('runs a real pnpm dependency script only after approval and cleanup retry', a
   expect(readFileSync(built, 'utf8')).toBe('built')
 })
 
+it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {
+  const { manager, dir } = await fixture()
+  writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy)
+  const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'original pnpm failure', truncated: false, logPath: '/log' })
+  onTestFinished(() => { run.mockRestore() })
+  expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'original pnpm failure' } })
+})
+
 it('unloads before removing packages and retries inactive dependencies whose files are missing', async () => {
   const { manager, dir, ctx } = await fixture()
   const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
@@ -231,18 +273,31 @@ 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?.diagnostic).toContain('cannot resolve profile bundle')
+  const before = readFileSync(join(dir, 'package.json'), 'utf8')
+  expect(await manager.installBundle('partial')).toMatchObject({
+    changed: false, application: 'failed', stage: 'install', target: 'partial',
+    error: { code: 'operation-error', diagnostic: expect.stringContaining('ENOTFOUND') as string },
+    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)
+  expect(install).toHaveBeenCalledTimes(2)
 })
 
 it('keeps saved changes after activation failure and allows a corrected configuration to retry', async () => {
@@ -257,13 +312,14 @@ it('keeps saved changes after activation failure and allows a corrected configur
 })
 
 
-it('reports plain dependencies, missing versions and invalid selected bundles distinctly', async () => {
+it('reports a selected plain dependency as a problem, omits an unselected one, and reports missing versions', async () => {
   const { manager, dir, profile } = await fixture()
   writeFileSync(profile.installAnchor, '{}')
   writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}')
   expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: true, error: { code: 'not-bundle' } })
   expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ application: 'applied' })
-  expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: false, removable: true, error: { code: 'not-bundle' } })
+  // Switched off, a dependency without a bundle patch is a library the page has no business with.
+  expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
   expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
   writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{"name":"core","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}')
   expect((await manager.listBundles())[0]?.version).toBeUndefined()
@@ -322,37 +378,27 @@ it('addresses children inside profile groups and marks ambiguous ids read-only',
 
 it.each(['', '-g'])('rejects an invalid installation spec before calling pnpm: %j', async (spec) => {
   const { manager } = await fixture()
-  expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed' })
+  expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed', error: { code: 'invalid-spec' } })
 })
 
-it.each([0, 1])('cleans a newly added non-bundle once and exposes leftovers when cleanup exits %i', async (cleanupExit) => {
+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 (_context, args) => {
+  const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
+    bundle('plain', [])
+    writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
     const manifest = readProfileManifest('test', dir)
-    if (args[0] === 'add') {
-      bundle('plain', [])
-      writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
-      manifest.dependencies = { ...manifest.dependencies, plain: '1' }
-    } else if (cleanupExit === 0) {
-      delete manifest.dependencies?.plain
-      rmSync(join(dir, 'node_modules', 'plain'), { recursive: true })
-    }
+    manifest.dependencies = { ...manifest.dependencies, plain: '1' }
     writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
-    return { exitCode: args[0] === 'add' ? 0 : cleanupExit, output: 'package diagnostic', truncated: false, logPath: join(dir, `${args[0]}.log`) }
+    return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
   })
   onTestFinished(() => { install.mockRestore() })
-  expect(await manager.installBundle('plain')).toMatchObject({ changed: cleanupExit !== 0, application: 'failed', stage: 'install',
-    error: { code: 'not-bundle' }, packageResult: { exitCode: 0 },
-    cleanup: { name: 'plain', packageResult: { exitCode: cleanupExit } },
-    remainingDependencies: cleanupExit === 0 ? [] : ['plain'] })
-  expect(install.mock.calls.map(call => call[1])).toEqual([['add', 'plain'], ['remove', 'plain']])
-  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
-  const leftover = (await manager.listBundles()).find(row => row.name === 'plain')
-  if (cleanupExit === 0) expect(leftover).toBeUndefined()
-  else {
-    expect(leftover).toMatchObject({ enabled: false, removable: true, error: { code: 'not-bundle' } })
-    expect(await manager.removeBundle('plain')).toMatchObject({ stage: 'remove', application: 'failed' })
-  }
+  expect(await manager.installBundle('plain')).toMatchObject({
+    changed: false, application: 'failed', stage: 'install', error: { code: 'not-bundle' }, packageResult: { exitCode: 0 },
+  })
+  // The manifest is put back rather than cleaned through another pnpm run.
+  expect(install).toHaveBeenCalledOnce()
+  expect(readProfileManifest('test', dir)).toMatchObject({ dependencies: { extra: '1.0.0' }, dsh: { profile: { bundles: ['core', 'extra'] } } })
+  expect((await manager.listBundles()).some(row => row.name === 'plain')).toBe(false)
 })
 
 it('never removes an existing dependency after installation validation fails', async () => {
@@ -362,7 +408,6 @@ it('never removes an existing dependency after installation validation fails', a
   onTestFinished(() => { install.mockRestore() })
   const result = await manager.installBundle('extra')
   expect(result).toMatchObject({ application: 'failed', stage: 'install', error: { code: 'not-bundle' } })
-  expect(result.cleanup).toBeUndefined()
   expect(install).toHaveBeenCalledOnce()
   expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
 })
@@ -378,8 +423,7 @@ it('keeps a valid installed bundle when its subsequent activation fails', async
   })
   onTestFinished(() => { install.mockRestore() })
   const result = await manager.installBundle('broken')
-  expect(result).toMatchObject({ application: 'failed', stage: 'enable', packageResult: { exitCode: 0 } })
-  expect(result.cleanup).toBeUndefined()
+  expect(result).toMatchObject({ application: 'failed', stage: 'enable', target: 'broken', bundle: 'broken', packageResult: { exitCode: 0 } })
   expect(install).toHaveBeenCalledOnce()
   expect((await manager.listBundles()).find(row => row.name === 'broken')).toMatchObject({ enabled: true, removable: true })
 })
@@ -421,12 +465,179 @@ it('reports repeated installs as requiring restart and ambiguous package changes
   onTestFinished(() => { install.mockRestore() })
   expect(await manager.installBundle('extra')).toMatchObject({ changed: false, application: 'restart-required' })
   expect(await manager.installBundle('extra@1')).toMatchObject({ changed: false, application: 'restart-required' })
-  expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed' })
+  expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed', error: { code: 'ambiguous-install' } })
   install.mockImplementationOnce(async () => {
     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', error: { code: 'ambiguous-install' } })
+  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', execution: 'service' }))
+  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')
+  expect(changes).toEqual([{ reason: 'install' }, { reason: 'remove' }])
+})
+
+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)
+  const cancelled = await run
+  expect(cancelled).toMatchObject({ application: 'cancelled', changed: false, stage: 'install', packageResult: { exitCode: 1 } })
+  expect(cancelled.error).toBeUndefined()
+  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: { 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.', 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 each manager operation as a change, and a patch generation applied outside it not at all', async () => {
+  const { ctx, manager, profile } = await fixture('startup')
+  const changes: PluginChange[] = []
+  ctx.on('plugin-manager/changed', (change) => { changes.push(change) })
+  await reconcileProfilePatches(ctx, readProfilePatches('test', profile), 'test')
+  expect(changes).toEqual([])
+  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
+  await manager.setPluginEnabled(id, false)
+  expect(changes).toEqual([{ reason: 'plugin' }])
+  await manager.setBundleEnabled('extra', false)
+  expect(changes).toEqual([{ reason: 'plugin' }, { reason: 'bundle' }])
 })
 
 it('handles missing patch files and retains non-Error package diagnostics', async () => {
@@ -463,6 +674,29 @@ it('refuses removal of a hot-installed bundle after HMR is disabled', async () =
   expect(await manager.removeBundle('later')).toMatchObject({ changed: false, application: 'failed' })
 })
 
+it('offers the launcher\'s optional bundles switched off and never removable', async () => {
+  const { manager, profile } = await fixture()
+  // The launcher names the bundles the installation ships; the fixture supplies one of them from the
+  // installation's own node_modules, which the resolver consults before the profile's and before the repository's.
+  const offered = OPTIONAL_BUNDLES[0]!
+  const supplied = join(profile.home, 'node_modules', offered)
+  mkdirSync(supplied, { recursive: true })
+  writeFileSync(join(supplied, 'package.json'), JSON.stringify({
+    name: offered, version: '3.0.0', description: 'Package one-liner.', dsh: { bundle: { patch: './cordis.patch.yml' } },
+  }))
+  writeFileSync(join(supplied, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'offered-row', name: './plugin.mjs', config: { service: 'offeredProbe' } }] }]))
+  writeFileSync(join(supplied, 'plugin.mjs'), 'export function apply(ctx, config) { ctx.provide(config?.service ?? "offeredProbe", true) }\n')
+  writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { [offered]: '3.0.0' } }))
+  expect((await manager.listBundles()).find(row => row.name === offered)).toEqual({
+    name: offered, version: '3.0.0', description: 'Package one-liner.',
+    enabled: false, installed: false, optional: true, removable: false,
+    rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [],
+  })
+  expect(await manager.setBundleEnabled(offered, true)).toMatchObject({ application: 'applied' })
+  expect((await manager.listBundles()).find(row => row.name === offered)).toMatchObject({ enabled: true, optional: true, removable: false })
+  expect(await manager.removeBundle(offered)).toMatchObject({ changed: false, application: 'failed' })
+})
+
 it('omits installation-owned plain packages from the bundle inventory', async () => {
   const { manager, dir, profile, bundle } = await fixture()
   bundle('installation-plain', [])
@@ -471,48 +705,6 @@ it('omits installation-owned plain packages from the bundle inventory', async ()
   expect((await manager.listBundles()).some(row => row.name === 'installation-plain')).toBe(false)
 })
 
-it.each(['throw', 'remove-manifest-dependencies', 'invalid-manifest'] as const)('preserves the original installation failure when cleanup encounters %s', async (failure) => {
-  const { manager, dir, profile } = await fixture()
-  writeFileSync(profile.installAnchor, '{}')
-  const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
-    if (args[0] === 'add') {
-      const manifest = readProfileManifest('test', dir)
-      manifest.dependencies = { ...manifest.dependencies, partial: '1' }
-      writeFileSync(join(dir, 'package.json'), failure === 'invalid-manifest' ? '{' : JSON.stringify(manifest))
-    } else if (failure === 'throw') throw new Error('cleanup unavailable')
-    else writeFileSync(join(dir, 'package.json'), '{}')
-    return { exitCode: args[0] === 'add' ? 42 : 0, output: 'fetch failed', truncated: false, logPath: '/operation.log' }
-  })
-  onTestFinished(() => { install.mockRestore() })
-  const result = await manager.installBundle('partial')
-  expect(result).toMatchObject({ application: 'failed', error: { code: 'operation-error', diagnostic: 'fetch failed' } })
-  if (failure === 'throw') {
-    expect(result.cleanup).toMatchObject({ name: 'partial', error: { diagnostic: 'cleanup unavailable' } })
-    expect(result.remainingDependencies).toEqual(['partial'])
-  } else if (failure === 'invalid-manifest') {
-    expect(result.warnings?.[0]).toContain('SyntaxError')
-    expect(install).toHaveBeenCalledOnce()
-  } else expect(result.remainingDependencies).toEqual([])
-})
-
-it.each(['ambiguous', 'selected', 'installation-owned'] as const)('does not clean newly declared dependencies that are %s', async (reason) => {
-  const { manager, dir, profile } = await fixture()
-  const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
-    const manifest = readProfileManifest('test', dir)
-    manifest.dependencies = { ...manifest.dependencies, partial: '1', ...reason === 'ambiguous' ? { another: '1' } : {} }
-    if (reason === 'selected') manifest.dsh!.profile!.bundles!.push('partial')
-    if (reason === 'installation-owned') writeFileSync(profile.installAnchor, '{"dependencies":{"partial":"1"}}')
-    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
-    return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: '/operation.log' }
-  })
-  onTestFinished(() => { install.mockRestore() })
-  const result = await manager.installBundle('partial')
-  expect(result.application).toBe('failed')
-  expect(result.cleanup).toBeUndefined()
-  expect(result.remainingDependencies).toContain('partial')
-  expect(install).toHaveBeenCalledOnce()
-})
-
 it('does not delete a bundle retained by a higher-priority overlay', async () => {
   const { ctx, manager, overlays, dir } = await fixture()
   const entry = [...ctx.loader.entries()].find(row => row.id === 'include:managed')!
@@ -550,11 +742,3 @@ it('applies watched configuration while pnpm installation is still running', asy
   expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra', 'new-bundle'])
   expect(ctx.get('managedProbe')).toBeUndefined()
 })
-
-it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {
-  const { manager, dir } = await fixture()
-  writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy)
-  const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'original pnpm failure', truncated: false, logPath: '/log' })
-  onTestFinished(() => { run.mockRestore() })
-  expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'original pnpm failure' } })
-})

+ 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) }))
@@ -211,3 +211,25 @@ it('settles inherited CLI descriptors without requiring captured streams', async
   ) as unknown as ReturnType<typeof result>)
   expect(await runPluginCommand(context, ['approve-builds'], { execution: 'cli', outputBytes: 100 })).toMatchObject({ exitCode: 0, output: '' })
 })
+
+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

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

+ 1 - 0
packages/bundle/web-app/package.json

@@ -79,6 +79,7 @@
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^",

+ 2 - 2
packages/client/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/README.md
-README.md: 75b33f62031fdd02e1594bbe6042df4735362183
-README.zh.md: 3cbd63f46ee3f0177b5b31715448a79187df1b02
+README.md: 42b9fc8bf7592a9e9d47281cf11c602a09028136
+README.zh.md: 5e135c26ec37579026352402b73cc021eafcbc68

+ 1 - 0
packages/client/README.md

@@ -72,6 +72,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas | — |
 | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — |
 | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — |
+| [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — |
 | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |

+ 1 - 0
packages/client/README.zh.md

@@ -72,6 +72,7 @@ kind: "package-group"
 | [`ui-settings/`](ui-settings/README.zh.md) | 承载设置界面及其扩展区域 | — |
 | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — |
 | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — |
+| [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — |
 | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — |

+ 2 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -153,6 +153,7 @@ export const zh = {
   'queue.steerFailed': '插话发送失败,请重试。',
   'terminal.signal': '信号 {signal}',
   'terminal.exitCode': '退出码 {code}',
+  'terminal.noExitCode': '未正常退出',
   'terminal.running': '运行中',
   'terminal.failed': '失败',
   'terminal.done': '已完成',
@@ -313,6 +314,7 @@ export const en = {
   'queue.steerFailed': 'Steering failed. Try again.',
   'terminal.signal': 'signal {signal}',
   'terminal.exitCode': 'exit code {code}',
+  'terminal.noExitCode': 'no exit code',
   'terminal.running': 'Running',
   'terminal.failed': 'Failed',
   'terminal.done': 'Done',

+ 6 - 0
packages/client/ui-plugin-manager/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: 313ebf7df21ea73c0c20b1c84cac6767c2079037
+README.zh.md: 81b15b459d8bea4721ed2f84607b1c737101b3b8

+ 108 - 0
packages/client/ui-plugin-manager/README.md

@@ -0,0 +1,108 @@
+---
+description: "Manage the profile's plugin bundles and their rows from the Web sidebar."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-plugin-manager
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Use the **Plugins** entry in the Web sidebar to manage the profile's installed bundles and the official bundles the installation ships switched off. 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
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+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. **Built in** comes first and lists the bundles the installation ships for switching on, each tagged official, off until switched on and without an uninstall; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section.
+
+### Installing a bundle
+
+**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **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 name, 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. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
+
+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.
+
+### Switching a bundle
+
+A bundle's page shows its full package name under the title, the spec that installs it elsewhere. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, 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. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The installation's own bundles are not on the page; the Settings Plugins section's Plugin list tab inspects them.
+
+### Switching one row of a bundle
+
+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 profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. 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. A list longer than ten rows gets a filter over the row ids.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+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. Display text comes from package metadata and the page's dictionary.
+
+### The store
+
+`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>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+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 `pluginManager.*` and `pluginInventory.*`.
+- [plugin-manager](../../boot/plugin-manager/README.md) — the Host-side manager this page drives.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the package is a browser-side management surface that registers nothing model-facing.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+These limits define the reach of the management view; they are current package constraints.
+
+- **Only bundles are managed** — a dependency without a bundle patch is refused before it installs; one the profile already holds is left off the page unless the profile selects it, and 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.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>
+
+**Runtime invariant:** No companion is published. This package owns a sidebar panel over Host-owned facts.

+ 108 - 0
packages/client/ui-plugin-manager/README.zh.md

@@ -0,0 +1,108 @@
+---
+description: "从 Web 侧栏管理 profile 的插件组合包及其行。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-plugin-manager
+
+[English](README.md) | 中文
+
+## 概述
+
+使用 Web 侧栏的**插件**入口管理 profile 已安装的组合包,以及安装随附、默认关闭的官方组合包。可以启停组合包及其行、在 Host 读出 spec 指向什么之后安装组合包、查看 pnpm 输出、停止一次运行,并启用它新增的包。卸载会要求确认。全局配置仍在设置中编辑。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置**排在前面,列出安装随附、供开启的组合包,每个带官方标签,开启前保持关闭,且没有卸载;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。
+
+### 安装一个组合包
+
+**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
+
+安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。
+
+### 切换一个组合包
+
+组合包页面在标题下方显示完整包名,也就是在别处安装它所需的 spec。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。随安装提供的组合包不在本页;设置中「插件」分区的「插件列表」标签页负责查看它们。
+
+### 切换组合包里的一行
+
+组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行按 Host 运行它们的 fiber 阶段显示状态。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按行 id 筛选的输入框。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+插件管理依据 profile 的依赖记录:已安装组合包可启停、可移除,随安装提供的组合包保持锁定。这一区分不决定启动失败策略。
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+### 注册
+
+浏览器插件通过 `ctx.slots.inject()` 注册 `plugins` 侧栏入口与它的 `main` 面板,使两者跟随 slot 延迟声明、本地化变化与销毁。页面为全局页面,不属于任何 Session。显示文本来自包元信息与页面字典。
+
+### store
+
+`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>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+这些页面覆盖侧栏、Remote 调用与 Host 侧管理器。
+
+- [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 影响
+
+无;本包既不组装也不发送 provider 请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+这些限制界定了管理视图的范围;它们是当前包的约束。
+
+- **只管理组合包**——没有组合包 patch 的依赖在安装前就被拒绝;profile 里已有的这类依赖不上页面,除非 profile 选中了它;加载普通插件模块仍是文件操作。
+- **行只显示阶段,不显示原因**——失败的行只显示为失败,没有 Host 的错误文本;Host 日志里有。
+- **一次只能安装一个**——对话框一次运行一个 pnpm 命令;第二个 spec 要等前一个完成。
+- **没有版本选择器**——spec 按 pnpm 接受的写法输入;页面不列出注册表版本,也不提供升级。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>
+
+**运行时不变式:** 不发布伴生检查。本包只拥有一个基于 Host 事实的侧栏面板。

+ 71 - 0
packages/client/ui-plugin-manager/package.json

@@ -0,0 +1,71 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-plugin-manager",
+  "description": "Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages",
+  "version": "0.1.6-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-plugin-manager"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-api-remotes",
+        "@deepseek-ai/dsh-client-locale",
+        "@deepseek-ai/dsh-client-ui-layout",
+        "@deepseek-ai/dsh-client-ui-sidebar"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-plugin-manager": "workspace:^",
+    "@testing-library/react": "^16.1.0",
+    "@types/react": "~18.3.1",
+    "clsx": "^2.1.1",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "@deepseek-ai/dsh-util-crypto": "workspace:^"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 1074 - 0
packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css

@@ -0,0 +1,1074 @@
+/* Plugin manager page: a centred column with its header, notices, and the package cards. */
+
+.page {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 32px;
+  box-sizing: border-box;
+  height: 100%;
+  overflow: auto;
+  padding: 28px clamp(24px, 4vw, 48px) 48px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.page > * {
+  width: 100%;
+  max-width: 960px;
+}
+
+.pageHead {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.pageTitle {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 500;
+  line-height: 28px;
+}
+
+.pageIntro {
+  margin: 4px 0 0;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 16px;
+}
+
+.status,
+.failure p,
+.empty {
+  margin: 0;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.failure {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.banner {
+  margin: 0;
+  padding: 8px 12px;
+  border-radius: 10px;
+  font-size: 12px;
+  line-height: 18px;
+  background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent);
+  color: var(--dsw-alias-label-primary);
+}
+
+.group {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.groupHead {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+}
+
+.groupTitle {
+  margin: 0;
+  font-size: 14px;
+  line-height: 22px;
+  font-weight: 500;
+}
+
+.count {
+  font-size: 14px;
+  color: var(--dsw-alias-label-caption);
+  font-variant-numeric: tabular-nums;
+}
+
+/* The group's explanation, folded behind a hover/focus tooltip. */
+.groupInfo {
+  display: inline-flex;
+  align-items: center;
+  align-self: center;
+  color: var(--dsw-alias-label-caption);
+}
+
+.groupInfo:hover,
+.groupInfo:focus-visible {
+  color: var(--dsw-alias-label-secondary);
+}
+
+/* Center the status label geometrically: beside larger titles the capsule's line-box offset shows. */
+.statusTag {
+  height: 20px;
+  padding: 0 8px;
+  line-height: 1;
+}
+
+/* The package an install just enabled: a ring that fades while the list scrolls to it. */
+.card[data-plugin-highlight] {
+  animation: dsh-plugin-highlight 2400ms ease-out;
+}
+
+@keyframes dsh-plugin-highlight {
+  0%,
+  55% {
+    box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
+  }
+
+  100% {
+    box-shadow: 0 0 0 0 transparent;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .card[data-plugin-highlight] {
+    animation: none;
+    box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
+  }
+}
+
+.iconButton:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 1px;
+}
+
+.cards {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.cards {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.card {
+  min-width: 0;
+  /* The hover surface reaches 8px past the content on both sides while the
+     row's content stays aligned with the page header. */
+  margin: 0 -8px;
+  border-radius: 12px;
+  /* The page itself scrolls on the base surface. Anything that scrolls inside
+     a card draws the l2 thumb (ui-theme styles/scrollbar.css rebinding contract). */
+  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
+  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
+}
+
+.cardHead {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 8px;
+}
+
+.cardIcon {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+  width: 48px;
+  height: 48px;
+  /* The same hairline frame as the component rows below. */
+  border: 0.5px solid var(--dsw-alias-border-l3);
+  border-radius: 10px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.cardMain {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.titleRow {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  min-width: 0;
+}
+
+.cardTitle {
+  font-size: 14px;
+  line-height: 20px;
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+/*
+ * A package card opens its page: the title button stretches over the whole
+ * card, and the controls at the card's end sit above that stretch, so the
+ * switch and the Add to… menu keep their own clicks.
+ */
+.cardLink {
+  position: relative;
+}
+
+.cardLink:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.cardOpen {
+  max-width: 100%;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  font-weight: 500;
+  text-align: left;
+  cursor: pointer;
+}
+
+.cardOpen::after {
+  content: '';
+  position: absolute;
+  inset: 0;
+  border-radius: 12px;
+}
+
+.cardOpen:focus-visible {
+  outline: none;
+}
+
+.cardOpen:focus-visible::after {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 2px;
+}
+
+.cardDesc {
+  font-size: 13px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 1;
+  overflow: hidden;
+}
+
+.cardEnd {
+  position: relative;
+  z-index: 1;
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  gap: 8px;
+}
+
+/* The toolbar's icon action, round like the message-row actions. */
+.iconButton {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  border: 0;
+  border-radius: 28px;
+  background: transparent;
+  color: var(--dsw-alias-label-caption);
+  cursor: pointer;
+}
+
+.iconButton:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-secondary);
+}
+
+.iconButton:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
+
+.iconWrap {
+  display: inline-flex;
+}
+
+/* The destructive text action in a card body: red ink inside a 30% red frame,
+   and the outline hover rebinds to a red wash instead of the gray one. */
+.danger {
+  color: var(--dsw-alias-state-error-primary);
+  border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent);
+  --dsw-alias-interactive-bg-hover: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
+}
+
+/* Retry, uninstall, and the switch at the end of the page's head. */
+.detailActions {
+  display: flex;
+  flex: none;
+  align-items: center;
+  gap: 16px;
+}
+
+.actions {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+/* The delete of a row the person added: red on its own, not only on hover. */
+.deleteButton {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  border: 0;
+  border-radius: 8px;
+  background: transparent;
+  color: var(--dsw-alias-state-error-primary);
+  cursor: pointer;
+}
+
+.deleteButton:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover));
+}
+
+.deleteButton:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
+
+.deleteButton:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 1px;
+}
+
+
+.reason {
+  margin: 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-state-error-primary);
+  overflow-wrap: anywhere;
+  /* The Host's captured tail, when no output streamed, keeps its lines. */
+  white-space: pre-wrap;
+}
+
+.partsHead .subLabel {
+  margin: 0;
+}
+
+.partsFilter {
+  width: 200px;
+}
+
+.partsFilter:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 1px;
+}
+
+/* Wider than the default dialog: pnpm's lines are long, and the terminal keeps them unwrapped. The card never
+   outgrows the viewport; the running screens scroll inside it so the head's controls stay reachable. */
+.installDialog {
+  width: min(600px, 100%);
+  max-height: calc(100vh - 48px);
+}
+
+.installBody {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  min-width: 0;
+}
+
+/* Where pnpm installs: the profile directory the first run names. */
+.installLocation {
+  margin: 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+  overflow-wrap: anywhere;
+}
+
+.installField {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  font-size: 13px;
+}
+
+.installField input[type='text'] {
+  height: 36px;
+  padding: 0 12px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 10px;
+  background: var(--dsw-alias-bg-layer-3);
+  font: inherit;
+  font-size: 13px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.installField input[aria-invalid='true'] {
+  border-color: var(--dsw-alias-state-error-primary);
+}
+
+/* The guide under the field: opened by a text button, it says what an ID is and shows the three forms with examples. */
+.guideToggle {
+  display: inline-flex;
+  align-items: center;
+  align-self: flex-start;
+  gap: 4px;
+  padding: 0;
+  border: 0;
+  background: none;
+  font: inherit;
+  font-size: 12.5px;
+  color: var(--dsw-alias-label-secondary);
+  cursor: pointer;
+}
+
+.guideToggle:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.guideChevron {
+  transition: transform 160ms ease;
+}
+
+.guideToggle[aria-expanded='true'] .guideChevron {
+  transform: rotate(180deg);
+}
+
+.guide {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  padding: 12px 14px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 12px;
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.guideIntro,
+.guideHint {
+  margin: 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+/* The security warning that closes the guide: plugins run with the person's own permissions. */
+.guideSafety {
+  display: flex;
+  align-items: flex-start;
+  gap: 6px;
+  margin: 0;
+  padding: 8px 10px;
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent);
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary));
+}
+
+.guideSafety > svg {
+  flex: none;
+  margin-top: 2px;
+}
+
+.guideNote {
+  margin: 0;
+  padding: 8px 10px;
+  border-radius: 8px;
+  background: var(--dsw-alias-bg-layer-3);
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.guideList {
+  display: flex;
+  flex-direction: column;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.guideItem {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  padding: 8px 0;
+  border-top: 0.5px solid var(--dsw-alias-border-l4);
+}
+
+.guideIndex {
+  flex: none;
+  width: 20px;
+  height: 20px;
+  border-radius: 999px;
+  corner-shape: round;
+  background: var(--dsw-alias-bg-layer-3);
+  font-size: 11px;
+  line-height: 20px;
+  text-align: center;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.guideMain {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+}
+
+.guideTitle {
+  font-size: 12.5px;
+  font-weight: 600;
+  color: var(--dsw-alias-label-primary);
+}
+
+.guideExample {
+  font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+  font-size: 12px;
+  color: var(--dsw-alias-label-secondary);
+  overflow-wrap: anywhere;
+}
+
+/* What the check refused, under the field. */
+.inputError {
+  margin: -4px 0 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-state-error-primary);
+}
+
+/* The footer's only action spans the dialog, as the primary action of a short form does. */
+.wide {
+  width: 100%;
+  justify-content: center;
+}
+
+/* The installing, installed, and failed screens: a head row, the centred
+   state, the subject card, then the details toggle beside the screen's action. */
+.wizard {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  gap: 16px;
+  min-width: 0;
+  min-height: 0;
+  padding: 16px 20px 20px;
+}
+
+/* Everything under the head: it scrolls when the runs' output makes the screen taller than the card. */
+.wizardScroll {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  gap: 16px;
+  min-height: 0;
+  overflow-y: auto;
+}
+
+.wizardHead {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 24px;
+}
+
+.wizardBack {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 0;
+  border: 0;
+  background: none;
+  font: inherit;
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--dsw-alias-label-primary);
+  cursor: pointer;
+}
+
+.wizardClose {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  padding: 0;
+  border: 0;
+  border-radius: 6px;
+  background: none;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+}
+
+.wizardClose:hover {
+  background: var(--dsw-alias-bg-layer-3);
+  color: var(--dsw-alias-label-primary);
+}
+
+.wizardHero {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 0 4px;
+  text-align: center;
+}
+
+.wizardIcon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 44px;
+  height: 44px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.wizardIcon[data-tone='done'] {
+  color: var(--dsw-alias-state-success-primary);
+}
+
+.wizardIcon[data-tone='failed'] {
+  color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary));
+}
+
+.wizardTitle {
+  margin: 0;
+  font-size: 18px;
+  line-height: 26px;
+  font-weight: 600;
+}
+
+.wizardSub {
+  margin: 0;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-secondary);
+  overflow-wrap: anywhere;
+}
+
+.subject {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 6px;
+  padding: 16px;
+  border: 0.5px solid var(--dsw-alias-border-l3);
+  border-radius: 12px;
+  text-align: center;
+}
+
+.subjectName {
+  margin: 0;
+  font-size: 15px;
+  line-height: 22px;
+  font-weight: 600;
+  overflow-wrap: anywhere;
+}
+
+.subjectDesc,
+.subjectMeta {
+  margin: 0;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-secondary);
+  overflow-wrap: anywhere;
+}
+
+.wizardFoot {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.detailsToggle {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 0;
+  border: 0;
+  background: none;
+  font: inherit;
+  font-size: 13px;
+  color: var(--dsw-alias-label-secondary);
+  cursor: pointer;
+}
+
+.detailsChevron {
+  transition: transform 160ms ease;
+}
+
+.detailsToggle[aria-expanded='true'] .detailsChevron {
+  transform: rotate(180deg);
+}
+
+.detailsBody {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  min-width: 0;
+}
+
+.spinnerLarge {
+  width: 28px;
+  height: 28px;
+  border: 3px solid color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);
+  border-top-color: var(--dsw-alias-label-primary);
+  border-radius: 50%;
+  corner-shape: round;
+  animation: spin 900ms linear infinite;
+}
+
+.spinner {
+  width: 14px;
+  height: 14px;
+  flex: none;
+  border: 2px solid color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);
+  border-top-color: var(--dsw-alias-label-primary);
+  border-radius: 50%;
+  corner-shape: round;
+  animation: spin 900ms linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .spinner,
+  .spinnerLarge {
+    animation: none;
+  }
+
+  .detailsChevron {
+    transition: none;
+  }
+}
+
+.result,
+.resultWarn {
+  margin: 0;
+  padding: 8px 12px;
+  border-radius: 10px;
+  font-size: 13px;
+  line-height: 20px;
+  background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);
+  color: var(--dsw-alias-label-primary);
+}
+
+.resultWarn {
+  background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent);
+}
+
+/* The approval a blocked run asks for: the packages whose install scripts pnpm did not run, and the one action. */
+.approval {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 12px 14px;
+  border: 0.5px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 40%, transparent);
+  border-radius: 12px;
+  background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 8%, transparent);
+}
+
+.approvalTitle {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--dsw-alias-label-primary);
+}
+
+.approvalText {
+  margin: 0;
+  font-size: 12.5px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.approvalCaution {
+  margin: 0;
+  font-size: 12.5px;
+  line-height: 18px;
+  font-weight: 600;
+  color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary));
+}
+
+.approvalList {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.approvalList code {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 6px;
+  background: var(--dsw-alias-bg-layer-3);
+  font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+  font-size: 12px;
+  color: var(--dsw-alias-label-primary);
+}
+
+/* The install terminal: the shared TerminalBlock at the bash card's smaller
+   size, its output capped so an expanded log scrolls inside the dialog. */
+.terminal {
+  --dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
+  --dsl-terminal-line-height: 18px;
+  --dsl-terminal-output-max-height: 240px;
+  margin: 4px 0 0;
+  border: 0.5px solid var(--dsw-alias-border-l1);
+}
+
+.dependents {
+  margin: 8px 0 0;
+  padding-left: 18px;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.dangerButton {
+  --dsw-alias-button-primary-fill: var(--dsw-alias-state-error-primary);
+  --dsw-alias-button-primary-hover: var(--dsw-alias-state-error-primary);
+}
+
+.detail {
+  display: flex;
+  flex-direction: column;
+}
+
+.crumb {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  border: 0;
+  padding: 0;
+  background: none;
+  color: var(--dsw-alias-label-tertiary);
+  font: inherit;
+  font-size: 12.5px;
+  cursor: pointer;
+}
+
+.crumb:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.crumb:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 2px;
+}
+
+.crumbIcon {
+  transform: rotate(90deg);
+}
+
+/* Head row: the pack's icon at one edge, its actions at the other; the name
+   block sits under the icon (figma detail handoff). */
+.detailHead {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin: 32px 0 0;
+}
+
+.detailMain {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  min-width: 0;
+  margin-top: 20px;
+}
+
+.detailTitle {
+  margin: 0;
+  font-size: 20px;
+  line-height: 28px;
+  font-weight: 500;
+}
+
+.versionTag {
+  flex: none;
+  padding: 1px 6px;
+  border-radius: 6px;
+  background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-secondary);
+  font-size: 12px;
+  line-height: 18px;
+  font-variant-numeric: tabular-nums;
+}
+
+.detailDesc {
+  margin: 0;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 14px;
+  line-height: 22px;
+}
+
+/* The package name under the title: the exact spec that installs the package
+   elsewhere, where a title of its own or a shortened name hides it. */
+.detailName {
+  margin: 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-caption);
+  overflow-wrap: anywhere;
+}
+
+.detailName code {
+  font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+}
+
+/* The unknown-package note under the name block. */
+.detail > .detailDesc {
+  margin-top: 12px;
+}
+
+.detailSections {
+  display: flex;
+  flex-direction: column;
+  gap: 32px;
+  margin-top: 32px;
+}
+
+.detailSection {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.sectionHead {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+}
+
+.sectionTitle {
+  margin: 0;
+  font-size: 14px;
+  line-height: 20px;
+  font-weight: 500;
+}
+
+.sectionCount {
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+/* The way into a package's page. */
+.cardArrow {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+/* Rows sit on the page ground, one hairline apart, so they read on a white page too. */
+.rows {
+  display: flex;
+  flex-direction: column;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.row {
+  padding: 12px 2px;
+  border-bottom: 0.5px solid var(--dsw-alias-border-l2);
+}
+
+.row:last-child {
+  border-bottom: 0;
+}
+
+.rowLine {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  min-width: 0;
+}
+
+.rowIcon {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+  width: 40px;
+  height: 40px;
+  border: 0.5px solid var(--dsw-alias-border-l3);
+  border-radius: 10px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.rowMain {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+}
+
+.rowId {
+  font-size: 13.5px;
+  line-height: 20px;
+  font-weight: 500;
+  color: var(--dsw-alias-label-primary);
+  overflow-wrap: anywhere;
+}
+
+.row[data-state='off'] .rowId {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.rowModule {
+  font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+  font-size: 11.5px;
+  line-height: 16px;
+  color: var(--dsw-alias-label-tertiary);
+  overflow-wrap: anywhere;
+}
+
+.rowState {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  gap: 6px;
+  font-size: 12.5px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+  white-space: nowrap;
+}
+
+.row[data-state='failed'] .rowState {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+
+
+.rowFailure {
+  margin: 4px 0 0 56px;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-state-error-primary);
+  overflow-wrap: anywhere;
+}

+ 799 - 0
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -0,0 +1,799 @@
+/**
+ * Global plugin management: cards for the profile's installed bundles and for
+ * the official bundles the installation ships switched off, their row
+ * switches, the install dialog with its guide and 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.
+ */
+
+import { useEffect, useId, useState, type ReactNode } from 'react'
+import type { PluginInstallFailureKind } from '@deepseek-ai/dsh-api-remotes/client'
+import {
+  Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconCloseOutline16, IconCordisPluginOutline14,
+  IconPluginPinwheelOutline16, IconPlusOutline16, IconRefreshOutline16, IconTrashOutline16, IconWarningOutline16,
+  Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast,
+  type StateDotState, type TerminalBlockLabels,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+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 PackageRow, type PackageView,
+  type PluginManagerFace,
+} from './manager-store.ts'
+import { managementText, noticeText, shortName, type Translate } from './presentation.ts'
+import css from './PluginManagerPage.module.css'
+
+/** Full component props assembled by the main slot renderer. */
+export type PluginManagerPageProps =
+  PropsRuntime<'main'>
+  & PropsLocale<'pluginManager'>
+  & InjectFace<PluginManagerFace>
+
+type RowPhase = NonNullable<PackageRow['phase']>
+
+/** How long the list marks a package an install just enabled. */
+const HIGHLIGHT_MS = 2_400
+
+/** How long a toast holds: long enough to read a failure that names what broke. */
+function toastHoldMs(text: string): number {
+  return Math.min(8_000, Math.max(3_000, text.length * 80))
+}
+
+const PHASE_KEYS = {
+  pending: 'rowPhasePending',
+  loading: 'rowPhaseLoading',
+  active: 'rowPhaseActive',
+  failed: 'rowPhaseFailed',
+  unloading: 'rowPhaseUnloading',
+} satisfies Record<RowPhase, PluginManagerLocaleKey>
+
+/** 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',
+  active: 'done',
+  failed: 'error',
+  unloading: 'idle',
+} satisfies Record<RowPhase, StateDotState>
+
+/** The count line over a pack's components: the total, then only the states that occur. */
+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) })] : [],
+    ...off > 0 ? [t('partsCountOff', { count: String(off) })] : [],
+    ...failed > 0 ? [t('partsCountFailed', { count: String(failed) })] : [],
+  ].join(' · ')
+}
+
+/** Switching for a pack's rows: which rows have a write in flight, and the write. */
+interface RowToggles {
+  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: 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={busy || locked}
+      {...row.readOnlyReason === undefined ? {} : { title: managementText({ code: row.readOnlyReason }, t) }}
+      onChange={onChange}
+    />
+  )
+}
+
+/** 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 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, 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 PackageRow[]
+  readonly t: Translate
+  readonly toggle?: RowToggles | undefined
+}): ReactNode {
+  const [filter, setFilter] = useState('')
+  const query = filter.trim().toLowerCase()
+  const shown = query === '' ? rows : rows.filter(row => row.rowId.toLowerCase().includes(query))
+  return (
+    <section className={css.detailSection} data-plugin-rows>
+      <div className={css.sectionHead}>
+        <h4 className={css.sectionTitle}>{t('partsLabel')}</h4>
+        {rows.length === 0 ? null : <span className={css.sectionCount}>{partsSummary(rows, t)}</span>}
+      </div>
+      {rows.length === 0 ? <p className={css.status}>{t('partsEmpty')}</p> : null}
+      {rows.length > ROW_FILTER_THRESHOLD
+        ? (
+          <Input
+            type="search"
+            className={css.partsFilter as string}
+            placeholder={t('partsFilter')}
+            aria-label={t('partsFilter')}
+            value={filter}
+            onChange={(event) => { setFilter(event.target.value) }}
+          />
+        )
+        : null}
+      {rows.length > 0 && shown.length === 0 ? <p className={css.status}>{t('partsFilterEmpty')}</p> : null}
+      {shown.length === 0
+        ? null
+        : (
+          <ul className={css.rows}>
+            {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>
+                  <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>
+  )
+}
+
+/**
+ * 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: PackageView
+  readonly title: string
+  readonly t: Translate
+  readonly busy: boolean
+  readonly onSetEnabled: (enabled: boolean) => void
+}): ReactNode {
+  return (
+    <Switch
+      checked={pkg.enabled}
+      label={t('enableToggle', { name: title })}
+      disabled={busy || pkg.readOnlyReason !== undefined || (!pkg.enabled && pkg.error !== undefined)}
+      {...pkg.readOnlyReason === undefined ? {} : { title: managementText({ code: pkg.readOnlyReason }, t) }}
+      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 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: PackageView
+  readonly t: Translate
+  readonly busy: boolean
+  readonly highlighted: boolean
+  readonly onOpen: () => void
+  readonly onSetEnabled: (enabled: boolean) => void
+}): ReactNode {
+  const title = shortName(pkg.name)
+  const status = packageStatus(pkg)
+  return (
+    <li
+      className={`${css.card} ${css.cardLink}`}
+      data-plugin-package={pkg.name}
+      data-plugin-status={status}
+      {...highlighted ? { 'data-plugin-highlight': '' } : {}}
+    >
+      <div className={css.cardHead}>
+        <span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
+        <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>
+            {pkg.optional ? <Tag className={css.statusTag} tone="info">{t('statusOfficial')}</Tag> : null}
+            {status === 'problem' ? <Tag className={css.statusTag} 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>
+  )
+}
+
+/**
+ * One package's page: the crumb back to the list; its icon with its switch
+ * and, for a package the profile installed, uninstall; its title beside its
+ * version tag, its official tag, and its problem tag; the
+ * package name the title stands for, which is what installs it elsewhere;
+ * its one-liner; the Host's problem when it reports one; and its rows with
+ * their switches.
+ */
+function PackageDetail({
+  pkg, t, busy, rowBusy,
+  onBack, onSetEnabled, onUninstall, onSetRowEnabled,
+}: {
+  readonly pkg: PackageView
+  readonly t: Translate
+  readonly busy: boolean
+  /** Whether a row has a write in flight. */
+  readonly rowBusy: (row: PackageRow) => boolean
+  readonly onBack: () => void
+  readonly onSetEnabled: (enabled: boolean) => void
+  readonly onUninstall: () => void
+  readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void
+}): ReactNode {
+  const title = shortName(pkg.name)
+  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}>
+        <IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
+        <span>{t('crumbRoot')}</span>
+      </button>
+      <div className={css.detailHead}>
+        <span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
+        <div className={css.detailActions}>
+          {pkg.installed
+            ? (
+              <Button
+                variant="outline"
+                size="sm"
+                className={css.danger}
+                icon={<IconTrashOutline16 size={13} />}
+                aria-label={t('uninstallLabel', { name: title })}
+                disabled={busy || pkg.readOnlyReason !== undefined}
+                onClick={onUninstall}
+              >
+                {t('uninstall')}
+              </Button>
+            )
+            : null}
+          <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
+        </div>
+      </div>
+      <div className={css.detailMain}>
+        <div className={css.titleRow}>
+          <h3 className={css.detailTitle}>{title}</h3>
+          {pkg.version === undefined ? null : <span className={css.versionTag} data-plugin-version>{t('versionTag', { version: pkg.version })}</span>}
+          {pkg.optional ? <Tag className={css.statusTag} tone="info">{t('statusOfficial')}</Tag> : null}
+          {status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
+        </div>
+        <p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
+        <p className={css.detailDesc}>{pkg.description ?? t('noDescription')}</p>
+      </div>
+      {pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
+      {pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
+      <div className={css.detailSections}>
+        <RowsSection
+          rows={pkg.rows}
+          t={t}
+          toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined}
+        />
+      </div>
+    </div>
+  )
+}
+
+/** 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
+
+/** The install terminal's display copy, from the tab's dictionary. */
+function terminalLabels(t: Translate): TerminalBlockLabels {
+  return {
+    /* v8 ignore next -- the Host reports a killed pnpm as a null exit code, never a signal name; the label interface needs one */
+    signal: signal => t('terminalSignal', { signal }),
+    exitCode: code => t('terminalExitCode', { code: String(code) }),
+    noExitCode: t('terminalNoExitCode'),
+    running: t('terminalRunning'),
+    failed: t('terminalFailed'),
+    done: t('terminalDone'),
+    copy: t('terminalCopy'),
+    copied: t('terminalCopied'),
+    noOutput: t('terminalNoOutput'),
+    collapseAria: t('terminalCollapseAria'),
+    collapse: t('terminalCollapse'),
+    expandAria: hidden => t('terminalExpandAria', { n: String(hidden) }),
+    expand: hidden => t('terminalExpand', { n: String(hidden) }),
+  }
+}
+
+/** The sentence under the field for a spec the check refused. */
+const INPUT_PROBLEM_KEYS = {
+  'invalid-spec': 'installProblemInvalid',
+  'already-installed': 'installProblemInstalled',
+  'not-found': 'installProblemNotFound',
+  'not-a-package': 'installProblemNotPackage',
+  'not-a-bundle': 'installProblemNotBundle',
+  'network': 'installProblemNetwork',
+  'unknown': 'installProblemUnknown',
+} satisfies Record<InstallInputError['problem'], PluginManagerLocaleKey>
+
+/** One row of the install guide: a spec form's title, its example, and where the person finds it. */
+interface GuideExample {
+  readonly key: string
+  readonly titleKey: PluginManagerLocaleKey
+  readonly exampleKey: PluginManagerLocaleKey
+  readonly hintKey: PluginManagerLocaleKey
+}
+
+/** The spec forms the install guide shows, each with an example the person can drop into the field. */
+const GUIDE_EXAMPLES = [
+  { key: 'id', titleKey: 'installGuideIdTitle', exampleKey: 'installGuideIdExample', hintKey: 'installGuideIdHint' },
+  { key: 'git', titleKey: 'installGuideGitTitle', exampleKey: 'installGuideGitExample', hintKey: 'installGuideGitHint' },
+  { key: 'path', titleKey: 'installGuidePathTitle', exampleKey: 'installGuidePathExample', hintKey: 'installGuidePathHint' },
+] as const satisfies readonly GuideExample[]
+
+/** The one-line reading of a classified pnpm failure. */
+const FAILURE_KIND_KEYS = {
+  'pnpm-missing': 'installFailurePnpmMissing',
+  'timeout': 'installFailureTimeout',
+  'not-found': 'installFailureNotFound',
+  'no-matching-version': 'installFailureNoMatchingVersion',
+  'network': 'installFailureNetwork',
+  'disk-full': 'installFailureDiskFull',
+  'permission': 'installFailurePermission',
+  'build-blocked': 'installFailureBuildBlocked',
+  'integrity': 'installFailureIntegrity',
+  'unknown': 'installFailureGeneric',
+} satisfies Record<PluginInstallFailureKind, PluginManagerLocaleKey>
+
+/** The heading of each screen past the spec. */
+const SCREEN_TITLE_KEYS = {
+  starting: 'installStarting',
+  running: 'installingTitle',
+  cancelling: 'installCancelling',
+  applying: 'installApplying',
+  done: 'installedTitle',
+  failed: 'installFailedTitle',
+} satisfies Record<Exclude<InstallState['phase'], 'idle' | 'checking'>, PluginManagerLocaleKey>
+
+/** What the spec's kind reads as when the package carries no description of its own. */
+const SUBJECT_KIND_KEYS = {
+  registry: undefined,
+  path: 'installSubjectPath',
+  git: 'installSubjectGit',
+  tarball: 'installSubjectTarball',
+} satisfies Record<InstallSubject['kind'], PluginManagerLocaleKey | undefined>
+
+/**
+ * The failed screen's one line: a pnpm failure by its kind, a refusal by its
+ * code, any other 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')
+  // Blocked scripts the Host could not name leave the person to allow them in the profile's pnpm settings by hand.
+  if (failure.kind === 'build-blocked' && !failure.pendingBuilds?.length) return t('installFailureBuildBlockedManual')
+  if (failure.kind !== undefined) return t(FAILURE_KIND_KEYS[failure.kind])
+  if (failure.code !== undefined) return managementText({ code: failure.code, diagnostic: failure.reason }, t)
+  return failure.reason === '' ? t('installFailureGeneric') : failure.reason
+}
+
+/** The package the install is about: its name, one-liner, and version, as the Host read them before installing. */
+function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonly t: Translate }): ReactNode {
+  const title = subject.name ?? subject.spec
+  const kindKey = SUBJECT_KIND_KEYS[subject.kind]
+  const description = subject.description ?? (kindKey === undefined ? undefined : t(kindKey))
+  return (
+    <div className={css.subject} data-install-subject={subject.spec}>
+      <p className={css.subjectName}>{title}</p>
+      {description === undefined ? null : <p className={css.subjectDesc}>{description}</p>}
+      {subject.version === undefined ? null : <p className={css.subjectMeta}>{t('installVersion', { version: subject.version })}</p>}
+    </div>
+  )
+}
+
+/**
+ * The install dialog: the spec and its check, then the installing, installed,
+ * and failed screens over the same subject card. A failed run that left
+ * install scripts undecided shows them for approval in place of plain retry.
+ */
+function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onToggleDetails, onEnableNow, onApproveBuilds }: {
+  readonly install: InstallState
+  readonly t: Translate
+  readonly onClose: () => void
+  readonly onEditSpec: (text: string) => void
+  readonly onRun: () => void
+  readonly onCancel: () => void
+  readonly onToggleDetails: () => void
+  readonly onEnableNow: () => void
+  readonly onApproveBuilds: () => void
+}): ReactNode {
+  const errorId = useId()
+  const guideId = useId()
+  const approvalId = useId()
+  const [guideOpen, setGuideOpen] = useState(false)
+  const { phase } = install
+  if (phase === 'idle' || phase === 'checking') {
+    const checking = phase === 'checking'
+    const empty = install.spec.trim() === ''
+    return (
+      <Modal
+        open={install.open}
+        onClose={onClose}
+        title={t('installTitle')}
+        closeLabel={t('close')}
+        description={t('installDescription')}
+        className={css.installDialog as string}
+        footer={(
+          <Button variant="primary" className={css.wide} disabled={checking || empty} aria-busy={checking} onClick={onRun}>
+            {checking ? <span className={css.spinner} aria-hidden="true" /> : null}
+            {t(checking ? 'installChecking' : 'installRun')}
+          </Button>
+        )}
+      >
+        <div className={css.installBody}>
+          <label className={css.installField}>
+            <span>{t('installSpecLabel')}</span>
+            <input
+              type="text"
+              value={install.spec}
+              placeholder={t('installSpecPlaceholder')}
+              disabled={checking}
+              aria-invalid={install.inputError !== null}
+              aria-describedby={install.inputError === null ? undefined : errorId}
+              onChange={(event) => { onEditSpec(event.currentTarget.value) }}
+              onKeyDown={(event) => { if (event.key === 'Enter' && !empty && !checking) onRun() }}
+            />
+          </label>
+          {install.inputError === null
+            ? null
+            : <p id={errorId} className={css.inputError} role="alert">{t(INPUT_PROBLEM_KEYS[install.inputError.problem], { reason: install.inputError.reason })}</p>}
+          <button
+            type="button"
+            className={css.guideToggle}
+            aria-expanded={guideOpen}
+            aria-controls={guideId}
+            onClick={() => { setGuideOpen(open => !open) }}
+          >
+            <IconChevronDownOutline14 className={css.guideChevron} aria-hidden="true" />
+            <span>{t(guideOpen ? 'installGuideHide' : 'installGuideToggle')}</span>
+          </button>
+          {guideOpen
+            ? (
+              <div id={guideId} className={css.guide} data-install-guide>
+                <p className={css.guideIntro}>{t('installGuideIntro')}</p>
+                <p className={css.guideNote}>{t('installGuideIdNote')}</p>
+                <ol className={css.guideList}>
+                  {GUIDE_EXAMPLES.map(({ key, titleKey, exampleKey, hintKey }, index) => (
+                    <li key={key} className={css.guideItem}>
+                      <span className={css.guideIndex} aria-hidden="true">{index + 1}</span>
+                      <div className={css.guideMain}>
+                        <span className={css.guideTitle}>{t(titleKey)}</span>
+                        <code className={css.guideExample}>{t(exampleKey)}</code>
+                        <span className={css.guideHint}>{t(hintKey)}</span>
+                      </div>
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        aria-label={t('installGuideFillAria', { example: t(exampleKey) })}
+                        disabled={checking}
+                        onClick={() => { onEditSpec(t(exampleKey)) }}
+                      >
+                        {t('installGuideFill')}
+                      </Button>
+                    </li>
+                  ))}
+                </ol>
+                <p className={css.guideSafety} role="note">
+                  <IconWarningOutline16 size={14} aria-hidden="true" />
+                  <span>{t('installGuideSafety')}</span>
+                </p>
+              </div>
+            )
+            : null}
+        </div>
+      </Modal>
+    )
+  }
+  const heading = t(SCREEN_TITLE_KEYS[phase])
+  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?.cancelUnconfirmed === true ? install.failure.reason : undefined
+  const pendingBuilds = phase === 'failed' ? install.failure?.pendingBuilds ?? [] : []
+  const approvable = pendingBuilds.length > 0
+  const firstRun = install.runs[0]
+  return (
+    <Modal open={install.open} onClose={onClose} title={heading} headless className={css.installDialog as string}>
+      <div className={css.wizard} data-install-phase={phase}>
+        <div className={css.wizardHead}>
+          {phase === 'done'
+            ? <span />
+            : (
+              <button type="button" className={css.wizardBack} aria-label={t('installEditAria')} disabled={!stoppable} onClick={onCancel}>
+                <IconChevronLeftOutline14 aria-hidden="true" />
+                <span>{t('installEdit')}</span>
+              </button>
+            )}
+          <button type="button" className={css.wizardClose} aria-label={t('close')} disabled={pending} onClick={onClose}>
+            <IconCloseOutline16 size={14} />
+          </button>
+        </div>
+        <div className={css.wizardScroll}>
+          <div className={css.wizardHero}>
+            <span className={css.wizardIcon} data-tone={pending ? 'pending' : phase} aria-hidden="true">
+              {pending
+                ? <span className={css.spinnerLarge} />
+                : phase === 'done' ? <IconCheckOutline16 size={28} /> : <IconWarningOutline16 size={28} />}
+            </span>
+            <h2 className={css.wizardTitle} role={phase === 'failed' ? 'alert' : 'status'}>{heading}</h2>
+            {phase === 'failed' ? <p className={css.wizardSub}>{failureText(install.failure, t)}</p> : null}
+            {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} />}
+          {approvable
+            ? (
+              <section className={css.approval} role="group" aria-labelledby={approvalId} data-install-approval>
+                <h3 id={approvalId} className={css.approvalTitle}>{t('installApprovalTitle')}</h3>
+                <p className={css.approvalText}>{t('installApprovalDescription')}</p>
+                <ul className={css.approvalList}>
+                  {pendingBuilds.map(name => <li key={name}><code>{name}</code></li>)}
+                </ul>
+                <p className={css.approvalText}>{t('installApprovalConsequence')}</p>
+                <p className={css.approvalCaution}>{t('installApprovalCaution')}</p>
+                <Button variant="primary" className={css.wide} onClick={onApproveBuilds}>{t('installApproveAndRetry')}</Button>
+              </section>
+            )
+            : null}
+          {phase === 'done' && install.installed === null
+            ? <p className={css.result} role="status">{t('installDoneNothing')}</p>
+            : null}
+          {phase === 'done' && install.restartRequired
+            ? <p className={css.resultWarn} role="status">{t('installDoneRestart')}</p>
+            : null}
+          {phase === 'done' && install.approvedBuilds.length > 0
+            ? <p className={css.result} role="status">{t('installDoneApproved', { names: install.approvedBuilds.join(', ') })}</p>
+            : null}
+          <div className={css.wizardFoot}>
+            <button type="button" className={css.detailsToggle} aria-expanded={install.detailsOpen} onClick={onToggleDetails}>
+              <span>{t(install.detailsOpen ? 'installDetailsHide' : 'installDetailsShow')}</span>
+              <IconChevronDownOutline14 className={css.detailsChevron} aria-hidden="true" />
+            </button>
+            {pending
+              ? (
+                <Button variant="outline" size="sm" disabled={phase !== 'running'} onClick={onCancel}>
+                  {t(phase === 'cancelling' ? 'installCancelling' : 'installCancel')}
+                </Button>
+              )
+              : null}
+            {phase === 'failed' && !approvable ? <Button variant="primary" size="sm" onClick={onRun}>{t('installRetry')}</Button> : null}
+          </div>
+          {install.detailsOpen
+            ? (
+              <div className={css.detailsBody}>
+                <p className={css.installLocation}>{firstRun === undefined ? t('terminalNoOutput') : t('installLocation', { dir: firstRun.cwd })}</p>
+                {install.runs.map(run => (
+                  <TerminalBlock
+                    key={run.jobId}
+                    command={run.command}
+                    output={run.output}
+                    running={run.exitCode === undefined}
+                    exitCode={run.exitCode}
+                    maxLines={INSTALL_TERMINAL_LINES}
+                    labels={{ ...terminalLabels(t), ...phase === 'cancelling' ? { failed: t('installCancelledShort') } : {} }}
+                    className={css.terminal}
+                  />
+                ))}
+              </div>
+            )
+            : null}
+          {phase !== 'done'
+            ? null
+            : 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>
+      </div>
+    </Modal>
+  )
+}
+
+/** The confirmation an uninstall waits on. */
+function ConfirmDialog({ confirm, t, onConfirm, onCancel }: {
+  readonly confirm: ConfirmState
+  readonly t: Translate
+  readonly onConfirm: () => void
+  readonly onCancel: () => void
+}): ReactNode {
+  const name = shortName(confirm.packageName)
+  return (
+    <Modal
+      open
+      onClose={onCancel}
+      title={t('confirmUninstallTitle', { name })}
+      closeLabel={t('close')}
+      description={t('confirmUninstallDescription')}
+      footer={(
+        <>
+          <Button variant="outline" onClick={onCancel}>{t('cancel')}</Button>
+          <Button variant="primary" className={css.dangerButton} onClick={onConfirm}>
+            {t('confirmUninstall')}
+          </Button>
+        </>
+      )}
+    />
+  )
+}
+
+/** 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)
+  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 }
+  useEffect(() => {
+    if (highlight === null) return
+    const card = document.querySelector(`[data-plugin-package="${highlight}"]`)
+    if (card !== null && typeof card.scrollIntoView === 'function') card.scrollIntoView({ block: 'center', behavior: 'smooth' })
+    const timer = setTimeout(clearHighlight, HIGHLIGHT_MS)
+    return () => { clearTimeout(timer) }
+  }, [highlight, clearHighlight])
+  const noticeLine = state.notice === null ? null : noticeText(state.notice, t)
+
+  // The page manages what the person installed, what the installation ships for them to switch on, and a
+  // selected name the Host cannot read; the installation's other bundles are inspected in the Settings
+  // Plugins section's Plugin list tab.
+  const listed = state.packages.filter(pkg => pkg.installed || pkg.optional || pkg.error !== undefined)
+  const mine = listed.filter(pkg => pkg.installed || !pkg.optional)
+  const builtin = listed.filter(pkg => pkg.optional && !pkg.installed)
+  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)
+  }
+  // One group of cards under its heading and count; the built-in group comes first, and a group with nothing in it takes no room.
+  const renderGroup = (id: 'bundles' | 'builtin', heading: string, packages: readonly PackageView[]): ReactNode => packages.length === 0
+    ? null
+    : (
+      <section className={css.group} data-plugin-scope="global" data-plugin-group={id}>
+        <div className={css.groupHead}>
+          <h3 className={css.groupTitle}>{heading}</h3>
+          <span className={css.count} data-plugin-count={packages.length}>{packages.length}</span>
+        </div>
+        <ul className={css.cards}>
+          {packages.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>
+    )
+
+  return (
+    <section className={css.page} data-plugin-panel aria-busy={state.status === 'loading'}>
+      {openPkg === undefined
+        ? (
+          <header className={css.pageHead}>
+            <div>
+              <h1 className={css.pageTitle}>{t('title')}</h1>
+              <p className={css.pageIntro}>{t('intro')}</p>
+            </div>
+            <div className={css.toolbar}>
+              <button type="button" className={css.iconButton} aria-label={t('refresh')} title={t('refresh')} disabled={!loaded} onClick={props.refresh}>
+                <span className={css.iconWrap} aria-hidden="true"><IconRefreshOutline16 /></span>
+              </button>
+              <Button variant="primary" size="sm" icon={<IconPlusOutline16 size={13} />} disabled={!loaded} onClick={props.openInstall}>{t('addPlugin')}</Button>
+            </div>
+          </header>
+        )
+        : null}
+      {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
+      {state.status === 'unavailable' ? <p className={css.status} role="status">{t('unavailable')}</p> : null}
+      {state.status === 'error'
+        ? (
+          <div className={css.failure}>
+            <p role="alert">{t('error')}</p>
+            <Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
+          </div>
+        )
+        : null}
+      {state.notice === null || noticeLine === null
+        ? null
+        : (
+          <Toast
+            key={state.notice.seq}
+            text={noticeLine}
+            icon={<IconWarningOutline16 />}
+            holdMs={toastHoldMs(noticeLine)}
+            onDone={props.dismissNotice}
+          />
+        )}
+      {loaded && openPkg !== undefined
+        ? (
+          <PackageDetail
+            pkg={openPkg}
+            t={t}
+            busy={state.busy.includes(openPkg.name)}
+            rowBusy={row => row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))}
+            onBack={() => { setOpenPackage(null) }}
+            onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
+            onUninstall={() => { props.uninstall(openPkg.name) }}
+            onSetRowEnabled={setRowEnabled}
+          />
+        )
+        : null}
+      {loaded && openPkg === undefined
+        ? listed.length === 0
+          ? <p className={css.empty}>{t('empty')}</p>
+          : (
+            <>
+              {renderGroup('builtin', t('builtinTitle'), builtin)}
+              {renderGroup('bundles', t('bundlesTitle'), mine)}
+            </>
+          )
+        : null}
+      <InstallDialog
+        install={state.install}
+        t={t}
+        onClose={props.closeInstall}
+        onEditSpec={props.editInstallSpec}
+        onRun={props.runInstall}
+        onCancel={props.cancelInstall}
+        onToggleDetails={props.toggleInstallDetails}
+        onEnableNow={props.enableInstalled}
+        onApproveBuilds={props.approveBuildsAndRetry}
+      />
+      {state.confirm === null
+        ? null
+        : (
+          <ConfirmDialog
+            confirm={state.confirm}
+            t={t}
+            onConfirm={props.confirm}
+            onCancel={props.cancelConfirm}
+          />
+        )}
+    </section>
+  )
+}

+ 15 - 0
packages/client/ui-plugin-manager/src/client/PluginsPanelIcon.tsx

@@ -0,0 +1,15 @@
+/** The sidebar's Plugins entry icon; the sidebar owns the button, label, and selected state around it. */
+
+import type { ReactNode } from 'react'
+import { IconPluginPinwheelOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
+
+/**
+ * Render the plugin glyph at the size the sidebar asks for.
+ * @param props - the sidebar's icon share: the requested edge and whether the panel is selected.
+ * @returns the icon element.
+ */
+export function PluginsPanelIcon({ size }: PropsRuntime<'sidebar.panellist'>): ReactNode {
+  return <IconPluginPinwheelOutline16 size={size} />
+}

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

@@ -0,0 +1,90 @@
+/**
+ * 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, 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.
+ */
+
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+// Type-only: the root `main` keyed slot the page registers into, declared by
+// ui-layout with the panel id brand, and the `sidebar.panellist` list the
+// entry registers into, declared by ui-sidebar.
+import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+// Type-only: the ctx.remote Context merge and the forwarded-event key face.
+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-plugin-manager/types'
+import { PluginManagerPage } from './PluginManagerPage.tsx'
+import { PluginsPanelIcon } from './PluginsPanelIcon.tsx'
+import { PluginManagerController } from './manager-store.ts'
+import { en, zh, type PluginManagerLocaleKey } from './locales.ts'
+
+export type { PluginManagerPageProps } from './PluginManagerPage.tsx'
+export type { PluginManagerFace } from './manager-store.ts'
+export type { PluginManagerLocaleKey } from './locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Plugin manager tab copy. */
+    'pluginManager': PluginManagerLocaleKey
+  }
+}
+
+/** Dictionary namespace owned by this plugin. */
+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 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
+ * opens, and keep it current on the Host's change events.
+ * @param ctx - the browser plugin context.
+ */
+export function apply(ctx: ClientContext): void {
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-manager: dictionaries')
+  const t = ctx.locale.bind(NS)
+  const controller = new PluginManagerController(ctx)
+  ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller')
+  // The Host says when what is installed, enabled, or composed changed — from
+  // this page, the CLI, or another browser — and streams install output.
+  ctx.effect(() => {
+    // A page never rendered holds no snapshot to refresh.
+    const refresh = (): void => {
+      if (controller.getSnapshot().status !== 'idle') void controller.load()
+    }
+    const disposers = [
+      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() }
+  }, 'ui-plugin-manager: host invalidations')
+
+  // The page is a global panel: it belongs to the profile, not to a Session,
+  // and the sidebar's entry selects it. How a plugin is configured stays in
+  // Settings; this page is what is installed and switched on.
+  ctx.slots.inject('main', () => ctx.slots.register({
+    name: 'main',
+    key: PANEL_ID,
+    locale: NS,
+    inject: () => controller.inject(),
+  }, PluginManagerPage))
+  ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
+    name: 'sidebar.panellist',
+    id: PANEL_ID,
+    order: 0,
+    label: () => t('panel'),
+    locale: NS,
+  }, PluginsPanelIcon))
+
+}

+ 302 - 0
packages/client/ui-plugin-manager/src/client/locales.ts

@@ -0,0 +1,302 @@
+/** Plugin management copy and display names of shipped global rows. */
+
+/** Simplified Chinese dictionary and key source of truth. */
+export const zh = {
+  panel: '插件',
+  title: '插件',
+  intro: '添加和管理插件',
+  loading: '正在读取插件…',
+  error: '暂时无法读取插件。',
+  unavailable: '本部署没有可管理的 profile,无法安装或启停插件。',
+  retry: '重试',
+  refresh: '刷新',
+  empty: '还没有安装任何插件。',
+  addPlugin: '添加插件',
+  restartNotice: '更改将在下次启动生效',
+  overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效',
+  bundlesTitle: '已安装',
+  builtinTitle: '内置',
+  statusProblem: '异常',
+  statusOfficial: '官方',
+  reasonLabel: '原因',
+  versionTag: 'v{version}',
+  noDescription: '暂无描述。',
+  partsLabel: '包含的组件',
+  partsEmpty: '这个插件包不包含任何组件。',
+  partsCountTotal: '共 {count} 个',
+  partsCountRunning: '{count} 运行中',
+  partsCountOff: '{count} 已停用',
+  partOff: '已关闭',
+  partsCountFailed: '{count} 异常',
+  partsFilter: '筛选组件',
+  partsFilterEmpty: '没有匹配的组件。',
+  partToggle: '启用组件 {name}',
+  rowPhasePending: '等待依赖',
+  rowPhaseLoading: '加载中',
+  rowPhaseActive: '运行中',
+  rowPhaseFailed: '异常',
+  rowPhaseUnloading: '卸载中',
+  enableToggle: '启用 {name}',
+  openDetail: '查看 {name}',
+  backToList: '返回插件列表',
+  crumbRoot: '插件列表',
+  rowStateIdle: '未运行',
+  uninstall: '卸载',
+  uninstallLabel: '卸载 {name}',
+  installTitle: '添加插件',
+  installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。',
+  installSpecLabel: '包名或地址',
+  installSpecPlaceholder: '例如 dsh-better-sidebar',
+  installGuideToggle: '不知道该填什么?',
+  installGuideHide: '收起引导',
+  installGuideIntro: '从插件的 README 或发布页面复制以下任意一项。',
+  installGuideIdNote: '包名即 npm 包名,常见形式为 dsh-xxx 或 @作者/插件名;插件的显示名称不是包名。',
+  installGuideIdTitle: '包名',
+  installGuideIdExample: 'dsh-better-sidebar',
+  installGuideIdHint: 'README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。',
+  installGuideGitTitle: 'GitHub 仓库地址',
+  installGuideGitExample: 'https://github.com/author/dsh-plugin',
+  installGuideGitHint: '插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。',
+  installGuidePathTitle: '本地插件目录',
+  installGuidePathExample: '/Users/name/my-plugin',
+  installGuidePathHint: '本机上插件目录的绝对路径,适用于自行开发或已下载的插件。',
+  installGuideFill: '填入示例',
+  installGuideFillAria: '填入示例 {example}',
+  installGuideSafety: '请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。',
+  installRun: '安装',
+  installChecking: '正在检查…',
+  installProblemInvalid: '无法识别这个包名或地址:{reason}',
+  installProblemInstalled: '该插件已安装',
+  installProblemNotFound: '未找到相关插件',
+  installProblemNotPackage: '该路径不存在或不是有效的插件包',
+  installProblemNotBundle: '这个包没有声明组合包,无法作为插件安装:{reason}',
+  installProblemNetwork: '无法连接插件源,请检查网络后重试',
+  installProblemUnknown: '无法获取插件信息:{reason}',
+  installingTitle: '插件安装中…',
+  installedTitle: '已安装',
+  installFailedTitle: '插件安装失败',
+  installEdit: '编辑',
+  installEditAria: '返回编辑',
+  installCancel: '取消安装',
+  installStarting: '正在准备安装…',
+  installCancelling: '正在停止安装…',
+  installApplying: '正在应用配置,请稍候…',
+  installCancelledShort: '已取消',
+  installCancelled: '已取消安装,插件未启用,下载的文件可能保留',
+  installCancelUnconfirmed: '尚未确认安装已停止,请重试取消或等待安装结果。{reason}',
+  installEnableNow: '立即启用',
+  installDetailsShow: '查看安装详情',
+  installDetailsHide: '收起安装详情',
+  installVersion: '版本 {version}',
+  installSubjectPath: '本地目录',
+  installSubjectGit: 'Git 仓库',
+  installSubjectTarball: '压缩包',
+  installLocation: '安装位置:{dir}',
+  installRetry: '重试',
+  installFailureNetwork: '网络连接失败',
+  installFailureNotFound: '未找到相关插件',
+  installFailureNoMatchingVersion: '没有匹配的版本',
+  installFailureDiskFull: '磁盘空间不足,安装已停止',
+  installFailurePermission: '没有写入权限,无法安装',
+  installFailureBuildBlocked: '有依赖的安装脚本需要你允许后才能继续',
+  installFailureBuildBlockedManual: '有依赖的安装脚本被 pnpm 拦下,请在 profile 的 pnpm-workspace.yaml 的 allowBuilds 中放行后重试',
+  installFailureIntegrity: '下载的安装包校验失败',
+  installFailureTimeout: '安装超时',
+  installFailurePnpmMissing: '没有找到 pnpm,无法安装',
+  installFailureGeneric: '安装过程中出错,原因见安装详情',
+  terminalRunning: '运行中',
+  terminalFailed: '失败',
+  terminalDone: '已完成',
+  terminalCopy: '复制',
+  terminalCopied: '复制成功',
+  terminalNoOutput: '无输出',
+  terminalCollapseAria: '收起输出',
+  terminalCollapse: '收起',
+  terminalExpandAria: '展开其余 {n} 行输出',
+  terminalExpand: '… 其余 {n} 行',
+  terminalExitCode: '退出码 {code}',
+  terminalSignal: '信号 {signal}',
+  terminalNoExitCode: '未正常退出',
+  installDoneNothing: '安装完成,没有新增依赖。',
+  installDoneRestart: '已安装,下次启动后加载。',
+  installDoneApproved: '已允许运行安装脚本:{names}',
+  installApprovalTitle: '需要允许安装脚本',
+  installApprovalDescription: '以下包声明了安装脚本,pnpm 默认不运行。',
+  installApprovalConsequence: '允许后,脚本会以你的权限在本机运行,授权保存在当前 profile,之后不再询问。',
+  installApprovalCaution: '只在信任这些包时允许。',
+  installApproveAndRetry: '允许这些脚本并重试',
+  installClose: '完成',
+  close: '关闭',
+  cancel: '取消',
+  confirmUninstallTitle: '卸载「{name}」?',
+  confirmUninstallDescription: '卸载后它提供的功能会消失。',
+  confirmUninstall: '卸载',
+  failedEnable: '启用失败:{reason}',
+  failedDisable: '停用失败:{reason}',
+  failedUninstall: '卸载失败:{reason}',
+  failedRowEnable: '组件启用失败:{reason}',
+  failedRowDisable: '组件停用失败:{reason}',
+  reasonManagementRequired: '插件管理所需,不能停用或卸载',
+  reasonUnaddressable: '当前 profile 的 patch 无法唯一定位这一项',
+  reasonUnknownPlugin: '找不到该插件',
+  reasonInvalidSpec: '请输入有效的包名或地址',
+  reasonAmbiguousInstall: '无法从依赖变更中确定安装了哪一个包',
+  reasonNotBundle: '这个包没有声明组合包,不能作为插件管理',
+  reasonNotRemovable: '这个包不属于当前 profile,或者是插件管理所需的组件',
+  reasonStopProfile: '这个 profile 没有启用 HMR,正在使用的包要停止后用 dsh plugin 卸载',
+  reasonBundleInUse: '其他配置仍在使用这个组合包的组件,请先停用它们',
+  reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新',
+  reasonOperationError: 'Host 报告了一个错误',
+} satisfies Record<string, string>
+
+/** Plugin manager locale key union. */
+export type PluginManagerLocaleKey = keyof typeof zh
+
+/** English dictionary checked against the Chinese key set. */
+export const en = {
+  panel: 'Plugins',
+  title: 'Plugins',
+  intro: 'Add and manage plugins',
+  loading: 'Reading plugins…',
+  error: 'Plugins are temporarily unavailable.',
+  unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.',
+  retry: 'Retry',
+  refresh: 'Refresh',
+  empty: 'No plugins are installed yet.',
+  addPlugin: 'Add plugin',
+  restartNotice: 'The change takes effect at the next start',
+  overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect',
+  bundlesTitle: 'Installed',
+  builtinTitle: 'Built in',
+  statusProblem: 'Problem',
+  statusOfficial: 'Official',
+  reasonLabel: 'Reason',
+  versionTag: 'v{version}',
+  noDescription: 'No description.',
+  partsLabel: 'Components',
+  partsEmpty: 'This plugin pack contains no components.',
+  partsCountTotal: '{count} total',
+  partsCountRunning: '{count} running',
+  partsCountOff: '{count} off',
+  partOff: 'Off',
+  partsCountFailed: '{count} failed',
+  partsFilter: 'Filter components',
+  partsFilterEmpty: 'No component matches.',
+  partToggle: 'Enable component {name}',
+  rowPhasePending: 'Waiting for dependencies',
+  rowPhaseLoading: 'Loading',
+  rowPhaseActive: 'Running',
+  rowPhaseFailed: 'Problem',
+  rowPhaseUnloading: 'Unloading',
+  enableToggle: 'Enable {name}',
+  openDetail: 'View {name}',
+  backToList: 'Back to plugins',
+  crumbRoot: 'Plugins',
+  rowStateIdle: 'Not running',
+  uninstall: 'Uninstall',
+  uninstallLabel: 'Uninstall {name}',
+  installTitle: 'Add plugin',
+  installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.',
+  installSpecLabel: 'Package name or address',
+  installSpecPlaceholder: 'for example dsh-better-sidebar',
+  installGuideToggle: 'Not sure what to enter?',
+  installGuideHide: 'Hide the guide',
+  installGuideIntro: 'Copy any one of these from the plugin\'s README or release page.',
+  installGuideIdNote: 'The package name is the npm package name, usually dsh-xxx or @author/plugin; the display name is not one.',
+  installGuideIdTitle: 'Package name',
+  installGuideIdExample: 'dsh-better-sidebar',
+  installGuideIdHint: 'The part after dsh plugin add or pnpm add in the README\'s install command.',
+  installGuideGitTitle: 'GitHub repository address',
+  installGuideGitExample: 'https://github.com/author/dsh-plugin',
+  installGuideGitHint: 'The address of the plugin\'s open-source repository on GitHub; other Git hosts work too.',
+  installGuidePathTitle: 'Local plugin directory',
+  installGuidePathExample: '/Users/name/my-plugin',
+  installGuidePathHint: 'The absolute path of a plugin directory on this machine, developed here or downloaded.',
+  installGuideFill: 'Use example',
+  installGuideFillAria: 'Use the example {example}',
+  installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.',
+  installRun: 'Install',
+  installChecking: 'Checking…',
+  installProblemInvalid: 'This is not a package name or address that can be installed: {reason}',
+  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…',
+  installedTitle: 'Installed',
+  installFailedTitle: 'The plugin could not be installed',
+  installEdit: 'Edit',
+  installEditAria: 'Back to editing',
+  installCancel: 'Cancel install',
+  installStarting: 'Preparing installation…',
+  installCancelling: 'Stopping installation…',
+  installApplying: 'Applying configuration, please wait…',
+  installCancelledShort: 'Cancelled',
+  installCancelled: 'Installation cancelled; the plugin is not enabled, and downloaded files may remain',
+  installCancelUnconfirmed: 'Installation has not been confirmed stopped. Retry cancellation or wait for the installation result. {reason}',
+  installEnableNow: 'Enable now',
+  installDetailsShow: 'Show install details',
+  installDetailsHide: 'Hide install details',
+  installVersion: 'Version {version}',
+  installSubjectPath: 'Local directory',
+  installSubjectGit: 'Git repository',
+  installSubjectTarball: 'Tarball',
+  installLocation: 'Installs into {dir}',
+  installRetry: 'Retry',
+  installFailureNetwork: 'The network connection failed',
+  installFailureNotFound: 'No such plugin was found',
+  installFailureNoMatchingVersion: 'No version matches the request',
+  installFailureDiskFull: 'The disk is full; the install stopped',
+  installFailurePermission: 'No write permission; the plugin cannot be installed',
+  installFailureBuildBlocked: 'A dependency\'s install scripts need your permission before the install can continue',
+  installFailureBuildBlockedManual: 'pnpm blocked install scripts; allow them under allowBuilds in pnpm-workspace.yaml and retry',
+  installFailureIntegrity: 'The downloaded package failed its integrity check',
+  installFailureTimeout: 'The install timed out',
+  installFailurePnpmMissing: 'pnpm was not found, so nothing can be installed',
+  installFailureGeneric: 'Something went wrong during the install; the details say what',
+  terminalRunning: 'Running',
+  terminalFailed: 'Failed',
+  terminalDone: 'Done',
+  terminalCopy: 'Copy',
+  terminalCopied: 'Copied',
+  terminalNoOutput: 'No output',
+  terminalCollapseAria: 'Collapse output',
+  terminalCollapse: 'Collapse',
+  terminalExpandAria: 'Expand the remaining {n} output lines',
+  terminalExpand: '… {n} more lines',
+  terminalExitCode: 'exit code {code}',
+  terminalSignal: 'signal {signal}',
+  terminalNoExitCode: 'no exit code',
+  installDoneNothing: 'Install finished with no new dependency.',
+  installDoneRestart: 'Installed; it loads at the next start.',
+  installDoneApproved: 'Install scripts allowed for {names}',
+  installApprovalTitle: 'Install scripts need permission',
+  installApprovalDescription: 'These packages have install scripts that pnpm did not run.',
+  installApprovalConsequence: 'Once allowed, the scripts run here with your permissions, and the permission is saved in this profile.',
+  installApprovalCaution: 'Allow only packages you trust.',
+  installApproveAndRetry: 'Allow these scripts and retry',
+  installClose: 'Done',
+  close: 'Close',
+  cancel: 'Cancel',
+  confirmUninstallTitle: 'Uninstall "{name}"?',
+  confirmUninstallDescription: 'What it provides goes away once it is uninstalled.',
+  confirmUninstall: 'Uninstall',
+  failedEnable: 'Could not enable: {reason}',
+  failedDisable: 'Could not disable: {reason}',
+  failedUninstall: 'Could not uninstall: {reason}',
+  failedRowEnable: 'Could not enable the component: {reason}',
+  failedRowDisable: 'Could not disable the component: {reason}',
+  reasonManagementRequired: 'Plugin management needs it; it cannot be switched off or uninstalled.',
+  reasonUnaddressable: 'The profile patch cannot address this one uniquely.',
+  reasonUnknownPlugin: 'No such plugin.',
+  reasonInvalidSpec: 'Enter a valid package name or address.',
+  reasonAmbiguousInstall: 'Which package was installed cannot be told from the dependency change.',
+  reasonNotBundle: 'This package declares no bundle, so it cannot be managed as a plugin.',
+  reasonNotRemovable: 'This package is not owned by the profile, or plugin management needs it.',
+  reasonStopProfile: 'This profile runs without HMR; stop it and uninstall the package with dsh plugin.',
+  reasonBundleInUse: 'Other configuration still uses this bundle\'s components; switch them off first.',
+  reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.',
+  reasonOperationError: 'The Host reported an error.',
+} satisfies Record<PluginManagerLocaleKey, string>

+ 715 - 0
packages/client/ui-plugin-manager/src/client/manager-store.ts

@@ -0,0 +1,715 @@
+/**
+ * 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 {
+  BundleInfo,
+  ChangeResult,
+  ManagementError,
+  PluginEntryId,
+  PluginInfo,
+  PluginInspectProblem,
+  PluginInstallFailureKind,
+  PluginInstallLogChunk,
+  PluginInstallProgress,
+  PluginInstallRequestId,
+  PluginSpecInspection,
+  ReadOnlyReason,
+} from '@deepseek-ai/dsh-api-remotes/client'
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { shortName } from './presentation.ts'
+
+/** The action a failed notice names. */
+export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'rowDisable'
+
+/** 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'
+    /** What was being done when it failed. */
+    readonly action: FailedAction
+    /** The Host's refusal, when the Host refused; absent when the transport failed. */
+    readonly code?: ManagementError['code']
+    /** The Host's diagnostic or the transport's words, shown verbatim; empty when the code says it all. */
+    readonly reason: string
+    readonly packageName?: string
+    readonly seq: number
+  }
+
+/** 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?: ReadOnlyReason
+}
+
+/** 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 description?: string
+  /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */
+  readonly installed: boolean
+  /** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */
+  readonly optional: 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?: ReadOnlyReason
+  /** Why the Host cannot read the bundle, when it cannot. */
+  readonly error?: ManagementError
+  readonly rows: readonly PackageRow[]
+}
+
+/** 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
+  readonly reason: string
+}
+
+/** One pnpm run of an install, as the dialog's terminal draws it. */
+export interface InstallRun {
+  readonly jobId: string
+  /** The command line the Host ran, space-joined. */
+  readonly command: string
+  /** The directory the Host ran pnpm in: the profile directory. */
+  readonly cwd: string
+  /** stdout and stderr interleaved as they arrived, pnpm's colour escapes included. */
+  readonly output: string
+  /** pnpm's exit code once the run settled, null when it ended by a signal or never started; absent while it runs. */
+  readonly exitCode?: number | null
+}
+
+/**
+ * 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 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.
+ */
+export interface InstallState {
+  readonly open: boolean
+  /** The package spec as typed. */
+  readonly spec: string
+  readonly phase: 'idle' | 'checking' | 'starting' | 'running' | 'cancelling' | 'applying' | 'done' | 'failed'
+  /** Identifies this dialog's installation, including log and cancellation messages. */
+  readonly requestId?: PluginInstallRequestId
+  /** Why the spec was refused before installing; shown under the field. */
+  readonly inputError: InstallInputError | null
+  /** What the spec names, once the Host has read it. */
+  readonly subject: InstallSubject | null
+  /** The pnpm runs of the open install, in the order they started. */
+  readonly runs: readonly InstallRun[]
+  /** Whether the run's command and output are unfolded. */
+  readonly detailsOpen: boolean
+  /** 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: the Host's refusal
+   * `code` with its diagnostic as `reason`, or the transport's words alone;
+   * `kind` classifies a pnpm failure, and `pendingBuilds` names the packages
+   * whose install scripts pnpm left undecided, offered for approval.
+   * `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 code?: ManagementError['code']
+    readonly kind?: PluginInstallFailureKind
+    readonly pendingBuilds?: readonly string[]
+    readonly cancelUnconfirmed?: true
+  } | null
+  /** The packages whose install scripts the finished run was allowed to execute, saved for this profile. */
+  readonly approvedBuilds: readonly string[]
+  /** Enabling the newly installed bundle from the installed screen is crossing the wire. */
+  readonly enabling: boolean
+}
+
+/**
+ * Whether an installation is still owned by the Host.
+ * @param phase - the dialog's current installation phase.
+ * @returns true until an authoritative result settles the installation.
+ */
+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. */
+export interface ConfirmState {
+  readonly action: 'uninstall'
+  readonly packageName: string
+}
+
+/** What the tab renders. */
+export interface PluginManagerState {
+  /** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */
+  readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable'
+  readonly packages: readonly PackageView[]
+  /** Package names and row keys with an action crossing the wire. */
+  readonly busy: readonly string[]
+  readonly notice: ManagerNotice | null
+  readonly install: InstallState
+  readonly confirm: ConfirmState | null
+  /** The package the list scrolls to and marks, once an install enabled it. */
+  readonly highlight: string | null
+}
+
+/** The registration-side face the tab's slot entry injects. */
+export interface PluginManagerFace {
+  hooks: {
+    /** Tab snapshot bound by the renderer as usePluginManager. */
+    pluginManager: SnapshotStore<PluginManagerState>
+  }
+  /** Read the Host once the tab first renders. */
+  ensure: () => void
+  /** Read the Host again. */
+  refresh: () => void
+  openInstall: () => void
+  /** Close the dialog; a check in flight is dropped, a Host-owned run has to be cancelled first. */
+  closeInstall: () => void
+  editInstallSpec: (text: string) => void
+  /** Check the spec with the Host, then install it; from the failed screen, run it again. */
+  runInstall: () => void
+  /** Allow the install scripts the failed run left pending, saved for this profile, and run the same spec again. */
+  approveBuildsAndRetry: () => void
+  /** 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 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
+  /** Ask before removing a package from the profile. */
+  uninstall: (packageName: string) => void
+  confirm: () => void
+  cancelConfirm: () => 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
+}
+
+/** A Remote answer as the generated client returns it. */
+type Answer<T> =
+  | { readonly ok: true; readonly value: T }
+  | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details?: unknown } }
+
+/** A refused answer or a change the Host could not apply, carrying what it said and, for a refusal, its code. */
+class RemoteAnswerError extends Error {
+  constructor(readonly reason: string, readonly code?: ManagementError['code']) {
+    super(reason)
+    this.name = 'RemoteAnswerError'
+  }
+}
+
+/** The dialog's reading of a failed change: the Host's code and diagnostic, and the run's classified failure. */
+function failureOf(
+  error: ManagementError | undefined, kind: PluginInstallFailureKind | undefined, pendingBuilds?: readonly string[],
+): NonNullable<InstallState['failure']> {
+  return {
+    reason: error?.diagnostic ?? '',
+    ...error === undefined ? {} : { code: error.code },
+    ...kind === undefined ? {} : { kind },
+    ...pendingBuilds === undefined || pendingBuilds.length === 0 ? {} : { pendingBuilds },
+  }
+}
+
+/** The notice a thrown failure becomes: a refusal keeps its code, anything else its words. */
+function failedNotice(error: unknown, subject: { action: FailedAction; packageName?: string }, seq: number): ManagerNotice {
+  const code = error instanceof RemoteAnswerError ? error.code : undefined
+  return { kind: 'failed', reason: reasonOf(error), ...code === undefined ? {} : { code }, ...subject, seq }
+}
+
+/** The runs with every one still open settled at `exitCode`. */
+function settledRuns(runs: readonly InstallRun[], exitCode: number | null): readonly InstallRun[] {
+  return runs.map(run => run.exitCode === undefined ? { ...run, exitCode } : run)
+}
+
+/**
+ * The key one row occupies in the busy list.
+ * @param entryId - the row's Loader entry id.
+ * @returns the busy key.
+ */
+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,
+    optional: bundle.optional,
+    enabled: bundle.enabled,
+    rows,
+    ...bundle.version === undefined ? {} : { version: bundle.version },
+    ...bundle.description === undefined ? {} : { description: bundle.description },
+    ...bundle.readOnlyReason === undefined ? {} : { readOnlyReason: bundle.readOnlyReason },
+    ...bundle.error === undefined ? {} : { error: bundle.error },
+  }
+}
+
+/**
+ * The order the list shows packages in: by the short name a person reads, so a
+ * card stays put when its bundle is switched, whatever order the Host answers in.
+ * @param packages - the Host's bundles as views.
+ * @returns the views sorted by short name.
+ */
+export function sortPackages(packages: readonly PackageView[]): PackageView[] {
+  return [...packages].sort((a, b) => shortName(a.name).localeCompare(shortName(b.name)))
+}
+
+const IDLE_INSTALL: InstallState = {
+  open: false, spec: '', phase: 'idle', inputError: null, subject: null, runs: [], detailsOpen: false,
+  installed: null, restartRequired: false, failure: null, approvedBuilds: [], enabling: false,
+}
+
+/** Reads and mutates the profile's plugins through the `pluginManager` Remote. */
+export class PluginManagerController {
+  private readonly store: SnapshotStore<PluginManagerState>
+  private inFlight: Promise<void> | undefined
+  private rerun = false
+  private generation = 0
+  private disposed = false
+  private pendingConfirm: (() => Promise<void>) | undefined
+  /** Cancels the check the dialog has in flight. */
+  private inspectAbort: AbortController | undefined
+  private noticeSeq = 0
+
+  /**
+   * @param ctx - the tab plugin's context, whose `remote.pluginManager` and `remote.pluginInventory` namespaces answer.
+   */
+  constructor(
+    private readonly ctx: ClientContext,
+  ) {
+    this.store = createSnapshotStore<PluginManagerState>({
+      status: 'idle', packages: [], busy: [], notice: null,
+      install: IDLE_INSTALL, confirm: null, highlight: null,
+    })
+  }
+
+  /**
+   * Read the tab's state.
+   * @returns the current sync snapshot (stable reference until the next change).
+   */
+  getSnapshot(): PluginManagerState {
+    return this.store.getSnapshot()
+  }
+
+  /** Stop publishing and drop every late settlement. */
+  dispose(): void {
+    this.disposed = true
+    this.generation += 1
+  }
+
+  /**
+   * Build the face the tab's slot registration injects.
+   * @returns the tab's snapshot source and its actions.
+   */
+  inject(): PluginManagerFace {
+    return {
+      hooks: { pluginManager: this.store },
+      ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
+      refresh: () => { void this.load() },
+      openInstall: () => {
+        if (!isInstallPending(this.getSnapshot().install.phase)) this.patch({ install: { ...IDLE_INSTALL, open: true } })
+      },
+      closeInstall: () => {
+        if (isInstallPending(this.getSnapshot().install.phase)) return
+        this.abortInspect()
+        this.patch({ install: IDLE_INSTALL })
+      },
+      editInstallSpec: (text) => {
+        const install = this.getSnapshot().install
+        // Typing while the Host checks or installs is not possible; a new spec after an outcome starts over.
+        if (install.phase === 'checking' || isInstallPending(install.phase)) return
+        this.patchInstall(install.phase === 'idle' ? { spec: text, inputError: null } : { ...IDLE_INSTALL, open: true, spec: text })
+      },
+      runInstall: () => { void this.runInstall() },
+      approveBuildsAndRetry: () => { void this.approveBuildsAndRetry() },
+      cancelInstall: () => { void this.cancelInstall() },
+      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.run(packageName, { packageName, action: enabled ? 'enable' : 'disable' }, async () => {
+          this.applied(await this.ctx.remote.pluginManager.setBundleEnabled(packageName, enabled), packageName)
+        })
+      },
+      uninstall: (packageName) => {
+        this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, 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 }) },
+      setRowEnabled: (entryId, enabled) => {
+        void this.run(rowKey(entryId), { packageName: entryId, action: enabled ? 'rowEnable' : 'rowDisable' }, async () => {
+          this.applied(await this.ctx.remote.pluginManager.setPluginEnabled(entryId, enabled), entryId)
+        })
+      },
+      dismissNotice: () => { this.patch({ notice: null }) },
+    }
+  }
+
+  /**
+   * Follow the Host's cancellation window for this dialog's installation.
+   * @param progress - a request id and phase received from the Host.
+   */
+  installProgress(progress: PluginInstallProgress): void {
+    const install = this.getSnapshot().install
+    if (install.requestId !== progress.requestId || !isInstallPending(install.phase)) return
+    // A queued start notification cannot undo the local user's cancellation request.
+    if (install.phase === 'cancelling' && progress.phase === 'installing') return
+    this.patchInstall({ phase: progress.phase === 'installing' ? 'running' : progress.phase })
+  }
+
+  /**
+   * Fold a chunk belonging to this installation into its pnpm command.
+   * 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 {
+    const install = this.getSnapshot().install
+    if (chunk.requestId !== install.requestId) return
+    const index = install.runs.findIndex(run => run.jobId === chunk.jobId)
+    if (index === -1 && !isInstallPending(install.phase)) return
+    const settled = chunk.exitCode === undefined ? {} : { exitCode: chunk.exitCode }
+    const runs = index === -1
+      ? [...install.runs, { jobId: chunk.jobId, command: chunk.argv.join(' '), cwd: chunk.cwd, output: chunk.text, ...settled }]
+      : install.runs.map((run, at) => at === index ? { ...run, output: run.output + chunk.text, ...settled } : run)
+    this.patchInstall({ runs })
+  }
+
+  /**
+   * 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.
+   */
+  load(): Promise<void> {
+    if (this.disposed) return Promise.resolve()
+    if (this.inFlight !== undefined) {
+      this.rerun = true
+      return this.inFlight
+    }
+    const run = Promise.resolve().then(() => this.read())
+    this.inFlight = run
+    return run
+  }
+
+  private async read(): Promise<void> {
+    try {
+      do {
+        this.rerun = false
+        const generation = ++this.generation
+        if (this.getSnapshot().status === 'idle') this.patch({ status: 'loading' })
+        // 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 (!bundles.ok || !plugins.ok) {
+          this.patch({ status: 'error' })
+          continue
+        }
+        this.patch({
+          status: 'ready',
+          packages: sortPackages(bundles.value.map(bundle => packageView(bundle, plugins.value))),
+        })
+      } while (this.shouldRerun())
+    } finally {
+      this.inFlight = undefined
+    }
+  }
+
+  private shouldRerun(): boolean {
+    return this.rerun
+  }
+
+  private async confirm(): Promise<void> {
+    const pending = this.pendingConfirm
+    this.pendingConfirm = undefined
+    this.patch({ confirm: null })
+    if (pending !== undefined) await pending()
+  }
+
+  /** Drop the check in flight; its answer is ignored. */
+  private abortInspect(): void {
+    this.inspectAbort?.abort()
+    this.inspectAbort = undefined
+  }
+
+  /** Whether a settlement arrives too late to matter: the store is disposed, or the dialog moved on. */
+  private gone(signal: AbortSignal): boolean {
+    return this.disposed || signal.aborted
+  }
+
+  /**
+   * Check the typed spec, then install it. The Host reads what the spec
+   * names first; a refused spec returns to the field with the reason, an
+   * accepted one becomes the subject the next screens show while pnpm runs.
+   */
+  private async runInstall(): Promise<void> {
+    const state = this.getSnapshot()
+    const install = state.install
+    const spec = install.spec.trim()
+    if (install.phase === 'checking' || isInstallPending(install.phase) || spec === '') return
+    // A name the list already shows is refused at once, before the Host is asked.
+    if (state.packages.some(pkg => pkg.name === spec)) {
+      this.patchInstall({ phase: 'idle', inputError: { problem: 'already-installed', reason: spec } })
+      return
+    }
+    this.abortInspect()
+    const controller = new AbortController()
+    this.inspectAbort = controller
+    this.patchInstall({
+      phase: 'checking', inputError: null, subject: null, runs: [], detailsOpen: false,
+      installed: null, restartRequired: false, failure: null, approvedBuilds: [],
+    })
+    const inspected = await this.ctx.remote.pluginManager.inspect(spec, controller.signal)
+    if (this.gone(controller.signal)) return
+    this.inspectAbort = undefined
+    if (!inspected.ok) {
+      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
+    }
+    await this.startInstall({ spec, ...inspected.value })
+  }
+
+  /**
+   * Hand the checked spec to the Host and settle the dialog from its answer.
+   * `approvedBuilds` names the pending install scripts the person allowed;
+   * the Host saves that permission for this profile before pnpm runs.
+   */
+  private async startInstall(subject: InstallSubject, approvedBuilds?: readonly string[]): Promise<void> {
+    const { spec } = subject
+    const requestId = randomUUID() as PluginInstallRequestId
+    this.patchInstall({ phase: 'starting', requestId, subject, runs: [], failure: null, installed: null, approvedBuilds: [] })
+    // 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.pluginManager.installBundle(spec, {
+      enabled: false, requestId, ...approvedBuilds === undefined ? {} : { approvedBuilds: [...approvedBuilds] },
+    })
+    if (this.disposed || this.getSnapshot().install.requestId !== requestId) return
+    const runs = this.getSnapshot().install.runs
+    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 (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, packages?.exitCode ?? null),
+        failure: failureOf(result.value.error, packages?.kind, result.value.pendingBuilds),
+      })
+    } else {
+      this.patchInstall({
+        phase: 'done',
+        runs: settledRuns(runs, 0),
+        installed: result.value.bundle ?? null,
+        restartRequired: result.value.application === 'restart-required',
+        approvedBuilds: result.value.approvedBuilds ?? [],
+      })
+    }
+    void this.load()
+  }
+
+  /**
+   * Allow the install scripts the failed run left pending and run the same
+   * spec again. Only the failed screen with pending names offers this.
+   */
+  private async approveBuildsAndRetry(): Promise<void> {
+    const install = this.getSnapshot().install
+    const pending = install.failure?.pendingBuilds
+    if (install.phase !== 'failed' || install.subject === null || pending === undefined || pending.length === 0) return
+    await this.startInstall(install.subject, pending)
+  }
+
+  /**
+   * Leave the check or the failed screen for the spec at once; a Host-owned
+   * run is asked to stop and the dialog waits for the Host's word, since
+   * neither a dropped RPC nor a closed connection means pnpm has stopped.
+   */
+  private async cancelInstall(): Promise<void> {
+    const install = this.getSnapshot().install
+    if (install.phase === 'checking' || install.phase === 'failed') {
+      this.abortInspect()
+      this.offerSpecAgain()
+      return
+    }
+    if (install.phase !== 'running' || install.requestId === undefined) return
+    const requestId = install.requestId
+    this.patchInstall({ phase: 'cancelling', failure: null })
+    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: { reason: result.error.message, cancelUnconfirmed: true } })
+      return
+    }
+    if (result.value.status === 'cancelled') {
+      this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq })
+      void this.load()
+    } else if (result.value.status === 'too-late') {
+      this.patchInstall({ phase: 'applying' })
+    } else {
+      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.
+   */
+  private offerSpecAgain(notice: ManagerNotice | null = null): void {
+    const { open, spec } = this.getSnapshot().install
+    this.patch({ install: { ...IDLE_INSTALL, open, spec }, ...notice === null ? {} : { notice } })
+  }
+
+  /**
+   * 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 })
+    if (name !== null) {
+      const result = await this.ctx.remote.pluginManager.setBundleEnabled(name, true)
+      if (this.disposed) return
+      try {
+        this.applied(result, name)
+      } catch (error) {
+        this.patch({ notice: failedNotice(error, { packageName: name, action: 'enable' }, ++this.noticeSeq) })
+      }
+    }
+    this.patch({ install: IDLE_INSTALL, highlight: name })
+    await this.load()
+  }
+
+  /**
+   * 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: { action: FailedAction; packageName?: string },
+    action: () => Promise<void>,
+  ): Promise<void> {
+    if (this.disposed || this.getSnapshot().busy.includes(key)) return
+    this.patch({ busy: [...this.getSnapshot().busy, key], notice: null })
+    try {
+      await action()
+    } catch (error) {
+      // `patch` drops the notice after disposal.
+      this.patch({ notice: failedNotice(error, subject, ++this.noticeSeq) })
+    } finally {
+      this.patch({ busy: this.getSnapshot().busy.filter(entry => entry !== key) })
+    }
+    await this.load()
+  }
+
+  /**
+   * 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, that a higher layer overrides, or that the Host stopped 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':
+        throw new RemoteAnswerError(result.error?.diagnostic ?? '', result.error?.code)
+      case 'cancelled':
+        this.patch({ notice: { kind: 'cancelled', seq: ++this.noticeSeq } })
+        return
+      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 {
+    if (this.disposed) return
+    this.store.set({ ...this.getSnapshot(), ...next })
+  }
+
+  private patchInstall(next: Partial<InstallState>): void {
+    this.patch({ install: { ...this.getSnapshot().install, ...next } })
+  }
+}
+
+/** 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)
+}

+ 73 - 0
packages/client/ui-plugin-manager/src/client/presentation.ts

@@ -0,0 +1,73 @@
+/** Display labels and toast sentences for global plugin management. */
+
+import type { ManagementError } from '@deepseek-ai/dsh-api-remotes/client'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PluginManagerLocaleKey } from './locales.ts'
+import type { FailedAction, ManagerNotice } from './manager-store.ts'
+
+/** The translate seat of the manager's dictionary. */
+export type Translate = PropsLocale<'pluginManager'>['t']
+
+/** The sentence each of the Host's refusal codes reads as. */
+const CODE_KEYS = {
+  'management-required': 'reasonManagementRequired',
+  'unaddressable': 'reasonUnaddressable',
+  'unknown-plugin': 'reasonUnknownPlugin',
+  'invalid-spec': 'reasonInvalidSpec',
+  'ambiguous-install': 'reasonAmbiguousInstall',
+  'not-bundle': 'reasonNotBundle',
+  'not-removable': 'reasonNotRemovable',
+  'stop-profile': 'reasonStopProfile',
+  'bundle-in-use': 'reasonBundleInUse',
+  'stale-approval': 'reasonStaleApproval',
+  'operation-error': 'reasonOperationError',
+} satisfies Record<ManagementError['code'], PluginManagerLocaleKey>
+
+/** The sentence a failed action opens with, by what was being done. */
+const FAILED_KEYS = {
+  enable: 'failedEnable',
+  disable: 'failedDisable',
+  uninstall: 'failedUninstall',
+  rowEnable: 'failedRowEnable',
+  rowDisable: 'failedRowDisable',
+} satisfies Record<FailedAction, PluginManagerLocaleKey>
+
+/**
+ * What a management error reads as: the code's sentence, or, for an
+ * operation error, the Host's diagnostic as it is.
+ * @param error - the Host's code and its diagnostic, when it has one.
+ * @param t - the manager's translate seat.
+ * @returns the sentence.
+ */
+export function managementText(error: { readonly code: ManagementError['code']; readonly diagnostic?: string }, t: Translate): string {
+  if (error.code !== 'operation-error') return t(CODE_KEYS[error.code])
+  return error.diagnostic === undefined || error.diagnostic === '' ? t('reasonOperationError') : error.diagnostic
+}
+
+/**
+ * Compact a package name to what a person calls it.
+ * @param name - the package name.
+ * @returns the unscoped name without the harness prefixes.
+ */
+export function shortName(name: string): string {
+  const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name
+  return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
+}
+
+/**
+ * The sentence one notice shows.
+ * @param notice - the last action's outcome.
+ * @param t - the manager's translate seat.
+ * @returns the sentence.
+ */
+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': {
+      const reason = notice.code === undefined ? notice.reason : managementText({ code: notice.code, diagnostic: notice.reason }, t)
+      return t(FAILED_KEYS[notice.action], { reason: reason === '' ? t('reasonOperationError') : reason })
+    }
+  }
+}

+ 6 - 0
packages/client/ui-plugin-manager/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

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

@@ -0,0 +1,4 @@
+/** Host loader entry for the plugin-manager tab's browser implementation exported from `./client`. */
+
+/** Host plugin body — no host-side behavior for the plugin manager tab. */
+export function apply(): void {}

+ 107 - 0
packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx

@@ -0,0 +1,107 @@
+// @vitest-environment jsdom
+import { Context, Service } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
+import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
+import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts'
+import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
+import { PluginsPanelIcon } from '../src/client/PluginsPanelIcon.tsx'
+import type { PluginManagerFace } from '../src/client/manager-store.ts'
+import { apply as hostApply } from '../src/index.ts'
+
+usePinnedBrowserLanguages('zh-CN')
+afterEach(cleanup)
+
+async function bench() {
+  const ctx = new Context()
+  await ctx.plugin(SlotRegistry).await()
+  const locale = new LocaleRuntime(ctx)
+  ctx.provide('locale', locale)
+  class LocaleHolder extends Service {
+    constructor(serviceCtx: Context) {
+      super(serviceCtx, 'localeHolder')
+    }
+  }
+  new LocaleHolder(ctx)
+  const list = vi.fn(() => Promise.resolve({ ok: true as const, value: { entries: [], managementAvailable: true } }))
+  const remote = new TestRemote(ctx, {
+    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 }
+}
+
+function declare(slots: SlotRegistry): () => void {
+  return slots.register({
+    name: 'root',
+    children: {
+      'main': { kind: 'keyed', scope: 'root' },
+      'sidebar.panellist': { kind: 'list', scope: 'root' },
+    },
+  } as never, () => null)
+}
+
+describe('ui-plugin-manager browser plugin', () => {
+  it('keeps the host Loader entry inert', () => {
+    expect(hostApply).not.toThrow()
+  })
+
+  it('declares only the services the page and its Remote methods use', () => {
+    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 () => {
+    const b = await bench()
+    declare(b.slots)
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+
+    const entry = b.slots.entries('main')[0]!
+    expect(entry.component).toBe(PluginManagerPage)
+    expect(entry.options).toMatchObject({ key: PANEL_ID })
+    expect(entry.locale).toBe(NS)
+    // The sidebar entry addresses the page by the same id and speaks the dictionary.
+    const icon = b.slots.entries('sidebar.panellist')[0]!
+    expect(icon.component).toBe(PluginsPanelIcon)
+    const unread = () => { throw new Error('The sidebar icon must not read application state') }
+    const glyph = render(<PluginsPanelIcon size={18} active={false}
+      usePanelInfo={unread} useSessions={unread} useSessionPendingInteraction={unread} useWorkspaces={unread} useResource={unread} />)
+    expect(glyph.container.querySelector('svg')?.getAttribute('width')).toBe('18')
+    expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 })
+    expect(icon.locale).toBe(NS)
+    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('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('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) })
+
+    // Install output folds into an open run only.
+    face.openInstall()
+    face.editInstallSpec('pkg')
+    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('plugin-manager/changed', [{ reason: 'install' }])
+    await Promise.resolve()
+    expect(b.list).toHaveBeenCalledTimes(3)
+  })
+})

+ 574 - 0
packages/client/ui-plugin-manager/tests/components.client.spec.tsx

@@ -0,0 +1,574 @@
+// @vitest-environment jsdom
+import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+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 PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts'
+import { en, type PluginManagerLocaleKey } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const t = ((key: PluginManagerLocaleKey, params?: Record<string, string>): string =>
+  Object.entries(params ?? {}).reduce(
+    (text, [name, value]) => text.replaceAll(`{${name}}`, value),
+    en[key],
+  )) as PluginManagerPageProps['t']
+
+function pkg(overrides: Partial<PackageView> = {}): PackageView {
+  return {
+    name: 'dsh-better-sidebar',
+    version: '0.16.0',
+    installed: true,
+    optional: false,
+    enabled: true,
+    rows: [],
+    ...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: null, restartRequired: false, failure: null, approvedBuilds: [], enabling: false,
+}
+
+const READY: PluginManagerState = {
+  status: 'ready',
+  packages: [],
+  busy: [],
+  notice: null,
+  install: IDLE_INSTALL,
+  confirm: null,
+  highlight: null,
+}
+
+function renderTab(state: Partial<PluginManagerState> = {}) {
+  const store = createSnapshotStore<PluginManagerState>({ ...READY, ...state })
+  const actions = {
+    ensure: vi.fn(),
+    refresh: vi.fn(),
+    openInstall: vi.fn(),
+    closeInstall: vi.fn(),
+    editInstallSpec: vi.fn(),
+    runInstall: vi.fn(),
+    cancelInstall: vi.fn(),
+    toggleInstallDetails: vi.fn(),
+    approveBuildsAndRetry: vi.fn(),
+    enableInstalled: vi.fn(),
+    clearHighlight: vi.fn(),
+    setEnabled: vi.fn(),
+    uninstall: vi.fn(),
+    confirm: vi.fn(),
+    cancelConfirm: vi.fn(),
+    setRowEnabled: vi.fn(),
+    dismissNotice: vi.fn(),
+  }
+  const props = {
+    t,
+    ...actions,
+    usePluginManager: bindSnapshotSelector(store),
+  } as unknown as PluginManagerPageProps
+  render(<PluginManagerPage {...props} />)
+  return { store, actions, set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } }
+}
+
+describe('PluginManagerPage', () => {
+  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)
+    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)
+    fireEvent.click(screen.getByRole('button', { name: en.refresh }))
+    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('lists the installed bundles as cards, the installation\'s offered ones as built in, and tags a problem the Host reports', () => {
+    const { actions } = renderTab({
+      packages: [
+        pkg({ description: 'A sidebar.' }),
+        pkg({ name: 'dsh-broken', enabled: false, error: { code: 'not-bundle' } }),
+        pkg({ name: '@deepseek-ai/dsh-web-app', installed: false }),
+        pkg({ name: 'dsh-protected', readOnlyReason: 'management-required' }),
+        pkg({ name: '@acme/dsh-tool', enabled: false }),
+        // Selected by the profile but not a bundle: a problem the person can switch off, in the profile's own group.
+        pkg({ name: 'dsh-selected', installed: false, error: { code: 'not-bundle' } }),
+        pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false }),
+      ],
+      busy: ['dsh-protected'],
+    })
+    const cards = screen.getAllByRole('listitem')
+    // The built-in group comes first.
+    expect(cards.map(card => card.getAttribute('data-plugin-package'))).toEqual([
+      '@deepseek-ai/dsh-experimental-agent-team-profile', 'dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool', 'dsh-selected',
+    ])
+    expect(cards.map(card => card.getAttribute('data-plugin-status'))).toEqual(['disabled', 'running', 'problem', 'running', 'disabled', 'problem'])
+    // Each group heads with its title and its bare count; the built-in card carries the official tag.
+    expect(screen.getByRole('heading', { name: en.bundlesTitle })).toBeTruthy()
+    expect(screen.getByRole('heading', { name: en.builtinTitle })).toBeTruthy()
+    expect([...document.querySelectorAll('[data-plugin-count]')].map(count => count.textContent)).toEqual(['1', '5'])
+    expect(screen.getAllByText(en.statusOfficial)).toHaveLength(1)
+    // 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(screen.getAllByText(en.statusProblem)).toHaveLength(2)
+    // 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: 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(en.reasonManagementRequired)
+  })
+
+  it('opens a built-in bundle\'s page with its official tag and no uninstall, and switches it on', () => {
+    const { actions } = renderTab({
+      packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })],
+    })
+    fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'experimental-agent-team-profile') }))
+    const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
+    expect(within(detail).getByText(en.statusOfficial)).toBeTruthy()
+    expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', 'experimental-agent-team-profile') })).toBeNull()
+    fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'experimental-agent-team-profile') }))
+    expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true)
+  })
+
+  it('opens a guide under the field and drops an example into it', () => {
+    const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
+    expect(screen.queryByText(en.installGuideIntro)).toBeNull()
+    const toggle = screen.getByRole('button', { name: en.installGuideToggle })
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(toggle)
+    expect(screen.getByRole('button', { name: en.installGuideHide }).getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByText(en.installGuideIdNote)).toBeTruthy()
+    expect(screen.getByText(en.installGuideGitExample)).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: en.installGuideFillAria.replace('{example}', en.installGuideIdExample) }))
+    expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith(en.installGuideIdExample)
+    fireEvent.click(screen.getByRole('button', { name: en.installGuideHide }))
+    expect(screen.queryByText(en.installGuideIntro)).toBeNull()
+  })
+
+  it('opens a bundle\'s page with its facts and rows, and uninstalls from it', () => {
+    const { actions, set } = renderTab({
+      packages: [pkg({
+        description: 'A sidebar.',
+        rows: [row(), row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId, enabled: false, phase: null })],
+      })],
+    })
+    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')
+    // The version sits beside the name as a tag; the crumb only leads back.
+    expect(within(detail).getByText('v0.16.0')).toBeTruthy()
+    // The full package name stays visible under the short name.
+    expect(document.querySelector('[data-plugin-name]')?.textContent).toBe('dsh-better-sidebar')
+    expect(within(detail).getByRole('button', { name: en.backToList }).textContent).toBe(en.crumbRoot)
+    expect(within(detail).getByText('A sidebar.')).toBeTruthy()
+    // The rows, in order, with their state and their module.
+    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()
+    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 problem and a protection the Host reports read on the page in the dictionary's words; the page leaves with the crumb.
+    set({ packages: [pkg({ error: { code: 'operation-error', diagnostic: 'unreadable' }, readOnlyReason: 'management-required' })] })
+    expect(within(detail).getByText(`${en.reasonLabel}: unreadable`)).toBeTruthy()
+    expect(within(detail).getByText(en.reasonManagementRequired)).toBeTruthy()
+    expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') })).toHaveProperty('disabled', true)
+    expect(within(detail).getByText(en.partsEmpty)).toBeTruthy()
+    set({ packages: [pkg({ error: { code: 'not-bundle' } })] })
+    expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonNotBundle}`)).toBeTruthy()
+    set({ packages: [pkg({ error: { code: 'operation-error' } })] })
+    expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonOperationError}`)).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()
+    expect(document.querySelector('[data-plugin-version]')).toBeNull()
+    set({ packages: [] })
+    expect(document.querySelector('[data-plugin-detail]')).toBeNull()
+    expect(screen.getByText(en.empty)).toBeTruthy()
+  })
+
+  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: 'unaddressable' as const } : {},
+        ...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 }
+    })
+    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(en.reasonUnaddressable)
+    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(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', () => {
+    const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
+    expect(screen.getByText(en.installDescription)).toBeTruthy()
+    const install = () => screen.getByRole('button', { name: en.installRun })
+    expect(install()).toHaveProperty('disabled', true)
+    const field = screen.getByRole('textbox', { name: en.installSpecLabel })
+    fireEvent.change(field, { target: { value: 'dsh-x' } })
+    expect(actions.editInstallSpec).toHaveBeenCalledWith('dsh-x')
+    expect(document.querySelector('[data-terminal]')).toBeNull()
+    expect(screen.queryByRole('checkbox')).toBeNull()
+
+    set({ install: { ...IDLE_INSTALL, open: true, spec: ' dsh-x ' } })
+    fireEvent.keyDown(screen.getByRole('textbox', { name: en.installSpecLabel }), { key: 'Enter' })
+    fireEvent.click(install())
+    expect(actions.runInstall).toHaveBeenCalledTimes(2)
+    // The check keeps the field and the button inert.
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'checking' } })
+    expect(screen.getByRole('textbox', { name: en.installSpecLabel })).toHaveProperty('disabled', true)
+    const checking = screen.getByRole('button', { name: en.installChecking })
+    expect(checking).toHaveProperty('disabled', true)
+    fireEvent.keyDown(screen.getByRole('textbox', { name: en.installSpecLabel }), { key: 'Enter' })
+    expect(actions.runInstall).toHaveBeenCalledTimes(2)
+
+    // Each refusal reads under the field.
+    const problems: [string, string][] = [
+      ['invalid-spec', en.installProblemInvalid.replace('{reason}', 'r')],
+      ['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')],
+    ]
+    for (const [problem, sentence] of problems) {
+      set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', inputError: { problem: problem as never, reason: 'r' } } })
+      expect(screen.getByRole('alert').textContent).toBe(sentence)
+      expect(screen.getByRole('textbox', { name: en.installSpecLabel }).getAttribute('aria-invalid')).toBe('true')
+    }
+    fireEvent.click(screen.getByRole('button', { name: en.close }))
+    expect(actions.closeInstall).toHaveBeenCalledTimes(1)
+  })
+
+  it('shows the subject while installing, folds the pnpm output behind the details, and stops through the Host', () => {
+    const subject = { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', 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('dsh-x')).toBeTruthy()
+    expect(screen.getByText('A sidebar.')).toBeTruthy()
+    expect(screen.getByText(en.installVersion.replace('{version}', '1.4.2'))).toBeTruthy()
+    expect(screen.queryByRole('textbox')).toBeNull()
+    // The output stays folded until asked for.
+    expect(document.querySelector('[data-terminal]')).toBeNull()
+    const details = screen.getByRole('button', { name: en.installDetailsShow })
+    expect(details.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(details)
+    expect(actions.toggleInstallDetails).toHaveBeenCalledTimes(1)
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, runs: [run], detailsOpen: true } })
+    expect(screen.getByRole('button', { name: en.installDetailsHide }).getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByText(en.installLocation.replace('{dir}', '/home/u/.dsh/profiles/web'))).toBeTruthy()
+    // The run streams as a terminal: its command line, its coloured output so far, the running label.
+    expect(screen.getByText('pnpm add dsh-x')).toBeTruthy()
+    const terminal = document.querySelector('[data-terminal]') as HTMLElement
+    expect(terminal.hasAttribute('data-running')).toBe(true)
+    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 ${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') }))
+    expect(screen.getByText('line 8')).toBeTruthy()
+    // Before the first chunk there is no location to name.
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, detailsOpen: true } })
+    expect(screen.getByText(en.terminalNoOutput)).toBeTruthy()
+    // Cancel and the back control each ask the Host to stop the run; close waits for the Host's word.
+    fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
+    fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
+    expect(actions.cancelInstall).toHaveBeenCalledTimes(2)
+    expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', true)
+    expect(actions.closeInstall).not.toHaveBeenCalled()
+  })
+
+  it('waits with the Host through starting, stopping, and applying, and words an unconfirmed stop', () => {
+    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)
+    expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true)
+    expect(screen.getByRole('button', { name: en.installEditAria })).toHaveProperty('disabled', true)
+    expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', true)
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject } })
+    fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
+    fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
+    expect(actions.cancelInstall).toHaveBeenCalledTimes(2)
+    // While the Host stops the run the terminal reads as cancelled rather than failed.
+    set({
+      install: {
+        ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'cancelling', subject, detailsOpen: true,
+        runs: [{ jobId: 'j', command: 'pnpm add slow', cwd: '/p', output: '', exitCode: null }],
+      },
+    })
+    expect(screen.getByRole('status').textContent).toBe(en.installCancelling)
+    expect(screen.getByRole('button', { name: en.installCancelling })).toHaveProperty('disabled', true)
+    expect(within(document.querySelector('[data-terminal]') as HTMLElement).getByText(en.installCancelledShort)).toBeTruthy()
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'applying', subject } })
+    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: { 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 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,
+        open: true,
+        spec: '/plugins/dsh-x',
+        phase: 'done',
+        subject,
+        runs: [{ jobId: 'j1', command: 'pnpm add /plugins/dsh-x', cwd: '/p', output: 'Done in 1s\n', exitCode: 0 }],
+        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.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', restartRequired: true, enabling: true } })
+    expect(screen.getByRole('button', { name: en.installEnableNow })).toHaveProperty('disabled', true)
+    expect(screen.getByText(en.installDoneRestart)).toBeTruthy()
+
+    // 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('asks to allow the scripts a blocked install left pending, retries with them, and says what was allowed', () => {
+    const subject = { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true } as const
+    const { actions, set } = renderTab({
+      install: {
+        ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'failed', subject,
+        failure: { reason: 'ERR', kind: 'build-blocked', pendingBuilds: ['native', '@scope/other'] },
+      },
+    })
+    expect(screen.getByText(en.installFailureBuildBlocked)).toBeTruthy()
+    const group = screen.getByRole('group', { name: en.installApprovalTitle })
+    expect(within(group).getByText('native')).toBeTruthy()
+    expect(within(group).getByText('@scope/other')).toBeTruthy()
+    expect(within(group).getByText(en.installApprovalCaution)).toBeTruthy()
+    // Plain retry would fail the same way, so only the approval is offered.
+    expect(screen.queryByRole('button', { name: en.installRetry })).toBeNull()
+    fireEvent.click(within(group).getByRole('button', { name: en.installApproveAndRetry }))
+    expect(actions.approveBuildsAndRetry).toHaveBeenCalledTimes(1)
+    // Without the pending names the failure reads as the manual instruction, and plain retry is back.
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'failed', subject, failure: { reason: 'ERR', kind: 'build-blocked' } } })
+    expect(screen.getByText(en.installFailureBuildBlockedManual)).toBeTruthy()
+    expect(screen.queryByRole('group', { name: en.installApprovalTitle })).toBeNull()
+    expect(screen.getByRole('button', { name: en.installRetry })).toBeTruthy()
+    // The installed screen says which scripts were allowed.
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject, installed: 'dsh-x', approvedBuilds: ['native'] } })
+    expect(screen.getByText(en.installDoneApproved.replace('{names}', 'native'))).toBeTruthy()
+  })
+
+  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,
+        open: true,
+        spec: 'github:a/b',
+        phase: 'failed',
+        subject,
+        detailsOpen: true,
+        runs: [{ jobId: 'j1', command: 'pnpm add github:a/b', cwd: '/p', output: 'ERR\n', exitCode: 1 }],
+        failure: { reason: 'ERR\n', kind: 'network' },
+      },
+    })
+    expect(screen.getByRole('alert').textContent).toBe(en.installFailedTitle)
+    expect(screen.getByText(en.installFailureNetwork)).toBeTruthy()
+    // A git spec without a manifest reads by its address and kind.
+    expect(screen.getByText('github:a/b')).toBeTruthy()
+    expect(screen.getByText(en.installSubjectGit)).toBeTruthy()
+    expect(screen.getByText(en.terminalExitCode.replace('{code}', '1'))).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: en.installRetry }))
+    expect(actions.runInstall).toHaveBeenCalledTimes(1)
+    fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
+    expect(actions.cancelInstall).toHaveBeenCalledTimes(1)
+
+    const kinds: [string, string][] = [
+      ['pnpm-missing', en.installFailurePnpmMissing], ['timeout', en.installFailureTimeout],
+      ['not-found', en.installFailureNotFound], ['no-matching-version', en.installFailureNoMatchingVersion],
+      ['disk-full', en.installFailureDiskFull], ['permission', en.installFailurePermission],
+      ['build-blocked', en.installFailureBuildBlockedManual], ['integrity', en.installFailureIntegrity], ['unknown', en.installFailureGeneric],
+    ]
+    for (const [kind, sentence] of kinds) {
+      set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'r', kind: kind as never } } })
+      expect(screen.getByText(sentence)).toBeTruthy()
+    }
+    // A failure without a kind reads by its code, else 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: '', code: 'not-bundle' } } })
+    expect(screen.getByText(en.reasonNotBundle)).toBeTruthy()
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'ERR_PNPM_ADDING_TO_ROOT', code: 'operation-error' } } })
+    expect(screen.getByText('ERR_PNPM_ADDING_TO_ROOT')).toBeTruthy()
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'the transport said so' } } })
+    expect(screen.getByText('the transport said so')).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: 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', () => {
+    vi.useFakeTimers()
+    const scrollIntoView = vi.fn()
+    const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView')
+    Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true })
+    try {
+      const { actions, set } = renderTab({ packages: [pkg()] })
+      // A package the list does not show has nothing to scroll to; the mark still times out.
+      set({ highlight: 'missing' })
+      expect(scrollIntoView).not.toHaveBeenCalled()
+      act(() => { vi.advanceTimersByTime(2_400) })
+      expect(actions.clearHighlight).toHaveBeenCalledTimes(1)
+      set({ highlight: 'dsh-better-sidebar' })
+      expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')?.hasAttribute('data-plugin-highlight')).toBe(true)
+      expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' })
+      act(() => { vi.advanceTimersByTime(2_400) })
+      expect(actions.clearHighlight).toHaveBeenCalledTimes(2)
+      set({ highlight: null })
+      expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')?.hasAttribute('data-plugin-highlight')).toBe(false)
+    } finally {
+      if (descriptor === undefined) delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView
+      else Object.defineProperty(Element.prototype, 'scrollIntoView', descriptor)
+      vi.useRealTimers()
+    }
+  })
+
+  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('confirms an uninstall by the package\'s title and runs the action through it', () => {
+    const { actions, set } = renderTab({
+      packages: [pkg(), pkg({ name: 'dsh-other' })],
+      confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar' },
+    })
+    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' } })
+    expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'other') })).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: en.confirmUninstall }))
+    expect(actions.confirm).toHaveBeenCalledTimes(1)
+  })
+
+  it('words every notice as a toast that dismisses itself', () => {
+    vi.useFakeTimers()
+    try {
+      const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x', seq: 1 } })
+      expect(screen.getByRole('alert').textContent).toContain(en.restartNotice)
+      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)
+      // A failure names what was being done; a refusal is worded by its code, a silent one generically.
+      set({ notice: { kind: 'failed', action: 'enable', reason: 'the tree rejected it', packageName: 'pkg-1', seq: 4 } })
+      expect(screen.getByRole('alert').textContent).toContain(en.failedEnable.replace('{reason}', 'the tree rejected it'))
+      set({ notice: { kind: 'failed', action: 'uninstall', code: 'bundle-in-use', reason: '', packageName: 'pkg-1', seq: 5 } })
+      expect(screen.getByRole('alert').textContent).toContain(en.failedUninstall.replace('{reason}', en.reasonBundleInUse))
+      set({ notice: { kind: 'failed', action: 'rowDisable', code: 'operation-error', reason: 'EACCES', packageName: 'pkg-1', seq: 6 } })
+      expect(screen.getByRole('alert').textContent).toContain(en.failedRowDisable.replace('{reason}', 'EACCES'))
+      set({ notice: { kind: 'failed', action: 'disable', reason: '', packageName: 'pkg-1', seq: 7 } })
+      expect(screen.getByRole('alert').textContent).toContain(en.failedDisable.replace('{reason}', en.reasonOperationError))
+      set({ notice: { kind: 'failed', action: 'rowEnable', reason: 'x', packageName: 'pkg-1', seq: 8 } })
+      expect(screen.getByRole('alert').textContent).toContain(en.failedRowEnable.replace('{reason}', 'x'))
+      // 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()
+      // The hold grows with the text, up to eight seconds, then the fade.
+      act(() => { vi.advanceTimersByTime(8_000 + 1_000) })
+      expect(actions.dismissNotice).toHaveBeenCalledTimes(1)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})

+ 721 - 0
packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts

@@ -0,0 +1,721 @@
+/**
+ * The manager store: what it reads, how actions cross the wire, which
+ * outcomes become toasts, and how the install run folds its output.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginInfo, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import { packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts'
+
+const ROW_ENTRY = 'include:sidebar' as PluginEntryId
+
+const BUNDLE: BundleInfo = {
+  name: 'dsh-better-sidebar',
+  version: '0.16.0',
+  description: 'A sidebar.',
+  enabled: false,
+  installed: true,
+  optional: false,
+  removable: true,
+  rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme' }],
+  overrides: [],
+}
+
+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: 'management-required' },
+]
+
+/** What the check answers for a registry name. */
+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', stage: 'enable', target: 'dsh-better-sidebar' }
+
+/** A change the Host could not apply, with the refusal it names. */
+function failed(error?: ManagementError, packageResult?: ChangeResult['packageResult']): ChangeResult {
+  return {
+    changed: false, application: 'failed', stage: 'enable', target: 'dsh-better-sidebar',
+    ...error === undefined ? {} : { error }, ...packageResult === undefined ? {} : { packageResult },
+  }
+}
+
+function ok<T>(value: T) {
+  return { ok: true as const, value }
+}
+
+function refused(code: string, message: string, details: object = {}) {
+  // The double's code map is keyed by literal codes; a spec-chosen string stands in.
+  return { ok: false as const, error: new RemoteError(code as never, message, details as never) }
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>((res) => { resolve = res })
+  return { promise, resolve }
+}
+
+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 = {
+    listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
+    listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))),
+    inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))),
+    installBundle: vi.fn(() => Promise.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))),
+    cancelInstall: vi.fn(() => Promise.resolve(ok({ status: 'cancelled' }))),
+    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: { pluginManager: plugins, pluginInventory: inventory } } as never
+  const controller = new PluginManagerController(ctx)
+  const face = controller.inject()
+  const state = () => controller.getSnapshot()
+  /** The request id of the run the dialog just handed to the Host. */
+  const started = async (): Promise<PluginInstallRequestId> => {
+    await vi.waitFor(() => { expect(state().install.phase).toBe('starting') })
+    return state().install.requestId as PluginInstallRequestId
+  }
+  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', description: 'A sidebar.',
+      installed: true, optional: false, enabled: false,
+      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, optional: false, removable: false, readOnlyReason: 'management-required',
+      error: { code: 'operation-error', diagnostic: '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, optional: false, enabled: true, readOnlyReason: 'management-required',
+      error: { code: 'operation-error', diagnostic: 'broken' },
+      rows: [
+        { rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' },
+        { rowId: 'gone', moduleName: 'x', entryId: 'include:gone', enabled: false, phase: null },
+      ],
+    })
+  })
+})
+
+describe('sortPackages', () => {
+  it('orders packages by the short name a person reads, not by the Host order or enablement', async () => {
+    const plain = { enabled: true, installed: true, optional: false, removable: true, rows: [], overrides: [] }
+    const zeta: BundleInfo = { ...plain, name: 'dsh-zeta' }
+    const alpha: BundleInfo = { ...plain, name: '@acme/dsh-alpha', enabled: false }
+    const views = [zeta, BUNDLE, alpha].map(bundle => packageView(bundle, PLUGINS))
+    expect(sortPackages(views).map(pkg => pkg.name)).toEqual(['@acme/dsh-alpha', 'dsh-better-sidebar', 'dsh-zeta'])
+    // The store lists what it read in that order, whatever the Host's order.
+    const { state, controller } = bench({ listBundles: vi.fn(() => Promise.resolve(ok([zeta, BUNDLE, alpha]))) })
+    await controller.load()
+    expect(state().packages.map(pkg => pkg.name)).toEqual(['@acme/dsh-alpha', 'dsh-better-sidebar', 'dsh-zeta'])
+  })
+})
+
+describe('PluginManagerController', () => {
+  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([BUNDLE]))
+    await mid
+    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.listBundles).toHaveBeenCalledTimes(2)
+  })
+
+  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().packages).toHaveLength(1)
+    inventory.list.mockResolvedValueOnce(ok({ entries: [] }))
+    await controller.load()
+    expect(state()).toMatchObject({ status: 'unavailable', packages: [] })
+    inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
+    await controller.load()
+    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 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.setBundleEnabled).toHaveBeenCalledExactlyOnceWith(BUNDLE.name, true)
+    gate.resolve(ok(APPLIED))
+    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
+    expect(state().notice).toBeNull()
+    expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+    face.setEnabled(BUNDLE.name, false)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name, seq: 1 }) })
+    face.setEnabled(BUNDLE.name, false)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'overridden', packageName: BUNDLE.name, seq: 2 }) })
+  })
+
+  it('turns a change the Host could not apply, or a refused answer, into a notice carrying its code and words', async () => {
+    const { face, state, controller } = bench({
+      setBundleEnabled: vi.fn()
+        .mockResolvedValueOnce(ok(failed({ code: 'operation-error', diagnostic: 'the tree rejected it' })))
+        .mockResolvedValueOnce(refused('gateway/internal', 'offline'))
+        .mockRejectedValueOnce(new Error('transport down'))
+        .mockRejectedValueOnce('odd')
+        .mockResolvedValueOnce(ok(failed({ code: 'bundle-in-use' })))
+        .mockResolvedValueOnce(ok(failed()))
+        .mockResolvedValueOnce(ok({ ...failed(), application: 'cancelled' })),
+    })
+    await controller.load()
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'operation-error', reason: 'the tree rejected it', packageName: BUNDLE.name, seq: 1 }) })
+    face.setEnabled(BUNDLE.name, false)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'disable', reason: 'offline', packageName: BUNDLE.name, seq: 2 }) })
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: 'transport down', packageName: BUNDLE.name, seq: 3 }) })
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: 'odd', packageName: BUNDLE.name, seq: 4 }) })
+    // A refusal keeps its code with no words of its own; a failure without a code has neither.
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'bundle-in-use', reason: '', packageName: BUNDLE.name, seq: 5 }) })
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: '', packageName: BUNDLE.name, seq: 6 }) })
+    // A change the Host stopped is said in passing.
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'cancelled', seq: 7 }) })
+    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()
+    face.uninstall(BUNDLE.name)
+    expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name })
+    face.cancelConfirm()
+    expect(state().confirm).toBeNull()
+    face.confirm()
+    expect(plugins.removeBundle).not.toHaveBeenCalled()
+    face.uninstall(BUNDLE.name)
+    face.confirm()
+    expect(state().confirm).toBeNull()
+    await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) })
+    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
+    // A refused removal names the action it was.
+    plugins.removeBundle.mockResolvedValueOnce(ok({ ...failed(), stage: 'remove', error: { code: 'not-removable' } }) as never)
+    face.uninstall(BUNDLE.name)
+    face.confirm()
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'uninstall', code: 'not-removable', reason: '', packageName: BUNDLE.name, seq: 1 }) })
+  })
+
+  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(failed({ code: 'unaddressable' }))),
+    })
+    await controller.load()
+    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', action: 'rowEnable', code: 'unaddressable', reason: '', 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<ChangeResult>>>()
+    const { plugins, face, state, controller, started } = bench({ installBundle: vi.fn().mockReturnValueOnce(gate.promise) })
+    await controller.load()
+    face.runInstall()
+    expect(plugins.inspect).not.toHaveBeenCalled()
+    face.openInstall()
+    expect(state().install).toMatchObject({ open: true, spec: '', phase: 'idle', inputError: null, subject: null })
+    face.editInstallSpec('  dsh-new ')
+    face.runInstall()
+    face.runInstall()
+    expect(state().install.phase).toBe('checking')
+    // Neither typing nor a second run reaches the Host while it checks.
+    face.editInstallSpec('other')
+    expect(state().install.spec).toBe('  dsh-new ')
+    expect(plugins.inspect).toHaveBeenCalledTimes(1)
+    expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', expect.any(AbortSignal))
+    const requestId = await started()
+    expect(state().install.subject).toEqual({ spec: 'dsh-new', ...INSPECTED })
+    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', stream: 'stdout', text: 'x' })
+    expect(state().install.runs).toEqual([])
+    const argv = ['pnpm', 'add', 'dsh-new']
+    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: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
+    ])
+    face.toggleInstallDetails()
+    expect(state().install.detailsOpen).toBe(true)
+    gate.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    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', 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\n', exitCode: 0 },
+      { jobId: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
+    ])
+    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: null, subject: null })
+    face.closeInstall()
+    expect(state().install.open).toBe(false)
+  })
+
+  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(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()
+    face.editInstallSpec(BUNDLE.name)
+    face.runInstall()
+    expect(plugins.inspect).not.toHaveBeenCalled()
+    expect(state().install).toMatchObject({ phase: 'idle', inputError: { problem: 'already-installed', reason: BUNDLE.name } })
+    // Typing clears the refusal.
+    face.editInstallSpec('nope')
+    expect(state().install.inputError).toBeNull()
+    face.runInstall()
+    await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'not-found', reason: 'E404' }) })
+    expect(state().install.phase).toBe('idle')
+    expect(plugins.installBundle).not.toHaveBeenCalled()
+    face.editInstallSpec('plain')
+    face.runInstall()
+    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: '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)),
+      installBundle: vi.fn().mockResolvedValue(ok(failed({ code: 'operation-error', diagnostic: 'ERR' }, { exitCode: 1, output: 'ERR', truncated: false, logPath: '/l', kind: 'network' }))),
+    })
+    await controller.load()
+    face.openInstall()
+    face.editInstallSpec('dsh-x')
+    face.runInstall()
+    const checkSignal = (plugins.inspect.mock.calls[0] as unknown[])[1] as AbortSignal
+    face.cancelInstall()
+    expect(checkSignal.aborted).toBe(true)
+    expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'dsh-x', inputError: null })
+    // The settlement of the dropped check changes nothing.
+    inspectGate.resolve(ok(INSPECTED))
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(state().install.phase).toBe('idle')
+    expect(plugins.installBundle).not.toHaveBeenCalled()
+    // Closing during a check drops it too.
+    face.runInstall()
+    face.closeInstall()
+    expect(state().install.open).toBe(false)
+    expect((plugins.inspect.mock.calls[1] as unknown[])[1]).toMatchObject({ aborted: true })
+    // From the failed screen the same control goes back to the spec.
+    face.openInstall()
+    face.editInstallSpec('dsh-x')
+    face.runInstall()
+    await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
+    expect(state().install.failure).toEqual({ reason: 'ERR', code: 'operation-error', 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 ok<ChangeResult>>>()
+    const second = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
+    const { plugins, face, state, controller, started } = bench({
+      installBundle: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
+      cancelInstall: vi.fn().mockReturnValueOnce(cancellation.promise),
+    })
+    face.openInstall()
+    face.editInstallSpec('slow')
+    face.runInstall()
+    const requestId = await started()
+    // Before the Host acknowledges the run there is nothing to stop, and the dialog cannot close.
+    face.cancelInstall()
+    face.closeInstall()
+    expect(plugins.cancelInstall).not.toHaveBeenCalled()
+    expect(state().install).toMatchObject({ open: true, phase: 'starting' })
+    controller.installProgress({ requestId, phase: 'installing' })
+    face.cancelInstall()
+    face.cancelInstall()
+    face.closeInstall()
+    face.openInstall()
+    expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
+    expect(state().install).toMatchObject({ phase: 'cancelling', open: true, spec: 'slow' })
+    // A queued start cannot undo the request to stop; the Host's own cancelling phase is the same.
+    controller.installProgress({ requestId, phase: 'installing' })
+    controller.installProgress({ requestId, phase: 'cancelling' })
+    expect(state().install.phase).toBe('cancelling')
+    cancellation.resolve(ok({ status: 'cancelled' }))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
+    // The spec is offered again, the run is forgotten, and a toast says the Host stopped it.
+    expect(state().install).toMatchObject({ open: true, spec: 'slow', subject: null, runs: [] })
+    expect(state().install.requestId).toBeUndefined()
+    expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
+    face.runInstall()
+    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(ok({ ...failed(), application: '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', stream: 'stdout', text: 'late' })
+    expect(state().install).toMatchObject({ phase: 'starting', runs: [] })
+    second.resolve(ok({ ...APPLIED, application: 'restart-required', 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 ok<ChangeResult>>>()
+    const { plugins, face, state, controller, started } = bench({
+      installBundle: vi.fn().mockReturnValue(pending.promise),
+      cancelInstall: vi.fn().mockResolvedValue(status === 'offline' ? refused('gateway/internal', 'offline') : ok({ status })),
+    })
+    face.openInstall()
+    face.editInstallSpec('slow')
+    face.runInstall()
+    const requestId = await started()
+    controller.installProgress({ requestId, phase: 'installing' })
+    face.cancelInstall()
+    await vi.waitFor(() => { expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'running') })
+    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 : { reason: status === 'offline' ? 'offline' : '', cancelUnconfirmed: true },
+    )
+    // The Host's own word that it stopped the run still ends it.
+    pending.resolve(ok({ ...failed(), application: '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<ChangeResult>>>()
+    const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
+    const { face, state, controller, started } = bench({
+      installBundle: vi.fn().mockReturnValue(answer.promise),
+      cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
+    })
+    await controller.load()
+    face.openInstall()
+    face.editInstallSpec('slow')
+    face.runInstall()
+    controller.installProgress({ requestId: await started(), phase: 'installing' })
+    face.cancelInstall()
+    expect(state().install.phase).toBe('cancelling')
+    if (dispose) controller.dispose()
+    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()
+    await Promise.resolve()
+    expect(state().install.phase).toBe(dispose ? 'cancelling' : 'done')
+    expect(state().notice).toBeNull()
+  })
+
+  it('enables what a finished install added from its screen, closes, and marks it in the list', async () => {
+    const { plugins, face, state, controller } = bench({
+      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(failed({ code: 'operation-error', diagnostic: 'the tree rejected it' }))),
+    })
+    await controller.load()
+    face.enableInstalled()
+    expect(plugins.setBundleEnabled).not.toHaveBeenCalled()
+    face.openInstall()
+    face.editInstallSpec('dsh-a')
+    face.runInstall()
+    await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    face.enableInstalled()
+    face.enableInstalled()
+    expect(state().install.enabling).toBe(true)
+    await vi.waitFor(() => { expect(state().install.open).toBe(false) })
+    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 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.setBundleEnabled).toHaveBeenCalledTimes(2)
+    expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'operation-error', reason: 'the tree rejected it', packageName: 'dsh-a', seq: 2 })
+    expect(state().highlight).toBe('dsh-a')
+
+    // 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.setBundleEnabled).toHaveBeenCalledTimes(2)
+    expect(state().highlight).toBeNull()
+  })
+
+  it('offers the scripts a blocked run left pending, and retries the same spec with them allowed', async () => {
+    const gates: ReturnType<typeof deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>>[] = []
+    const { face, state, controller, plugins, started } = bench({
+      installBundle: vi.fn(() => {
+        const gate = deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>()
+        gates.push(gate)
+        return gate.promise
+      }),
+    })
+    await controller.load()
+    face.openInstall()
+    face.editInstallSpec('x')
+    // Before the failed screen offers anything, the action does nothing.
+    face.approveBuildsAndRetry()
+    face.runInstall()
+    const first = await started()
+    gates[0]!.resolve(ok({
+      ...failed({ code: 'operation-error', diagnostic: 'ERR_PNPM_IGNORED_BUILDS' },
+        { exitCode: 1, output: 'ERR_PNPM_IGNORED_BUILDS', truncated: false, logPath: '/l', kind: 'build-blocked' }),
+      pendingBuilds: ['native'],
+    }))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
+    expect(state().install.failure).toEqual({ reason: 'ERR_PNPM_IGNORED_BUILDS', code: 'operation-error', kind: 'build-blocked', pendingBuilds: ['native'] })
+    // The retry keeps the subject the check produced and carries the approved names under a new request id.
+    face.approveBuildsAndRetry()
+    const second = await started()
+    expect(second).not.toBe(first)
+    expect(plugins.installBundle).toHaveBeenLastCalledWith('x', { enabled: false, requestId: second, approvedBuilds: ['native'] })
+    expect(state().install).toMatchObject({ subject: { spec: 'x', name: 'dsh-better-sidebar' }, failure: null })
+    gates[1]!.resolve(ok({ ...APPLIED, bundle: 'dsh-better-sidebar', approvedBuilds: ['native'] }))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    expect(state().install).toMatchObject({ installed: 'dsh-better-sidebar', approvedBuilds: ['native'] })
+    expect(plugins.installBundle).toHaveBeenCalledTimes(2)
+  })
+
+  it('drops an enable from the installed screen that settles after disposal', async () => {
+    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.setBundleEnabled).toHaveBeenCalledWith('dsh-new', true)
+    const before = state()
+    controller.dispose()
+    enableGate.resolve(ok(APPLIED))
+    await Promise.resolve()
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(state()).toBe(before)
+  })
+
+  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')
+    face.runInstall()
+    await started()
+    // The Host announces the change before the run answers; the read it triggers must not drop the answer.
+    await controller.load()
+    gate.resolve(ok({ ...APPLIED, bundle: 'pkg' }))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    expect(plugins.listBundles).toHaveBeenCalledTimes(3)
+  })
+
+  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 ok<ChangeResult>> | ReturnType<typeof refused>>>>[] = []
+    const { face, state, controller, plugins, started } = bench({
+      installBundle: vi.fn(() => {
+        const gate = deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>()
+        gates.push(gate)
+        return gate.promise
+      }),
+    })
+    const argv = ['pnpm', 'add', 'x']
+    await controller.load()
+    face.openInstall()
+    face.editInstallSpec('x')
+    let requestId = '' as PluginInstallRequestId
+    const installing = async (): Promise<void> => {
+      face.runInstall()
+      requestId = await started()
+    }
+    const answer = (value: ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>): void => {
+      gates[gates.length - 1]?.resolve(value)
+    }
+    // No chunk arrived: the Host's words are the reason, and there is no run; the kind is kept.
+    await installing()
+    answer(ok(failed({ code: 'operation-error', diagnostic: '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({ reason: 'ERR_PNPM', code: 'operation-error', 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({ reason: 'offline' }) })
+    expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'streamed', exitCode: null }])
+    // A pnpm failure settles the open run with the code the answer names.
+    await installing()
+    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'Done' })
+    answer(ok(failed({ code: 'operation-error', diagnostic: '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: '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', stream: 'stdout', text: 'partial', exitCode: 0 })
+    controller.appendLog({ requestId, jobId: 'k', argv, cwd: '/p', stream: 'stdout', text: 'open' })
+    answer(ok(failed({ code: 'not-bundle' })))
+    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: 0 },
+      { jobId: 'k', command: 'pnpm add x', cwd: '/p', output: 'open', exitCode: null },
+    ])
+    expect(state().install.failure).toEqual({ reason: '', code: 'not-bundle' })
+    // A failure the Host does not explain has neither code nor words.
+    await installing()
+    answer(ok(failed()))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
+    expect(state().install.failure).toEqual({ reason: '' })
+    expect(plugins.installBundle).toHaveBeenCalledTimes(5)
+    // 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<ChangeResult>>>()
+    const installGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const { face, state, controller, started } = bench({
+      setBundleEnabled: vi.fn().mockReturnValueOnce(enableGate.promise),
+      installBundle: vi.fn().mockReturnValueOnce(installGate.promise),
+    })
+    await controller.load()
+    face.openInstall()
+    face.editInstallSpec('x')
+    face.runInstall()
+    await started()
+    face.setEnabled(BUNDLE.name, true)
+    const before = state()
+    controller.dispose()
+    enableGate.resolve(ok(APPLIED))
+    installGate.resolve(ok(APPLIED))
+    await Promise.resolve()
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(state()).toBe(before)
+    await controller.load()
+    expect(state()).toBe(before)
+    face.setEnabled(BUNDLE.name, false)
+    expect(state()).toBe(before)
+  })
+
+  it('drops a read that settles after disposal', async () => {
+    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({ entries: [], managementAvailable: true }))
+    await loading
+    expect(state()).toBe(before)
+  })
+
+  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()
+    gate.resolve(ok([BUNDLE]))
+    await loading
+    expect(state()).toBe(before)
+  })
+})

+ 48 - 0
packages/client/ui-plugin-manager/tsconfig.json

@@ -0,0 +1,48 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
+    {
+      "path": "../../boot/plugin-manager"
+    },
+    {
+      "path": "../../test-support/client-runtime"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../store"
+    },
+    {
+      "path": "../ui-layout"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-sidebar"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../../util/crypto"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-plugin-manager/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-plugin-manager', ['lib/types/index.js'])

+ 3 - 3
packages/client/ui-primitives/src/TerminalBlock.module.css

@@ -59,9 +59,9 @@
 }
 
 /* Full-width l2 hairline between the command banner and the body — the same
-   divider the IN/OUT card draws between its sections. A running card is
-   banner-only, so it draws none. */
-.block:not([data-running]) .header {
+   divider the IN/OUT card draws between its sections. A banner-only card (a
+   running command that has printed nothing yet) draws none. */
+.block[data-body] .header {
   border-bottom: 0.5px solid var(--dsw-alias-border-l2);
 }
 

+ 23 - 9
packages/client/ui-primitives/src/TerminalBlock.tsx

@@ -19,6 +19,8 @@ export interface TerminalBlockLabels {
   signal: (signal: string) => string
   /** Status pill text for a non-zero exit code. */
   exitCode: (exitCode: number) => string
+  /** Status pill text for a command that ended without an exit code: killed by a signal the view does not know, or never started. */
+  noExitCode: string
   /** Run-state text while the command is still running. */
   running: string
   /** Run-state text for a signal or non-zero-exit settle. */
@@ -50,11 +52,14 @@ export interface TerminalBlockProps {
   home?: string | undefined
   /** The command's output text; may contain ANSI escape sequences. */
   output?: string | undefined
-  /** Settled exit code; a non-zero value renders the status pill. */
-  exitCode?: number | undefined
+  /** Settled exit code; a non-zero value renders the status pill, and null (settled without one) the no-exit-code pill. */
+  exitCode?: number | null | undefined
   /** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */
   signal?: string | undefined
-  /** The command is still running: the block shows the prompt line alone. */
+  /**
+   * The command is still running: the block shows the prompt line, and the
+   * output printed so far when there is any, with no copy control until it settles.
+   */
   running?: boolean | undefined
   /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
   maxLines?: number | undefined
@@ -84,17 +89,18 @@ function promptLabel(cwd: string, home: string | undefined): string {
  * Status pill text for a settled command, or undefined when the command
  * settled cleanly (exit 0, no signal) and needs no pill — the same
  * distinction the bash tool's own exit-status markers draw.
- * @param exitCode - settled exit code, when known.
+ * @param exitCode - settled exit code, when known; null when the command settled without one.
  * @param signal - settled terminating signal name, when known.
  * @param labels - display copy for the pill text.
  * @returns the pill text, or undefined for a clean exit.
  */
 function statusText(
-  exitCode: number | undefined,
+  exitCode: number | null | undefined,
   signal: string | undefined,
   labels: TerminalBlockLabels,
 ): string | undefined {
   if (signal !== undefined) return labels.signal(signal)
+  if (exitCode === null) return labels.noExitCode
   if (exitCode !== undefined && exitCode !== 0) return labels.exitCode(exitCode)
   return undefined
 }
@@ -110,14 +116,14 @@ function statusText(
  * settled command whose exit status never reached the view counts as a clean
  * settle: the view says it finished and says nothing went wrong.
  * @param running - the command has not settled.
- * @param exitCode - settled exit code, when known.
+ * @param exitCode - settled exit code, when known; null when the command settled without one.
  * @param signal - settled terminating signal name, when known.
  * @param labels - display copy for the text label.
  * @returns the dot's state and its text label, since the dot is aria-hidden.
  */
 function runState(
   running: boolean,
-  exitCode: number | undefined,
+  exitCode: number | null | undefined,
   signal: string | undefined,
   labels: TerminalBlockLabels,
 ): { state: StateDotState; label: string } {
@@ -194,9 +200,17 @@ export function TerminalBlock({
   // for invisible bytes, and hide the placeholder that belongs there.
   const empty = lines.every(line => line.every(span => span.text.trim() === ''))
   const { hidden, capped, headLines, tailLines } = headTailCap(lines.length, maxLines, expanded)
+  // A running command with nothing printed yet is banner-only; once it has
+  // printed, the output streams in under the banner as it would in a terminal.
+  const body = !running || !empty
 
   return (
-    <div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
+    <div
+      className={clsx(css.block, className)}
+      data-terminal=""
+      data-running={running ? '' : undefined}
+      data-body={body ? '' : undefined}
+    >
       <div className={css.header}>
         <div className={css.prompt}>
           <span className={css.runStateLabel}>{state.label}</span>
@@ -227,7 +241,7 @@ export function TerminalBlock({
           </button>
         )}
       </div>
-      {!running && (empty
+      {body && (empty
         ? <div className={css.empty}>{copy.noOutput}</div>
         : (
           <div className={css.output}>

+ 6 - 2
packages/client/ui-primitives/src/ansi.ts

@@ -37,8 +37,10 @@ export type AnsiLine = readonly AnsiSpan[]
  * semantic. Black and white both resolve to the primary label color so text
  * stays legible under either theme instead of matching the surface it sits
  * on; bright black takes the tertiary label color (the muted-gray role).
- * Magenta and cyan have no token equivalent in this design system and fall
- * through to anser's literal rgb, as do all 256-palette and truecolor values.
+ * Cyan and bright cyan take two static blues: the design system has no cyan,
+ * and anser's literal bright cyan is unreadable on the light theme's code
+ * surface. Magenta has no token equivalent and falls through to anser's
+ * literal rgb, as do all 256-palette and truecolor values.
  */
 const TOKEN_BY_BASIC_RGB: Record<string, string> = {
   '0,0,0': 'var(--dsw-alias-label-primary)',
@@ -52,6 +54,8 @@ const TOKEN_BY_BASIC_RGB: Record<string, string> = {
   '255,255,85': 'var(--dsw-alias-state-warn-secondary)',
   '0,0,187': 'var(--dsw-alias-state-business-primary)',
   '85,85,255': 'var(--dsw-static-blue-400)',
+  '0,187,187': 'var(--dsw-static-blue-600)',
+  '85,255,255': 'var(--dsw-static-blue-500)',
 }
 
 /**

+ 22 - 0
packages/client/ui-primitives/src/icons/index.tsx

@@ -903,6 +903,18 @@ export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
   </svg>
 )
 
+/** Info companion to the question outline: the same ring with an "i" glyph. */
+export const IconInfoOutline14 = ({ size = 14, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path
+      d="M12.5757 7.00012C12.5757 3.92085 10.0794 1.42463 7.00012 1.42456C3.9208 1.42456 1.42456 3.9208 1.42456 7.00012C1.42463 10.0794 3.92085 12.5757 7.00012 12.5757C10.0793 12.5756 12.5756 10.0793 12.5757 7.00012ZM13.8002 7.00012C13.8001 10.7559 10.7559 13.8001 7.00012 13.8002C3.2443 13.8002 0.199291 10.7559 0.199219 7.00012C0.199219 3.24426 3.24426 0.199219 7.00012 0.199219C10.7559 0.199291 13.8002 3.2443 13.8002 7.00012Z"
+      fill="currentColor"
+    />
+    <path d="M7.6127 3.18921V4.55986H6.38735V3.18921H7.6127Z" fill="currentColor" />
+    <path d="M7.6127 5.68921V10.8109H6.38735V5.68921H7.6127Z" fill="currentColor" />
+  </svg>
+)
+
 /** Alarm clock outline for active scheduled-task indicators. */
 export const IconAlarmClockOutline16 = ({ size = 16, className }: IconProps) => (
   <svg
@@ -998,3 +1010,13 @@ export const IconShieldOutline16 = ({ size = 16, className }: IconProps) => (
     <path d={SHIELD_OUTLINE_PATH} stroke="currentColor" strokeWidth={SHIELD_OUTLINE_STROKE} strokeLinejoin="round" />
   </svg>
 )
+
+/** Plugin pinwheel (design handoff for the plugin manager): four interleaved arcs on the 16 grid. */
+export const IconPluginPinwheelOutline16 = ({ size = 16, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d="M7.84457 5.06199C11.6605 4.93876 14.7962 6.14848 14.8484 7.76397C14.8875 8.97461 13.1838 10.0696 10.7215 10.5942" stroke="currentColor" strokeWidth="1.2" />
+    <path d="M5.12742 8.07731C5.00419 4.26138 6.21391 1.12568 7.8294 1.07351C9.04004 1.03441 10.135 2.73808 10.6596 5.20037" stroke="currentColor" strokeWidth="1.2" />
+    <path d="M8.02457 10.6802C4.20865 10.8034 1.07294 9.5937 1.02077 7.97821C0.981678 6.76758 2.68535 5.67262 5.14763 5.14798" stroke="currentColor" strokeWidth="1.2" />
+    <path d="M10.7476 7.89535C10.8708 11.7113 9.66109 14.847 8.0456 14.8991C6.83496 14.9382 5.74 13.2346 5.21536 10.7723" stroke="currentColor" strokeWidth="1.2" />
+  </svg>
+)

+ 2 - 1
packages/client/ui-primitives/tests/ansi.client.spec.ts

@@ -52,6 +52,8 @@ describe('parseAnsiLines: basic colors mapped onto theme tokens', () => {
     ['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'],
     ['34', 'blue', 'var(--dsw-alias-state-business-primary)'],
     ['94', 'bright blue', 'var(--dsw-static-blue-400)'],
+    ['36', 'cyan', 'var(--dsw-static-blue-600)'],
+    ['96', 'bright cyan', 'var(--dsw-static-blue-500)'],
   ])('SGR %s (%s) resolves to %s', (code, _name, token) => {
     expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } })
   })
@@ -60,7 +62,6 @@ describe('parseAnsiLines: basic colors mapped onto theme tokens', () => {
 describe('parseAnsiLines: colors with no token equivalent', () => {
   it.each<[string, string, string]>([
     ['35', 'magenta', 'rgb(187, 0, 187)'],
-    ['36', 'cyan', 'rgb(0, 187, 187)'],
     ['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'],
     ['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'],
   ])('SGR %s (%s) falls through to %s', (code, _name, literal) => {

+ 2 - 2
packages/client/ui-primitives/tests/icons.client.spec.tsx

@@ -17,8 +17,8 @@ const icons = Object.fromEntries(
 const iconNames = Object.keys(icons)
 
 describe('ic_ds_ icon set', () => {
-  it('exports the full icon set (46 deepsuite + 21 figma extracts + twelve product glyphs outside those sets)', () => {
-    expect(iconNames.length).toBe(79)
+  it('exports the full icon set (46 deepsuite + 21 figma extracts + fourteen product glyphs outside those sets)', () => {
+    expect(iconNames.length).toBe(81)
     // The composer menu's own glyphs, pinned by name.
     expect(iconNames).toEqual(expect.arrayContaining(['IconPlanOutline14', 'IconCompactOutline16', 'IconShieldOutline16']))
   })

+ 1 - 0
packages/client/ui-primitives/tests/labels.client.ts

@@ -43,6 +43,7 @@ export const searchBlockLabels: SearchBlockLabels = {
 export const terminalBlockLabels: TerminalBlockLabels = {
   signal: signal => `信号 ${signal}`,
   exitCode: code => `退出码 ${code}`,
+  noExitCode: '未正常退出',
   running: '运行中', failed: '失败', done: '已完成',
   copy: '复制', copied: '复制成功', noOutput: '无输出',
   collapseAria: '收起输出', collapse: '收起',

+ 17 - 3
packages/client/ui-primitives/tests/terminal-block.client.spec.tsx

@@ -89,13 +89,21 @@ describe('TerminalBlock prompt label', () => {
 })
 
 describe('TerminalBlock states', () => {
-  it('running shows the command line only: no output, no placeholder, no copy', () => {
-    const view = render(<TerminalBlock command="sleep 5" running output="partial" />)
+  it('running with nothing printed yet shows the command line only: no placeholder, no copy, no body', () => {
+    const view = render(<TerminalBlock command="sleep 5" running />)
     expect(view.getByText('sleep 5')).toBeTruthy()
-    expect(view.queryByText('partial')).toBeNull()
     expect(view.queryByText('无输出')).toBeNull()
     expect(view.queryByRole('button')).toBeNull()
     expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('')
+    expect(view.container.firstElementChild?.hasAttribute('data-body')).toBe(false)
+  })
+
+  it('running shows the output printed so far under the banner, still without a copy control', () => {
+    const view = render(<TerminalBlock command="pnpm add x" running output={'Progress: resolved 1\n'} />)
+    expect(outputLines(view.container)).toEqual(['Progress: resolved 1'])
+    expect(view.queryByRole('button')).toBeNull()
+    expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('')
+    expect(view.container.firstElementChild?.getAttribute('data-body')).toBe('')
   })
 
   it('running still shows a settled-looking status pill when one is supplied', () => {
@@ -186,6 +194,12 @@ describe('TerminalBlock status pill', () => {
     expect(screen.getByText('退出码 1')).toBeTruthy()
   })
 
+  it('renders the no-exit-code pill and the error dot for a command that settled without one', () => {
+    const view = render(<TerminalBlock command="pnpm add x" output="spawn pnpm ENOENT" exitCode={null} />)
+    expect(view.getByText('未正常退出')).toBeTruthy()
+    expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
+  })
+
   it('renders the signal pill, which outranks the exit code', () => {
     render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
     expect(screen.getByText('信号 SIGKILL')).toBeTruthy()

+ 2 - 3
packages/client/ui-settings-general/src/client/SettingsRoot.module.css

@@ -38,7 +38,8 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
-/* Rail trigger: the same 36x36 circle box as the other rail controls. */
+/* Rail trigger: the same 36x36 box and 12px rounding as the other rail
+   controls. */
 .trigger.rail {
   flex: none;
   width: 36px;
@@ -47,8 +48,6 @@
   justify-content: center;
   gap: 0;
   padding: 0;
-  border-radius: 50%;
-  corner-shape: round;
 }
 
 .triggerLabel {

+ 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: dd8c778341feb3969186f1d916362516fa3dccf4
-README.zh.md: e16760937ff003c7cfff95129846a8249849f2c6
+README.md: a82f177b2a0a463a395cef470b526ea3117625fd
+README.zh.md: c6395d015a7c60b0b0738eae96ae99fe1135cedc

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott