Преглед изворни кода

feat(web): guide plugin installation with a pre-install check and classified failures

The install dialog asks the Host to read a spec before installing it:
`plugins/inspect` sorts the spec (registry name, absolute path, git address,
tarball), asks the registry through `pnpm view` in the profile directory, or
reads a directory's package.json, and refuses what would fail with
`plugins/inspect-rejected` and a problem the dialog words under the field.
A failed pnpm run carries a `kind` classified from how it ended and what pnpm
printed, shown as one line with the output folded behind the details.
Enabling happens after the fact from the installed screen, which scrolls the
list to the first new bundle. Refusals of the moment and the confirmed
cancellation are toasts; the run is stopped through the manager's
`cancelInstall` and the dialog offers the spec again once the Host confirms.
The non-pack dependency group folds by default under its own name.

The host config gains `inspectTimeoutMs`; the CLI passes it explicitly.
Yichen Jiang пре 3 дана
родитељ
комит
ac2bed35e2
51 измењених фајлова са 2116 додато и 516 уклоњено
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml
  2. 37 0
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
  3. 37 0
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md
  4. 3 1
      apps/cli/src/plugin.ts
  5. 3 12
      apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md
  6. 4 1
      apps/web/tests/expected/plugin-manager/manager.expected.md
  7. 14 6
      apps/web/tests/plugin-install-cancel.e2e.ts
  8. 35 2
      apps/web/tests/plugin-manager.e2e.ts
  9. 2 2
      docs/config-catalog.i18n.yaml
  10. 3 1
      docs/config-catalog.md
  11. 3 1
      docs/config-catalog.zh.md
  12. 2 2
      docs/event-producer-consumer.i18n.yaml
  13. 3 3
      docs/event-producer-consumer.md
  14. 3 3
      docs/event-producer-consumer.zh.md
  15. 2 2
      docs/module-graph.i18n.yaml
  16. 4 1
      docs/module-graph.md
  17. 4 1
      docs/module-graph.zh.md
  18. 2 2
      docs/subsystems/core.i18n.yaml
  19. 9 0
      docs/subsystems/core.md
  20. 9 0
      docs/subsystems/core.zh.md
  21. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  22. 5 3
      packages/boot/plugin-manager/README.md
  23. 5 3
      packages/boot/plugin-manager/README.zh.md
  24. 2 0
      packages/boot/plugin-manager/src/helpers.ts
  25. 2 0
      packages/boot/plugin-manager/src/index.ts
  26. 43 0
      packages/boot/plugin-manager/src/install-failure.ts
  27. 64 0
      packages/boot/plugin-manager/src/install-spec.ts
  28. 170 5
      packages/boot/plugin-manager/src/installer.ts
  29. 14 0
      packages/boot/plugin-manager/src/manager.ts
  30. 58 2
      packages/boot/plugin-manager/src/types.ts
  31. 71 0
      packages/boot/plugin-manager/tests/install-spec.spec.ts
  32. 126 1
      packages/boot/plugin-manager/tests/plugin-manager.spec.ts
  33. 2 2
      packages/client/ui-plugin-manager/README.i18n.yaml
  34. 4 4
      packages/client/ui-plugin-manager/README.md
  35. 4 4
      packages/client/ui-plugin-manager/README.zh.md
  36. 0 26
      packages/client/ui-plugin-manager/src/client/NoticeLine.tsx
  37. 229 31
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
  38. 271 112
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
  39. 69 25
      packages/client/ui-plugin-manager/src/client/locales.ts
  40. 190 36
      packages/client/ui-plugin-manager/src/client/manager-store.ts
  41. 1 0
      packages/client/ui-plugin-manager/src/client/presentation.ts
  42. 228 90
      packages/client/ui-plugin-manager/tests/components.client.spec.tsx
  43. 321 120
      packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
  44. 14 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  45. 2 2
      packages/host/plugin-manager/README.i18n.yaml
  46. 3 2
      packages/host/plugin-manager/README.md
  47. 3 2
      packages/host/plugin-manager/README.zh.md
  48. 17 0
      packages/host/plugin-manager/src/index.ts
  49. 3 1
      packages/host/plugin-manager/src/types.ts
  50. 7 3
      packages/host/plugin-manager/tests/plugin-manager.spec.ts
  51. 1 0
      scripts/gen-cordis-catalog.ts

+ 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: 8e4b908d5813a0b87b67fe0f775b89f53bf0f5fa
+2026-09-15-guided-plugin-installation.zh.md: 59dbe042d86a2501aad8d38b99b3bb5f3581568e

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

@@ -0,0 +1,37 @@
+# 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. Enabling was a checkbox to tick before knowing what would be installed, and a finished install left the new package somewhere in the list. Refusals such as a running session sat on the page until dismissed.
+
+## Decision
+
+**The Host reads a spec before installing it.** `PluginInstaller.inspect` sorts the spec — registry name, absolute path, git address, tarball — with `parseInstallSpec`, refuses what pnpm or the registry would refuse, and asks the registry through `pnpm view` or the directory through its `package.json` for the name, version, description, title, and bundle declaration. `pnpm view` runs in the profile directory so the registry and proxy settings match the install. `plugins/inspect-rejected` carries one of six problems; the client renders each as a sentence under the field and keeps the spec editable. The dialog refuses a name the list already shows without asking the Host.
+
+**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into a `kind` on `plugins/install-failed`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
+
+**Cancellation is the manager's, not the signal's.** A run is stopped through `plugins/cancelInstall` with the request id the dialog generated, and the dialog waits for the Host's answer before offering the spec again; [the manager note](2026-09-04-plugin-manager-over-the-profile-runtime.md) records why an aborted RPC is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
+
+**Enabling comes after the fact.** The finished screen offers **Enable now** for the bundles the run added; the dialog closes and the list scrolls to the first of them. Nothing is enabled before the person has seen what was installed.
+
+**Refusals of the moment are toasts.** `plugins/busy` and `plugins/agents-running` return the dialog to the spec and toast; every other notice on the page is a toast too. The restart banner stays, because it describes state rather than an event.
+
+## 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.
+
+## Consequences
+
+`plugins/inspect` and `plugins/inspect-rejected` join the Remote; `plugins/install-failed` gains `kind`; the host config gains `inspectTimeoutMs`. The dialog is four screens over one subject card. The non-pack dependency group folds by default and names what it holds.
+
+## Testing
+
+`packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `plugin-manager.spec.ts` drives `inspect` against a fake `pnpm view` and a real directory and checks each kind; `packages/host/plugin-manager/tests` relays `inspect` with its signal. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog and gets the spec back.

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

@@ -0,0 +1,37 @@
+# Agent Note:引导式插件安装
+
+Status: implemented
+
+[English](2026-09-15-guided-plugin-installation.md) | 中文
+
+## 问题
+
+安装对话框把 spec 直接交给 `pnpm add`,并把 pnpm 的终端当作全部说明:打错字、已装过的包、不存在的路径、注册表不可用,最后都是同一个红色退出码,人得去读 pnpm 输出才知道是哪一种,而且一旦开始就停不下来。启用是一个在知道要装什么之前就得勾的选项,装完的包散在列表某处。会话运行中之类的拒绝会一直挂在页面上,直到手动关掉。
+
+## 决定
+
+**宿主先读 spec,再安装。** `PluginInstaller.inspect` 用 `parseInstallSpec` 把 spec 分成注册表名、绝对路径、git 地址、压缩包,拒绝 pnpm 或注册表不会接受的写法,再通过 `pnpm view` 问注册表、或读目录的 `package.json`,得到名字、版本、描述、标题和组合包声明。`pnpm view` 在 profile 目录里运行,让注册表和代理设置与安装一致。`plugins/inspect-rejected` 带六种 problem 之一;客户端把每一种渲染成输入框下的一句话,spec 保留可改。列表里已有的名字由对话框直接拒绝,不问宿主。
+
+**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给 `plugins/install-failed` 加上 `kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
+
+**取消属于管理器,不属于信号。** 运行通过 `plugins/cancelInstall` 带上对话框生成的 request id 来停止,对话框等宿主答复之后才把 spec 重新交回;[管理器笔记](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)记录了为何中止 RPC 不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
+
+**启用在事后。** 完成画面为这次运行新增的组合包提供**立即启用**;对话框关闭,列表滚动到其中第一个。在人看到装了什么之前,不会启用任何东西。
+
+**当下的拒绝是 toast。** `plugins/busy` 与 `plugins/agents-running` 让对话框回到输入并弹 toast;页面上其余提示也都是 toast。重启横幅保留,因为它描述的是状态而非事件。
+
+## 考虑过的替代方案
+
+**在客户端校验 spec。** 否决:规则属于 pnpm、注册表和 profile,客户端无法导入拥有这些规则的宿主包。
+
+**用 HTTP 直接查注册表而不是 `pnpm view`。** 否决:决定安装能否成功的注册表、代理与认证设置都在 pnpm 的配置里,`pnpm view` 读得到,直接 fetch 得重新实现一遍。
+
+**通过中止 add RPC 来停止运行。** 否决:断掉的 RPC 说不清 pnpm 是否停了、manifest 是否已恢复,对话框会在一次仍在写 profile 的运行之上把 spec 重新交回。管理器的 `cancelInstall` 只在清理完成后答复,对话框等它。
+
+## 后果
+
+Remote 新增 `plugins/inspect` 与 `plugins/inspect-rejected`;`plugins/install-failed` 增加 `kind`;宿主配置增加 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。非插件包依赖分组默认折叠,并说明自己装的是什么。
+
+## 测试
+
+`packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`plugin-manager.spec.ts` 用假的 `pnpm view` 和真实目录驱动 `inspect`,并检查每一种 kind;`packages/host/plugin-manager/tests` 连同信号转接 `inspect`。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程并拿回 spec。

+ 3 - 1
apps/cli/src/plugin.ts

@@ -32,7 +32,9 @@ import { INSTALL_ANCHOR } from './profile-boot.ts'
 const NAME = 'dsh'
 
 /** The tooling bounds the command runs with; the Web host reads the same values from its config. */
-const TOOLING: PluginToolingConfig = { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installKillGraceMs: 5_000, installLogTailBytes: 16_384 }
+const TOOLING: PluginToolingConfig = {
+  pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installKillGraceMs: 5_000, installLogTailBytes: 16_384, inspectTimeoutMs: 20_000,
+}
 
 /** Test seams: the child spawner and the static metadata reader. */
 export interface PluginCommandInternals {

+ 3 - 12
apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md

@@ -2,18 +2,9 @@
   - heading "添加插件" [level=2]
   - button "关闭":
     - img
-  - paragraph: 输入插件的包名、本地路径或 Git 地址
+  - paragraph: 你可以从 Git 社区中获取插件 ID,例如 dsh-better-sidebar、github:someone/dsh-plugin,或输入本地路径例如 /path/to/plugin
   - text: 包名或地址
   - textbox "包名或地址":
-    - /placeholder: dsh-better-sidebar 或 /path/to/plugin
+    - /placeholder: 输入插件的包名、本地路径或 Git 地址
     - text: slow-package
-  - text: 例如 dsh-better-sidebar、github:someone/dsh-plugin、/path/to/plugin
-  - checkbox "安装完成后直接启用" [checked]
-  - text: 安装完成后直接启用
-  - status: 已取消安装,本次未继续启用插件。下载缓存或已解包文件可能保留,需要时可重新安装。
-  - paragraph: 安装位置:{{cwd}}/.dsh-home/profiles/scaffold
-  - text: 已取消 $ {{node}} add slow-package 未正常退出
-  - button "复制"
-  - text: "Waiting for package download plugin-manager: installation cancelled"
-  - button "完成"
-  - button "重试"
+  - button "安装"

+ 4 - 1
apps/web/tests/expected/plugin-manager/manager.expected.md

@@ -9,8 +9,11 @@
     - button "查看 示例组合包": 示例组合包
     - text: "Web e2e fixture: a bundle whose one row is an inert plugin."
     - switch "启用 示例组合包"
-- heading "其他已安装包" [level=3]
+- button "非插件包依赖" [expanded]:
+  - img
+  - text: 非插件包依赖
 - text: 1 个
+- paragraph: 这些包不是 dsh 插件包,不会被加载;可以在这里卸载。
 - list:
   - listitem:
     - button "查看 示例插件": 示例插件

+ 14 - 6
apps/web/tests/plugin-install-cancel.e2e.ts

@@ -9,7 +9,7 @@ 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 accepts a retry', async () => {
+it('cancels installation through the UI, restores files, and offers the spec again', async () => {
   const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-'))
   const overlay = join(scratch, 'cordis.patch.yml')
   await writeFile(overlay, `- id: plugin-manager\n  config: ${JSON.stringify({ pnpmCommand: process.execPath, installTimeoutMs: 60_000, installKillGraceMs: 50 })}\n`)
@@ -23,7 +23,9 @@ it('cancels installation through the UI, restores files, and accepts a retry', a
       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; the same installer owns and stops this real child.
+      // Node stands in for the pnpm executable: `view` answers the check that precedes the run,
+      // and the same installer owns and stops the real `add` child.
+      await writeFile(join(profile, 'view'), 'console.log(JSON.stringify({ name: "slow-package", version: "1.0.0" }))\n')
       await writeFile(join(profile, 'add'), `
         import('node:fs').then(fs => {
         fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { partial: '1.0.0' } }));
@@ -40,12 +42,17 @@ it('cancels installation through the UI, restores files, and accepts a retry', a
       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', { name: '添加插件' })
+      // 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()
-      await dialog.getByText('已取消安装,本次未继续启用插件。下载缓存或已解包文件可能保留,需要时可重新安装。', { exact: true }).waitFor()
+      // 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')
@@ -54,9 +61,10 @@ it('cancels installation through the UI, restores files, and accepts a retry', a
         .split(scaffold.harnessHome).join('{{harnessHome}}')
       await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/cancelled.expected.md', import.meta.url)), snapshot, webSnapshotMode())
       await writeFile(join(profile, 'add'), 'console.log("Retry completed")\n')
-      await dialog.getByRole('button', { name: '重试', exact: true }).click()
+      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()
-      await expect.poll(() => dialog.getByRole('button', { name: '完成', exact: true }).count()).toBeGreaterThan(0)
       expect(tripwire.pageErrors).toEqual([])
     } finally { await browser.close() }
   } finally {

+ 35 - 2
apps/web/tests/plugin-manager.e2e.ts

@@ -74,13 +74,18 @@ describe('web e2e: plugin manager', () => {
     const panel = await openPluginsPanel()
 
     await panel.getByText('示例组合包', { exact: true }).waitFor({ timeout: 20_000 })
-    expect(await panel.getByText('示例插件', { exact: true }).count()).toBe(1)
-    // Non-bundle packages remain installed and expose their uninstall on the detail page.
+    // Non-bundle packages remain installed, folded under their own group, and expose their uninstall on the detail page.
+    expect(await panel.getByText('示例插件', { exact: true }).count()).toBe(0)
     const toggle = panel.getByRole('switch', { name: '启用 示例组合包' })
     expect(await toggle.getAttribute('aria-checked')).toBe('false')
     expect(await panel.getByRole('switch', { name: '启用 示例插件' }).count()).toBe(0)
     expect(await panel.getByRole('button', { name: '加入全局' }).count()).toBe(0)
     expect(await panel.getByRole('button', { name: '卸载 示例插件' }).count()).toBe(0)
+    const others = panel.getByRole('button', { name: '非插件包依赖', exact: true })
+    expect(await others.getAttribute('aria-expanded')).toBe('false')
+    expect(await panel.getByRole('button', { name: '查看 示例插件' }).count()).toBe(0)
+    await others.click()
+    expect(await panel.getByText('示例插件', { exact: true }).count()).toBe(1)
     await panel.getByRole('button', { name: '查看 示例插件' }).click()
     await panel.getByRole('button', { name: '卸载 示例插件' }).waitFor({ timeout: 5_000 })
     await panel.getByText('此包未提供组合包 patch,可通过 Cordis 配置手动加载模块。', { exact: true }).waitFor({ timeout: 5_000 })
@@ -92,6 +97,34 @@ describe('web e2e: plugin manager', () => {
     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 and reports the restart it waits for', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable'))
     const panel = await openPluginsPanel()

+ 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: 43c1bd2758d78dce50835186b6a78af7fea3ca25
-config-catalog.zh.md: 4af257ab3d0e4053db76c41a022e68e336d60443
+config-catalog.md: 888b50ee84bad57690622413da79bc5554e4b05b
+config-catalog.zh.md: 2839bb7bf3dd7fb4013fb179def406a6d2ffab74

+ 3 - 1
docs/config-catalog.md

@@ -1052,10 +1052,12 @@ export interface Config {
   installKillGraceMs: number
   /** How many trailing bytes of an install run's output an install failure reports. */
   installLogTailBytes: number
+  /** Bound on one registry lookup an inspection runs, in milliseconds. */
+  inspectTimeoutMs: number
 }
 ```
 
-Source: [`packages/host/plugin-manager/src/index.ts:42`](../packages/host/plugin-manager/src/index.ts)
+Source: [`packages/host/plugin-manager/src/index.ts:43`](../packages/host/plugin-manager/src/index.ts)
 
 <a id="deepseek-aidsh-host-webserver"></a>
 

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

@@ -1054,10 +1054,12 @@ export interface Config {
   installKillGraceMs: number
   /** How many trailing bytes of an install run's output an install failure reports. */
   installLogTailBytes: number
+  /** Bound on one registry lookup an inspection runs, in milliseconds. */
+  inspectTimeoutMs: number
 }
 ```
 
-来源:[`packages/host/plugin-manager/src/index.ts:39`](../packages/host/plugin-manager/src/index.ts)
+来源:[`packages/host/plugin-manager/src/index.ts:43`](../packages/host/plugin-manager/src/index.ts)
 
 
 <a id="deepseek-aidsh-host-webserver"></a>

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: ef8a12d810bfb88d8785e0d1fbb559c5aa1f84e4
-event-producer-consumer.zh.md: 29ba51480ac58fe7688fc24d569c6aad7c69ef07
+event-producer-consumer.md: 16d6ab3a9eb6d44583d922a59fd17c08ac77808d
+event-producer-consumer.zh.md: a0f4593d4314c4d679cd019d07456364c8fbf9e0

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

@@ -48,9 +48,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` |
-| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:232`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:238`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugins/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:244`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:288`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:294`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:300`](../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 - 3
docs/event-producer-consumer.zh.md

@@ -50,9 +50,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` |
-| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:232`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:238`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugins/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:244`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:288`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:294`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:300`](../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: 2af374732a72125e00ba30fa4e08a63bbd41c037
-module-graph.zh.md: 34abc115529984f5b869f8306b02cdbec48ec035
+module-graph.md: 2246ab9eeaa0868e55c55a84a25dc8b2d83ba3b4
+module-graph.zh.md: 3d7a842afaf869804ff31dccd4d45f7dbe351047

+ 4 - 1
docs/module-graph.md

@@ -493,7 +493,10 @@ flowchart TD
   pkg_session_log_export --> pkg_session
   pkg_session_log_export --> pkg_session_persistence
   pkg_plugin_manager --> pkg_app_boot
+  pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_package_manifest
+  pkg_plugin_manager --> pkg_subprocess
+  pkg_plugin_manager --> pkg_subprocess_local
   pkg_plugin_manager --> pkg_util_values
   pkg_ptc_runtime --> pkg_sandbox
   pkg_sandbox_local --> pkg_llm
@@ -1431,7 +1434,7 @@ flowchart TD
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`app-boot`](../packages/boot/app-boot), [`package-manifest`](../packages/util/package-manifest), [`util-values`](../packages/util/values) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`package-manifest`](../packages/util/package-manifest), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) |
 | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |

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

@@ -495,7 +495,10 @@ flowchart TD
   pkg_session_log_export --> pkg_session
   pkg_session_log_export --> pkg_session_persistence
   pkg_plugin_manager --> pkg_app_boot
+  pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_package_manifest
+  pkg_plugin_manager --> pkg_subprocess
+  pkg_plugin_manager --> pkg_subprocess_local
   pkg_plugin_manager --> pkg_util_values
   pkg_ptc_runtime --> pkg_sandbox
   pkg_sandbox_local --> pkg_llm
@@ -1433,7 +1436,7 @@ flowchart TD
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`app-boot`](../packages/boot/app-boot), [`package-manifest`](../packages/util/package-manifest), [`util-values`](../packages/util/values) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`package-manifest`](../packages/util/package-manifest), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) |
 | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |

+ 2 - 2
docs/subsystems/core.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/core.md
-core.md: 6f3bd0ec50b7dbc7704503a0371428efcd33a8e7
-core.zh.md: 2d95002999a58180cc830744d566e1b9866fe06f
+core.md: 644eb15998be33e204d17aadacb5abfbbe56c65a
+core.zh.md: afddfd64f21740d662c5f204a90bdf42335ced88

+ 9 - 0
docs/subsystems/core.md

@@ -916,6 +916,15 @@ The row injects only the Loader; the profile runtime and the agent registry are
  */
 @Remote('list') async list(): Promise<PluginPackageView[]>
 
+/**
+ * Read what a spec names before installing it: its form, the package's name, version,
+ * description, and title where they are known ahead of the install, and whether it declares a bundle.
+ * @param spec - what would be installed, in pnpm's own vocabulary.
+ * @param signal - cancels the registry lookup.
+ * @returns the inspection.
+ */
+@Remote('inspect') async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection>
+
 /**
  * Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.
  * @param spec - what to install, in pnpm's own vocabulary.

+ 9 - 0
docs/subsystems/core.zh.md

@@ -926,6 +926,15 @@ The row injects only the Loader; the profile runtime and the agent registry are
  */
 @Remote('list') async list(): Promise<PluginPackageView[]>
 
+/**
+ * Read what a spec names before installing it: its form, the package's name, version,
+ * description, and title where they are known ahead of the install, and whether it declares a bundle.
+ * @param spec - what would be installed, in pnpm's own vocabulary.
+ * @param signal - cancels the registry lookup.
+ * @returns the inspection.
+ */
+@Remote('inspect') async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection>
+
 /**
  * Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.
  * @param spec - what to install, in pnpm's own vocabulary.

+ 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: 208c315b28bca2a987c5f50f5a6e7b40e70cd4a1
-README.zh.md: 8d4734513ec6a6c1090441bd13688a072cdda6b4
+README.md: 829ac6cd3391e9d7048b92cb5207ab33e854d6aa
+README.zh.md: e18a9590d4cf39311f1cfbc9fc78e8fb496f79d9

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

@@ -39,7 +39,7 @@ declare const installAnchor: string
 const installer = new PluginInstaller({
   profileDir, profileName: 'web', installAnchor,
   loadProfile: () => loadProfile('dsh', 'web', installAnchor, undefined, { userLayer: false }),
-  config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installKillGraceMs: 5_000, installLogTailBytes: 16_384 },
+  config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installKillGraceMs: 5_000, installLogTailBytes: 16_384, inspectTimeoutMs: 20_000 },
   installLog: (chunk) => process.stdout.write(chunk.text),
   color: process.stdout.isTTY,
 })
@@ -47,7 +47,9 @@ const outcome = await installer.add('@acme/dsh-sql-tool')
 console.log(outcome.installed, outcome.removed)
 ```
 
-`add` runs pnpm in the profile, reconciles `dependencies`, and statically checks new bundle declarations against current row ownership. Conflicting bundles are removed with a reason; undeclared and unreadable packages remain installed. New bundles stay disabled unless the caller enables them. Failed installation restores the saved manifest and `pnpm-lock.yaml`; downloaded and unpacked files may remain in `node_modules` or the pnpm store. Failures report `plugins/install-failed` with the log tail.
+`add` runs pnpm in the profile, reconciles `dependencies`, and statically checks new bundle declarations against current row ownership. Conflicting bundles are removed with a reason; undeclared and unreadable packages remain installed. New bundles stay disabled unless the caller enables them. Failed installation restores the saved manifest and `pnpm-lock.yaml`; downloaded and unpacked files may remain in `node_modules` or the pnpm store. Failures report `plugins/install-failed` with the log tail and a `kind` read off how the run ended and what pnpm printed: `pnpm-missing`, `timeout`, `not-found`, `no-matching-version`, `network`, `disk-full`, `permission`, `build-blocked`, `integrity`, or `unknown`.
+
+`inspect(spec, signal?)` reads what a spec names before anything installs: `parseInstallSpec` sorts it into a registry name, an absolute path, a git address, or a tarball, refusing a relative path or a name the registry would not accept; a registry name is then asked of the registry through `pnpm view`, run in the profile directory so the same registry and proxy settings apply as to the install, and a directory has its `package.json` read. The answer carries the name, version, description, `dsh.title`, and whether the package declares a bundle; a git or tarball spec answers only its kind. The lookup ends at `inspectTimeoutMs` or when the caller's signal aborts. A refusal is `plugins/inspect-rejected` with a `problem`: `invalid-spec`, `already-installed` (a dependency or a template bundle), `not-found`, `not-a-package`, `network`, or `unknown`.
 
 ### Managing the booted profile
 
@@ -80,7 +82,7 @@ The manager runs one mutation at a time — a second call while one runs fails w
 
 ### Failures
 
-Every refusal or failure is a `PluginOperationError` with a stable `code` and `details` typed by it: `plugins/unavailable` (no profile runtime), `plugins/not-installed`, `plugins/not-enableable`, `plugins/enable-failed`, `plugins/install-failed`, `plugins/install-cancelled`, `plugins/busy`, `plugins/agents-running`, and `plugins/bad-request` for a request that names nothing the profile has. `pluginOperationFailureOf` narrows a caught value to the code-discriminated union.
+Every refusal or failure is a `PluginOperationError` with a stable `code` and `details` typed by it: `plugins/unavailable` (no profile runtime), `plugins/not-installed`, `plugins/not-enableable`, `plugins/enable-failed`, `plugins/install-failed`, `plugins/install-cancelled`, `plugins/inspect-rejected`, `plugins/busy`, `plugins/agents-running`, and `plugins/bad-request` for a request that names nothing the profile has. `pluginOperationFailureOf` narrows a caught value to the code-discriminated union.
 
 -----
 

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

@@ -39,7 +39,7 @@ declare const installAnchor: string
 const installer = new PluginInstaller({
   profileDir, profileName: 'web', installAnchor,
   loadProfile: () => loadProfile('dsh', 'web', installAnchor, undefined, { userLayer: false }),
-  config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installKillGraceMs: 5_000, installLogTailBytes: 16_384 },
+  config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installKillGraceMs: 5_000, installLogTailBytes: 16_384, inspectTimeoutMs: 20_000 },
   installLog: (chunk) => process.stdout.write(chunk.text),
   color: process.stdout.isTTY,
 })
@@ -47,7 +47,9 @@ const outcome = await installer.add('@acme/dsh-sql-tool')
 console.log(outcome.installed, outcome.removed)
 ```
 
-`add` 在 profile 中执行 pnpm,核对 `dependencies`,并按当前行归属静态检查新组合包声明。冲突组合包会被移除并给出原因;未声明或声明不可读的包保持已安装。新组合包保持禁用,除非调用方启用。安装失败会恢复运行前的 manifest 与 `pnpm-lock.yaml`,并通过 `plugins/install-failed` 报告日志尾部。
+`add` 在 profile 中执行 pnpm,核对 `dependencies`,并按当前行归属静态检查新组合包声明。冲突组合包会被移除并给出原因;未声明或声明不可读的包保持已安装。新组合包保持禁用,除非调用方启用。安装失败会恢复运行前的 manifest 与 `pnpm-lock.yaml`,并通过 `plugins/install-failed` 报告日志尾部,附带按运行结束方式与 pnpm 输出判定的 `kind`:`pnpm-missing`、`timeout`、`not-found`、`no-matching-version`、`network`、`disk-full`、`permission`、`build-blocked`、`integrity` 或 `unknown`。
+
+`inspect(spec, signal?)` 在任何东西安装之前读出 spec 指向什么:`parseInstallSpec` 把它归为注册表包名、绝对路径、git 地址或 tarball,拒绝相对路径和注册表不会接受的包名;注册表包名随后通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的注册表与代理设置;目录则读取其 `package.json`。答复携带名称、版本、描述、`dsh.title` 以及该包是否声明组合包;git 或 tarball spec 只答复自己的类型。查询在 `inspectTimeoutMs` 到期或调用方的 signal 中止时结束。拒绝是带 `problem` 的 `plugins/inspect-rejected`:`invalid-spec`、`already-installed`(已是依赖或模板组合包)、`not-found`、`not-a-package`、`network` 或 `unknown`。
 
 ### 管理已启动的 profile
 
@@ -80,7 +82,7 @@ console.log(await manager.list())
 
 ### 失败
 
-每次拒绝或失败都是一个 `PluginOperationError`,带稳定的 `code` 与按码定型的 `details`:`plugins/unavailable`(没有 profile runtime)、`plugins/not-installed`、`plugins/not-enableable`、`plugins/enable-failed`、`plugins/install-failed`、`plugins/install-cancelled`、`plugins/busy`、`plugins/agents-running`,以及请求点名了 profile 没有的东西时的 `plugins/bad-request`。`pluginOperationFailureOf` 把捕获到的值收窄为按码区分的联合。
+每次拒绝或失败都是一个 `PluginOperationError`,带稳定的 `code` 与按码定型的 `details`:`plugins/unavailable`(没有 profile runtime)、`plugins/not-installed`、`plugins/not-enableable`、`plugins/enable-failed`、`plugins/install-failed`、`plugins/install-cancelled`、`plugins/inspect-rejected`、`plugins/busy`、`plugins/agents-running`,以及请求点名了 profile 没有的东西时的 `plugins/bad-request`。`pluginOperationFailureOf` 把捕获到的值收窄为按码区分的联合。
 
 -----
 

+ 2 - 0
packages/boot/plugin-manager/src/helpers.ts

@@ -51,6 +51,8 @@ export interface PluginToolingConfig {
   readonly installKillGraceMs: number
   /** How many trailing bytes of an install run's output an install failure reports. */
   readonly installLogTailBytes: number
+  /** Bound on one registry lookup an inspection runs, in milliseconds. */
+  readonly inspectTimeoutMs: number
 }
 
 /**

+ 2 - 0
packages/boot/plugin-manager/src/index.ts

@@ -19,5 +19,7 @@
 export * from './errors.ts'
 export type * from './types.ts'
 export type { PluginToolingConfig, SpawnLike } from './helpers.ts'
+export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts'
+export { parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
 export { PluginInstaller, type PluginInstallerOptions, type PluginInstallOutcome } from './installer.ts'
 export { PluginManager, type PluginManagerOptions } from './manager.ts'

+ 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'
+}

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

@@ -0,0 +1,64 @@
+/**
+ * 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'
+import { PluginOperationError } from './errors.ts'
+import { NAME } from './helpers.ts'
+
+/** 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
+
+function invalid(spec: string, reason: string): PluginOperationError<'plugins/inspect-rejected'> {
+  return new PluginOperationError('plugins/inspect-rejected', `${NAME}: ${reason}: ${spec}`, { spec, problem: 'invalid-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 {PluginOperationError} `plugins/inspect-rejected` with `invalid-spec` 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 }
+}

+ 170 - 5
packages/boot/plugin-manager/src/installer.ts

@@ -7,7 +7,7 @@
 
 import { spawn as spawnChild } from 'node:child_process'
 import { randomUUID } from 'node:crypto'
-import { readFileSync, writeFileSync, rmSync } from 'node:fs'
+import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
 import type { Readable } from 'node:stream'
 import { finished } from 'node:stream/promises'
 import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/spawn'
@@ -25,8 +25,13 @@ import {
   type ProfileManifest,
 } from '@deepseek-ai/dsh-app-boot'
 import { PluginOperationError } from './errors.ts'
-import { dependenciesOf, messageOf, NAME, type PluginToolingConfig, type SpawnLike } from './helpers.ts'
-import type { PluginInstallLogChunk, PluginInstallRejection, PluginInstallResult, PluginInstallRequestId } from './types.ts'
+import { bundlesOf, dependenciesOf, messageOf, NAME, optional, type PluginToolingConfig, type SpawnLike } from './helpers.ts'
+import { classifyInstallFailure } from './install-failure.ts'
+import { parseInstallSpec } from './install-spec.ts'
+import type {
+  PluginInspectProblem, PluginInstallFailureKind, PluginInstallLogChunk, PluginInstallRejection, PluginInstallRequestId,
+  PluginInstallResult, PluginSpecInspection,
+} from './types.ts'
 
 /** The manager owns cancellation until prepared transfers control to runtime application. */
 export interface PluginInstallControl {
@@ -85,6 +90,40 @@ export interface PluginInstallerOptions {
 /** The installed package's manifest slice the view reads. */
 export type InstalledManifest = ProfileManifest & { description?: string; dsh?: ProfileManifest['dsh'] & { title?: string } }
 
+/** The fields an inspection reads off a manifest, on disk or as the registry reports it. */
+interface InspectedManifest {
+  readonly name?: unknown
+  readonly version?: unknown
+  readonly description?: unknown
+  readonly dsh?: { readonly title?: unknown; readonly bundle?: unknown } | null
+}
+
+/** How one quiet pnpm run ended. */
+interface QuietRun {
+  readonly exitCode: number | null
+  readonly stdout: string
+  readonly stderr: string
+  readonly kind: PluginInstallFailureKind | null
+}
+
+function stringField(value: unknown): string | undefined {
+  return typeof value === 'string' && value !== '' ? value : undefined
+}
+
+/** The manifest facts an inspection reports, with only the fields the manifest carries. */
+function inspectionOf(kind: PluginSpecInspection['kind'], manifest: InspectedManifest): PluginSpecInspection {
+  const dsh = manifest.dsh ?? undefined
+  const bundle = dsh?.bundle
+  return {
+    kind,
+    ...optional('name', stringField(manifest.name)),
+    ...optional('version', stringField(manifest.version)),
+    ...optional('description', stringField(manifest.description)),
+    ...optional('title', stringField(dsh?.title)),
+    bundle: bundle !== undefined && bundle !== null,
+  }
+}
+
 /**
  * Installs and removes packages in one profile with pnpm, and reads their declarations.
  *
@@ -222,6 +261,129 @@ export class PluginInstaller {
     }
   }
 
+  /**
+   * Read what a spec names before installing it: its form, and for a registry
+   * name or a directory the package's name, version, description, title, and
+   * whether it declares a bundle. A registry name is asked of the registry
+   * through `pnpm view`, run in the profile directory so the same registry,
+   * proxy, and auth settings apply as to the install itself; a git or tarball
+   * spec is only checked for form, and a tarball on disk for existence.
+   * @param spec - what would be installed, in pnpm's own vocabulary.
+   * @param signal - cancels the registry lookup.
+   * @returns the inspection.
+   * @throws {PluginOperationError} `plugins/inspect-rejected` with the problem: an
+   * `invalid-spec`, a package `already-installed` (a dependency or a template bundle),
+   * a registry name `not-found` (no such package, or no version in the range), a path that is
+   * `not-a-package`, a `network` failure reaching the registry, or an `unknown` lookup failure.
+   */
+  async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection> {
+    const parsed = parseInstallSpec(spec)
+    const manifest = readProfileManifest(NAME, this.options.profileDir)
+    const known = new Set([...bundlesOf(manifest), ...Object.keys(dependenciesOf(manifest))])
+    const rejected = (problem: PluginInspectProblem, reason: string): PluginOperationError<'plugins/inspect-rejected'> =>
+      new PluginOperationError('plugins/inspect-rejected', `${NAME}: ${reason}: ${parsed.spec}`, { spec: parsed.spec, problem, reason })
+    const assertNew = (name: string): void => {
+      if (known.has(name)) throw rejected('already-installed', `${name} is already installed`)
+    }
+    switch (parsed.kind) {
+      case 'path': {
+        if (!existsSync(parsed.path)) throw rejected('not-a-package', 'the path does not exist')
+        let read: InspectedManifest
+        try {
+          read = JSON.parse(readFileSync(join(parsed.path, 'package.json'), 'utf8')) as InspectedManifest
+        } catch (error) {
+          throw rejected('not-a-package', `no readable package.json at the path: ${messageOf(error)}`)
+        }
+        const inspection = inspectionOf('path', read)
+        if (inspection.name === undefined) throw rejected('not-a-package', 'the package.json names no package')
+        assertNew(inspection.name)
+        return inspection
+      }
+      case 'tarball': {
+        if (parsed.path !== undefined && !existsSync(parsed.path)) throw rejected('not-a-package', 'the tarball does not exist')
+        return { kind: 'tarball', bundle: null }
+      }
+      case 'git':
+        return { kind: 'git', bundle: null }
+      case 'registry': {
+        assertNew(parsed.name)
+        const run = await this.runQuietly(['view', parsed.spec, 'name', 'version', 'description', 'dsh', '--json'], signal)
+        if (run.kind !== null) {
+          const log = run.stderr.trim() || run.stdout.trim()
+          switch (run.kind) {
+            case 'not-found':
+            case 'no-matching-version':
+              throw rejected('not-found', log)
+            case 'network':
+              throw rejected('network', log)
+            default:
+              throw rejected('unknown', log || `pnpm view exited with ${String(run.exitCode)}`)
+          }
+        }
+        let answer: unknown
+        try {
+          answer = JSON.parse(run.stdout)
+        } catch (error) {
+          throw rejected('unknown', `unreadable pnpm view output: ${messageOf(error)}`)
+        }
+        // A range that several versions satisfy answers one object per version, newest last.
+        const latest: unknown = Array.isArray(answer) ? answer.at(-1) : answer
+        if (typeof latest !== 'object' || latest === null) throw rejected('unknown', 'pnpm view answered no package')
+        const inspection = inspectionOf('registry', latest)
+        return inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection
+      }
+      /* v8 ignore next -- closed-union exhaustiveness guard */
+      default: return parsed satisfies never
+    }
+  }
+
+  /**
+   * Run one pnpm command whose output is answered, not streamed: a registry
+   * lookup, bounded by `inspectTimeoutMs` and the caller's signal.
+   */
+  private async runQuietly(args: readonly string[], signal: AbortSignal | undefined): Promise<QuietRun> {
+    const { color, config, profileDir } = this.options
+    const deadline = new AbortController()
+    const timer = setTimeout(() => { deadline.abort() }, config.inspectTimeoutMs)
+    let stdout = ''
+    let stderr = ''
+    let exitCode: number | null = null
+    let cause: unknown
+    try {
+      const child = this.spawn({
+        argv: [config.pnpmCommand, ...args], cwd: profileDir, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
+        graceMs: config.installKillGraceMs, signal: signal === undefined ? deadline.signal : AbortSignal.any([deadline.signal, signal]),
+        env: { ...process.env, FORCE_COLOR: color ? '1' : '0' },
+      })
+      const out = child.stdout as Readable
+      const err = child.stderr as Readable
+      out.setEncoding('utf8')
+      err.setEncoding('utf8')
+      out.on('data', (text: string) => { stdout += text })
+      err.on('data', (text: string) => { stderr += text })
+      try {
+        const [outcome] = await Promise.all([child.done, finished(out), finished(err)])
+        exitCode = outcome.exitCode
+      } finally {
+        await child.waitForExit()
+      }
+      if (deadline.signal.aborted) cause = new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(config.inspectTimeoutMs)}ms`)
+    } catch (error) {
+      cause = error
+    } finally {
+      clearTimeout(timer)
+    }
+    const plain = stderr.replace(ANSI_SEQUENCE, '')
+    const log = cause === undefined ? plain : `${plain}${messageOf(cause)}\n`
+    const failed = exitCode !== 0 || cause !== undefined
+    return {
+      exitCode,
+      stdout: stdout.replace(ANSI_SEQUENCE, ''),
+      stderr: log,
+      kind: failed ? classifyInstallFailure({ log, cause, timedOut: deadline.signal.aborted }) : null,
+    }
+  }
+
   /**
    * Run `pnpm remove` and reconcile the layer list.
    * @param packageName - the dependency to remove.
@@ -315,16 +477,19 @@ export class PluginInstaller {
       record('stderr', `${message}\n`)
       this.options.installLog({ ...request, jobId, argv, cwd: profileDir, spec, stream: 'stderr', text: '', exitCode })
       if (error instanceof PluginOperationError && error.code === 'plugins/install-cancelled') throw error
-      throw new PluginOperationError('plugins/install-failed', `${NAME}: ${message}`, { spec, exitCode, log: tail.join('') }, { cause: error })
+      const log = tail.join('')
+      const kind = classifyInstallFailure({ log, cause: error, timedOut: deadline.signal.aborted })
+      throw new PluginOperationError('plugins/install-failed', `${NAME}: ${message}`, { spec, exitCode, log, kind }, { cause: error })
     } finally {
       clearTimeout(timer)
     }
     this.options.installLog({ ...request, jobId, argv, cwd: profileDir, spec, stream: 'stdout', text: '', exitCode })
     if (exitCode !== 0) {
+      const log = tail.join('')
       throw new PluginOperationError(
         'plugins/install-failed',
         `${NAME}: pnpm ${args.join(' ')} exited with ${String(exitCode)} in ${profileDir}`,
-        { spec, exitCode, log: tail.join('') },
+        { spec, exitCode, log, kind: classifyInstallFailure({ log }) },
       )
     }
     return jobId

+ 14 - 0
packages/boot/plugin-manager/src/manager.ts

@@ -34,6 +34,7 @@ import type {
   PluginInstallCancellation,
   PluginPackageView,
   PluginRowIssue,
+  PluginSpecInspection,
   PluginRowReference,
   PluginServiceDependent,
 } from './types.ts'
@@ -217,6 +218,19 @@ export class PluginManager {
     })
   }
 
+  /**
+   * Read what a spec names before installing it, without changing anything:
+   * a lookup, so it runs beside a mutation rather than waiting for one.
+   * @param spec - what would be installed, in pnpm's own vocabulary.
+   * @param signal - cancels the registry lookup.
+   * @returns the inspection.
+   * @throws {PluginOperationError} `plugins/unavailable` without a profile runtime, or
+   * `plugins/inspect-rejected` with the problem the installer found.
+   */
+  async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection> {
+    return this.installer(this.runtime()).inspect(spec, signal)
+  }
+
   /**
    * Stop the matching installation and wait for its file recovery and mutation lock release.
    * @param requestId - the installation the caller started; never selects another active operation.

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

@@ -167,6 +167,60 @@ export interface PluginDependents {
   readonly references: readonly PluginRowReference[]
 }
 
+/** The form one install spec takes, in pnpm's vocabulary. */
+export type InstallSpecKind = 'registry' | 'path' | 'git' | 'tarball'
+
+/**
+ * What a spec names before anything installs. `name` and `bundle` are known
+ * for a registry package the registry answered for and for a directory whose
+ * manifest was read; a git or tarball spec keeps them unknown until pnpm has
+ * fetched it.
+ */
+export interface PluginSpecInspection {
+  readonly kind: InstallSpecKind
+  readonly name?: string
+  readonly version?: string
+  readonly description?: string
+  /** The manifest's `dsh.title`. */
+  readonly title?: string
+  /** Whether the package declares a bundle patch; null while that is unknown. */
+  readonly bundle: boolean | null
+}
+
+/** Why an inspection refused a spec. */
+export type PluginInspectProblem =
+  | 'invalid-spec'
+  | 'already-installed'
+  | 'not-found'
+  | 'not-a-package'
+  | 'network'
+  | 'unknown'
+
+/**
+ * What an install failure was, read off pnpm's exit and output:
+ *
+ * - `pnpm-missing`: pnpm could not be spawned;
+ * - `timeout`: the run outlived its bound;
+ * - `not-found`: the registry has no such package;
+ * - `no-matching-version`: the package exists, the requested range matches nothing;
+ * - `network`: the registry or a git host could not be reached;
+ * - `disk-full`, `permission`: the profile directory could not be written;
+ * - `build-blocked`: pnpm refused a dependency's build script until it is allowed;
+ * - `integrity`: a downloaded tarball failed its check;
+ * - `unknown`: none of the above.
+ */
+export type PluginInstallFailureKind =
+  | 'pnpm-missing'
+  | 'timeout'
+  | 'not-found'
+  | 'no-matching-version'
+  | 'network'
+  | 'disk-full'
+  | 'permission'
+  | 'build-blocked'
+  | 'integrity'
+  | 'unknown'
+
 /** Why the manager changed something, for a listener deciding what to refresh. */
 export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable' | 'retry' | 'row' | 'runtime'
 
@@ -203,8 +257,10 @@ export interface PluginOperationDetailsMap {
   'plugins/not-enableable': { readonly packageName: string; readonly reason: string }
   /** Preparation or the root Include rejected enablement; the layer selection was reverted. */
   'plugins/enable-failed': { readonly packageName: string; readonly reason: string }
-  /** pnpm exited non-zero, could not be spawned, or timed out. */
-  'plugins/install-failed': { readonly spec: string; readonly exitCode: number | null; readonly log: string }
+  /** pnpm exited non-zero, could not be spawned, or timed out; `kind` classifies the failure. */
+  'plugins/install-failed': { readonly spec: string; readonly exitCode: number | null; readonly log: string; readonly kind: PluginInstallFailureKind }
+  /** The spec cannot be installed as given; `problem` says why and `reason` details it. */
+  'plugins/inspect-rejected': { readonly spec: string; readonly problem: PluginInspectProblem; readonly reason: string }
   /** The user cancelled, and the installer restored its manifest and lockfile. */
   'plugins/install-cancelled': { readonly requestId: PluginInstallRequestId }
   /** Another mutation is still running; the manager runs one at a time and refuses rather than queues. */

+ 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, parseInstallSpec, PluginOperationError } 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(PluginOperationError)
+        const failure = error as PluginOperationError<'plugins/inspect-rejected'>
+        expect(failure.code).toBe('plugins/inspect-rejected')
+        expect(failure.details).toMatchObject({ spec: spec.trim(), problem: 'invalid-spec' })
+        return failure.details.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')
+  })
+})

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

@@ -31,7 +31,7 @@ const NAME = 'dsh-test'
 
 /** A complete tooling config: the host's schema fills these defaults at load, the type does not. */
 function managerConfig(overrides: Partial<PluginToolingConfig> = {}): PluginToolingConfig {
-  return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installKillGraceMs: 50, installLogTailBytes: 16_384, ...overrides }
+  return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installKillGraceMs: 50, installLogTailBytes: 16_384, inspectTimeoutMs: 1_000, ...overrides }
 }
 
 /** Test seams: the child spawner and the static metadata reader. */
@@ -771,6 +771,10 @@ describe('PluginManager', () => {
       expect(log.every(chunk => chunk.requestId === undefined)).toBe(true)
       await installer.remove('ext-plain')
       expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-plain')
+      // A caller's signal bounds an inspection beside the deadline: one already aborted never reads the registry.
+      await expect(installer.inspect('ext-view', AbortSignal.abort())).rejects.toMatchObject({
+        code: 'plugins/inspect-rejected', details: { spec: 'ext-view', problem: 'unknown' },
+      })
     })
 
     it('does not start package removal after removing its user row disposes the manager', async () => {
@@ -946,6 +950,127 @@ describe('PluginManager', () => {
       }, { installLogTailBytes: 256 })
       await expect(manager.add('x')).rejects.toMatchObject({ details: { log: 'b'.repeat(300) } })
     })
+
+    it('classifies a failure by how the run ended and what pnpm printed', async () => {
+      const outputs: Record<string, string> = {
+        'disk': 'ERR_PNPM_ENOSPC  ENOSPC: no space left on device\n',
+        'blocked': 'ERR_PNPM_IGNORED_BUILDS  Ignored build scripts: node-pty\n',
+        'gone': 'ERR_PNPM_META_FETCH_FAIL  GET https://registry/x: getaddrinfo ENOTFOUND registry\n',
+        'odd': 'something else\n',
+      }
+      const staged = await stageHome()
+      const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, args => ({ code: 1, stderr: outputs[args[1] ?? ''] ?? '' })) })
+      for (const [spec, kind] of [['disk', 'disk-full'], ['blocked', 'build-blocked'], ['gone', 'network'], ['odd', 'unknown']] as const) {
+        await expect(manager.add(spec)).rejects.toMatchObject({ code: 'plugins/install-failed', details: { spec, kind } })
+      }
+      const missingHome = await stageHome()
+      const missing = await bootProfile(missingHome, {
+        spawn: fakePnpm(missingHome.profileDir, () => ({ code: null, error: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })),
+      })
+      await expect(missing.manager.add('x')).rejects.toMatchObject({ details: { kind: 'pnpm-missing' } })
+      const hangingHome = await stageHome()
+      const hanging = await bootProfile(hangingHome, { spawn: fakePnpm(hangingHome.profileDir, () => ({ code: null, hang: true })) })
+      await expect(hanging.manager.add('x')).rejects.toMatchObject({ details: { kind: 'timeout' } })
+    })
+  })
+
+  describe('inspect', () => {
+    /** The registry as a fake pnpm view answers it: one JSON object per package, several for a range. */
+    type ViewAnswer = { code?: number | null; stdout?: string; stderr?: string; hang?: boolean; error?: unknown }
+    const registry = (profileDir: string, answers: Record<string, ViewAnswer>): SpawnLike =>
+      fakePnpm(profileDir, (args) => {
+        expect(args.slice(0, 1)).toEqual(['view'])
+        expect(args.slice(2)).toEqual(['name', 'version', 'description', 'dsh', '--json'])
+        const answer = answers[args[1] ?? '']
+        if (answer === undefined) return { code: 1, stderr: 'unexpected pnpm view\n' }
+        return { code: answer.code ?? 0, ...answer }
+      })
+
+    it('reads a registry name through pnpm view, newest version of a range last', async () => {
+      const staged = await stageHome()
+      const { manager } = await bootProfile(staged, {
+        spawn: registry(staged.profileDir, {
+          'dsh-x': { stdout: JSON.stringify({ name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', dsh: { title: 'Sidebar', bundle: { patch: './cordis.patch.yml' } } }) },
+          'dsh-lib@^1': { stdout: JSON.stringify([{ name: 'dsh-lib', version: '1.0.0' }, { name: 'dsh-lib', version: '1.1.0', dsh: null }]) },
+          'dsh-bare': { stdout: '\u001b[36m' + JSON.stringify({ version: '0.0.1', description: '' }) + '\u001b[39m\n' },
+        }),
+      })
+      await expect(manager.inspect('dsh-x')).resolves.toEqual({
+        kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', title: 'Sidebar', bundle: true,
+      })
+      await expect(manager.inspect('dsh-lib@^1')).resolves.toEqual({ kind: 'registry', name: 'dsh-lib', version: '1.1.0', bundle: false })
+      // An answer that names no package keeps the name the spec gave; colour escapes around the JSON are dropped.
+      await expect(manager.inspect('dsh-bare')).resolves.toEqual({ kind: 'registry', name: 'dsh-bare', version: '0.0.1', bundle: false })
+    })
+
+    it('refuses a registry name the registry cannot answer for, by what it said', async () => {
+      const staged = await stageHome()
+      const { manager } = await bootProfile(staged, {
+        spawn: registry(staged.profileDir, {
+          'nope': { code: 1, stderr: 'npm error code E404\nnpm error 404 Not Found - GET https://registry/nope\n' },
+          'old@9': { code: 1, stderr: 'ERR_PNPM_NO_MATCHING_VERSION  No matching version found for old@9\n' },
+          'far': { code: 1, stderr: 'ERR_PNPM_META_FETCH_FAIL  request failed, reason: getaddrinfo ENOTFOUND registry\n' },
+          'odd': { code: 3, stdout: 'plain text\n' },
+          'quiet': { code: 4 },
+          'garbled': { stdout: 'not json' },
+          'scalar': { stdout: '"just a string"' },
+          'slow': { hang: true },
+          'gone': { code: null, error: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) },
+        }),
+      }, { inspectTimeoutMs: 100 })
+      const problem = async (spec: string): Promise<{ problem: string; reason: string }> => {
+        const failure = await manager.inspect(spec).then(() => undefined, (error: unknown) => pluginOperationFailureOf(error))
+        if (failure?.code !== 'plugins/inspect-rejected') throw new Error(`${spec}: ${String(failure?.code)}`)
+        expect(failure.details.spec).toBe(spec)
+        return { problem: failure.details.problem, reason: failure.details.reason }
+      }
+      await expect(problem('nope')).resolves.toMatchObject({ problem: 'not-found', reason: expect.stringContaining('E404') as string })
+      await expect(problem('old@9')).resolves.toMatchObject({ problem: 'not-found', reason: expect.stringContaining('No matching version') as string })
+      await expect(problem('far')).resolves.toMatchObject({ problem: 'network' })
+      // Without stderr, stdout stands in as the reason; with neither, the exit code does.
+      await expect(problem('odd')).resolves.toEqual({ problem: 'unknown', reason: 'plain text' })
+      await expect(problem('quiet')).resolves.toEqual({ problem: 'unknown', reason: 'pnpm view exited with 4' })
+      await expect(problem('garbled')).resolves.toMatchObject({ problem: 'unknown', reason: expect.stringContaining('unreadable pnpm view output') as string })
+      await expect(problem('scalar')).resolves.toEqual({ problem: 'unknown', reason: 'pnpm view answered no package' })
+      await expect(problem('slow')).resolves.toMatchObject({ problem: 'unknown', reason: expect.stringContaining('timed out') as string })
+      await expect(problem('gone')).resolves.toMatchObject({ problem: 'unknown', reason: expect.stringContaining('ENOENT') as string })
+    })
+
+    it('reads a directory\'s manifest, and refuses what is not a package or is already installed', async () => {
+      const staged = await stageHome()
+      const dir = join(staged.home, 'dev', 'dsh-local')
+      mkdirSync(dir, { recursive: true })
+      writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.', dsh: { bundle: { patch: './p.yml' } } }))
+      const bare = join(staged.home, 'dev', 'bare')
+      mkdirSync(bare, { recursive: true })
+      writeFileSync(join(bare, 'package.json'), '{"version":"1.0.0"}')
+      const broken = join(staged.home, 'dev', 'broken')
+      mkdirSync(broken, { recursive: true })
+      writeFileSync(join(broken, 'package.json'), '{')
+      const tarball = join(staged.home, 'dev', 'pack.tgz')
+      writeFileSync(tarball, '')
+      addDependency(staged.profileDir, 'dsh-installed')
+      const { manager } = await bootProfile(staged, { spawn: registry(staged.profileDir, {}) })
+
+      await expect(manager.inspect(`file:${dir}`)).resolves.toEqual({ kind: 'path', name: 'dsh-local', version: '0.1.0', description: 'Local.', bundle: true })
+      await expect(manager.inspect(tarball)).resolves.toEqual({ kind: 'tarball', bundle: null })
+      await expect(manager.inspect('github:acme/dsh-remote')).resolves.toEqual({ kind: 'git', bundle: null })
+      await expect(manager.inspect(join(staged.home, 'dev', 'missing'))).rejects.toMatchObject({ details: { problem: 'not-a-package', reason: 'the path does not exist' } })
+      await expect(manager.inspect(join(staged.home, 'dev', 'missing.tgz'))).rejects.toMatchObject({ details: { problem: 'not-a-package', reason: 'the tarball does not exist' } })
+      await expect(manager.inspect(bare)).rejects.toMatchObject({ details: { problem: 'not-a-package', reason: 'the package.json names no package' } })
+      await expect(manager.inspect(broken)).rejects.toMatchObject({ details: { problem: 'not-a-package', reason: expect.stringContaining('no readable package.json') as string } })
+      await expect(manager.inspect('dsh-installed')).rejects.toMatchObject({ details: { problem: 'already-installed', reason: 'dsh-installed is already installed' } })
+      writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dsh-installed' }))
+      await expect(manager.inspect(dir)).rejects.toMatchObject({ details: { problem: 'already-installed' } })
+      await expect(manager.inspect('./relative')).rejects.toMatchObject({ details: { problem: 'invalid-spec' } })
+    })
+
+    it('reports plugins/unavailable without a profile runtime', async () => {
+      const ctx = new Context()
+      contexts.push(ctx)
+      await ctx.plugin(Loader)
+      await expect(managerOver(ctx).inspect('x')).rejects.toMatchObject({ code: 'plugins/unavailable' })
+    })
   })
 
   describe('enable, disable, and retry', () => {

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md
-README.md: bebcef310371aff791960c9cf2415d140655294f
-README.zh.md: 315eca1bbf5e0924ff9a7510cc48405124d08456
+README.md: 7d464296a110ff261312911b5631f45dc310aa7f
+README.zh.md: 8e264d53f80b6c9d7648887428ade320ca6c8217

+ 4 - 4
packages/client/ui-plugin-manager/README.md

@@ -29,7 +29,7 @@ Select **Plugins** in the sidebar. The page reads packages through `api-remotes`
 
 ### Installing a package
 
-**Add plugin** accepts a pnpm package spec, local path or Git URL. The dialog shows the command, streamed output, and completion result, and stays open during installation. New bundles can be enabled immediately. Undeclared packages remain installed; invalid or conflicting bundle declarations may be rejected with the Host’s reason. A successful installation does not certify that a module can activate.
+**Add plugin** takes a registry name, an absolute local path, or a Git address. **Install** first asks the Host to read what the spec names (`plugins.inspect`): a name the list already shows, a name the registry does not have, a path without a package, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's title, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundles on, closes the dialog, and scrolls the list to the first of them; closing instead leaves them installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand. Packages pnpm added that the Host removed again are listed with its reason; a dependency that is not a plugin pack is named as such. A successful installation does not certify that a module can activate.
 
 ### Switching a plugin pack
 
@@ -41,11 +41,11 @@ A row's switch on the pack's page calls `plugins.setRowDisabled` against the pro
 
 ### Packages without a bundle
 
-Non-bundle dependencies remain installed and appear under **Other installed packages**. Their detail page shows package metadata and uninstall. Modules can be loaded by writing Cordis configuration; this page does not infer module exports.
+Non-bundle dependencies remain installed and appear under **Dependencies that are not plugin packs**, folded until opened. Their detail page shows package metadata and uninstall. Modules can be loaded by writing Cordis configuration; this page does not infer module exports.
 
 -----
 
-During installation, **Cancel installation** requests Host cleanup and shows **Stopping installation** until it is confirmed. Configuration application cannot be cancelled. A cancelled dialog keeps the package spec and logs and offers retry; downloaded or unpacked files can remain. Closing the dialog is blocked while the Host owns the operation. A connection error does not confirm cancellation.
+During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Configuration application cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; downloaded or unpacked files can remain. Closing the dialog is blocked while the Host owns the operation. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again.
 
 <a id="understand-the-implementation"></a>
 ## Understand the implementation
@@ -61,7 +61,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th
 
 ### The store
 
-`PluginManagerController` owns the package snapshot, busy keys, notices, install progress and confirmations. It coalesces overlapping reads, refreshes after operations and Host changes, and ignores late results after disposal. Install output is grouped by job id.
+`PluginManagerController` owns the package snapshot, busy keys, notices, install progress and confirmations. It coalesces overlapping reads, refreshes after operations and Host changes, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `plugins.cancelInstall`, whose answer the dialog waits for. A refusal of the moment — `plugins/busy` or `plugins/agents-running` — returns the dialog to the spec instead of the failed screen. Every notice is a toast that retires on its own; only the restart banner, which names packages whose change waits for the next start, stays on the page.
 
 ### Confirmation
 

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

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 安装一个包
 
-**添加插件**接受 pnpm 包描述、本地路径或 Git URL。对话框显示命令、实时输出和完成结果,在安装期间保持打开。新组合包可以立即启用。未声明入口的包保持已安装;无效或冲突的组合包声明可能被拒绝,并显示宿主原因。安装成功不代表模块一定能够激活。
+**添加插件**接受注册表包名、本地绝对路径或 Git 地址。**安装**先让 Host 读出 spec 指向什么(`plugins.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包标题、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到其中第一个;直接关闭则让它们保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边。pnpm 加入后又被 Host 移除的包会连同原因列出;不是插件包的依赖会被如此标明。安装成功不代表模块一定能够激活。
 
 ### 切换一个插件包
 
@@ -41,11 +41,11 @@ kind: "package-reference"
 
 ### 没有组合包声明的包
 
-非组合包依赖保持已安装,列在**其他已安装包**下。详情页展示包元数据和卸载操作。模块可以通过手写 Cordis 配置加载;此页面不推断模块导出。
+非组合包依赖保持已安装,列在**非插件包依赖**下,默认折叠。详情页展示包元数据和卸载操作。模块可以通过手写 Cordis 配置加载;此页面不推断模块导出。
 
 -----
 
-安装期间可点击**取消安装**,页面显示**正在停止安装**,直到 Host 确认清理完成。配置应用阶段不可取消。取消后的对话框保留包名和日志,并提供重试;下载或解包文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功。
+安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。配置应用阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;下载或解包文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消
 
 <a id="understand-the-implementation"></a>
 ## 理解实现
@@ -61,7 +61,7 @@ kind: "package-reference"
 
 ### store
 
-`PluginManagerController` 拥有包快照、忙碌键、提示、安装进度和确认状态。它合并重叠读取,在操作或 Host 变化后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。
+`PluginManagerController` 拥有包快照、忙碌键、提示、安装进度和确认状态。它合并重叠读取,在操作或 Host 变化后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `plugins.cancelInstall` 停止,对话框等待其答复。当下的拒绝——`plugins/busy` 或 `plugins/agents-running`——让对话框回到 spec,而不是失败界面。每条提示都是会自行消失的 toast;只有点名哪些包的变更要等下次启动的重启横幅留在页面上。
 
 ### 确认
 

+ 0 - 26
packages/client/ui-plugin-manager/src/client/NoticeLine.tsx

@@ -1,26 +0,0 @@
-/** One operation notice on the plugin management page. */
-
-import type { ReactNode } from 'react'
-import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ManagerNotice } from './manager-store.ts'
-import { noticeText, type Translate } from './presentation.ts'
-import css from './PluginManagerPage.module.css'
-
-/**
- * Render the last operation's notice with its dismiss button, or nothing while there is none.
- * @param props - the notice, the translator, and the dismiss handler.
- * @returns the notice paragraph, or null.
- */
-export function NoticeLine({ notice, t, onDismiss }: {
-  readonly notice: ManagerNotice | null
-  readonly t: Translate
-  readonly onDismiss: () => void
-}): ReactNode {
-  if (notice === null) return null
-  return (
-    <p className={css.notice} data-kind={notice.kind} role={notice.kind === 'failed' ? 'alert' : 'status'}>
-      <span>{noticeText(notice, t)}</span>
-      <Button variant="ghost" size="sm" onClick={onDismiss}>{t('dismiss')}</Button>
-    </p>
-  )
-}

+ 229 - 31
packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css

@@ -79,29 +79,6 @@
   color: var(--dsw-alias-label-primary);
 }
 
-.notice {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  margin: 0;
-  padding: 8px 12px;
-  border-radius: 10px;
-  font-size: 12px;
-  line-height: 18px;
-  background: var(--dsw-alias-bg-layer-1);
-  color: var(--dsw-alias-label-secondary);
-}
-
-.notice[data-kind='failed'] {
-  color: var(--dsw-alias-state-error-primary);
-}
-
-.notice span {
-  flex: 1;
-  min-width: 0;
-  overflow-wrap: anywhere;
-}
-
 .group {
   display: flex;
   flex-direction: column;
@@ -134,6 +111,64 @@
   font-variant-numeric: tabular-nums;
 }
 
+/* The folded group's header is its toggle; the chevron turns as it opens. */
+.groupToggle {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 0;
+  border: 0;
+  background: none;
+  font: inherit;
+  color: inherit;
+  cursor: pointer;
+}
+
+.groupChevron {
+  flex: none;
+  transform: rotate(-90deg);
+  transition: transform 160ms ease;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.groupToggle[aria-expanded='true'] .groupChevron {
+  transform: none;
+}
+
+.groupIntro {
+  margin: -2px 0 0;
+  font-size: 12.5px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+/* 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);
+  }
+
+  .groupChevron {
+    transition: none;
+  }
+}
+
 .groupSub {
   margin: -4px 0 4px;
 }
@@ -438,28 +473,186 @@
   color: var(--dsw-alias-label-primary);
 }
 
-.installExample {
-  display: block;
-  margin-top: -6px;
+.installField input[aria-invalid='true'] {
+  border-color: var(--dsw-alias-state-error-primary);
+}
+
+/* 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-direction: column;
+  gap: 16px;
+  min-width: 0;
+  padding: 16px 20px 20px;
+}
+
+.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;
 }
 
-.installOption {
+.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: 8px;
+  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;
 }
 
-.progress {
+.subject {
   display: flex;
+  flex-direction: column;
   align-items: center;
-  gap: 10px;
+  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 {
@@ -480,9 +673,14 @@
 }
 
 @media (prefers-reduced-motion: reduce) {
-  .spinner {
+  .spinner,
+  .spinnerLarge {
     animation: none;
   }
+
+  .detailsChevron {
+    transition: none;
+  }
 }
 
 .result,

+ 271 - 112
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -1,23 +1,25 @@
 /**
  * Global plugin management: installed package cards, bundle component switches,
- * entry diagnostics, streamed installation output and dependency confirmations.
+ * entry diagnostics, the guided install dialog with its folded pnpm output,
+ * dependency confirmations, and the toasts an action's refusal becomes.
  * Package details expose module names and runtime failures; non-bundle packages
  * retain package information and uninstall without automatic composition actions.
  */
 
-import { isInstallPending } from './manager-store.ts'
 import { useEffect, useId, useState, type ReactNode } from 'react'
-import type { PluginInstallRejection, PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
+import type { PluginInstallFailureKind, PluginInstallRejection, PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
 import {
-  Button, IconChevronDownOutline14, IconCordisPluginOutline14, IconRefreshOutline16,
-  Input, Modal, StateDot, Switch, Tag, TerminalBlock,
+  Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconCloseOutline16,
+  IconCordisPluginOutline14, IconRefreshOutline16, 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 { rowKey, type ConfirmState, type InstallState, type PluginManagerFace } from './manager-store.ts'
-import { NoticeLine } from './NoticeLine.tsx'
-import { packageOf, refusalText, rowLabel, shortName, type Translate } from './presentation.ts'
+import {
+  isInstallPending, rowKey, type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PluginManagerFace,
+} from './manager-store.ts'
+import { noticeText, packageOf, refusalText, rowLabel, shortName, type Translate } from './presentation.ts'
 import css from './PluginManagerPage.module.css'
 
 /** Full component props assembled by the main slot renderer. */
@@ -31,11 +33,28 @@ type RowPhase = NonNullable<RowView['phase']>
 
 /** The layer a pack's components are switched in: a row override always lands in the profile's global user layer. */
 
-/** The list's two groups: packs, which switch as a whole, and everything else, which joins a composition per row. */
+/**
+ * The list's two groups: packs, which switch as a whole, and the dependencies
+ * that are not packs, which only uninstall and stay folded until opened.
+ */
 const PACKAGE_GROUPS = [
-  { key: 'bundles', titleKey: 'bundlesTitle', holds: (pkg: PluginPackageView) => pkg.kind === 'bundle' },
-  { key: 'plugins', titleKey: 'pluginsTitle', holds: (pkg: PluginPackageView) => pkg.kind !== 'bundle' },
-] as const satisfies readonly { key: string; titleKey: PluginManagerLocaleKey; holds: (pkg: PluginPackageView) => boolean }[]
+  { key: 'bundles', titleKey: 'bundlesTitle', introKey: undefined, folds: false, holds: (pkg: PluginPackageView) => pkg.kind === 'bundle' },
+  { key: 'plugins', titleKey: 'pluginsTitle', introKey: 'pluginsIntro', folds: true, holds: (pkg: PluginPackageView) => pkg.kind !== 'bundle' },
+] as const satisfies readonly {
+  key: string
+  titleKey: PluginManagerLocaleKey
+  introKey: PluginManagerLocaleKey | undefined
+  folds: boolean
+  holds: (pkg: PluginPackageView) => boolean
+}[]
+
+/** 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',
@@ -267,17 +286,23 @@ function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: {
 }
 
 /** One installed package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */
-function PackageCard({ pkg, t, busy, onOpen, onSetEnabled }: {
+function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
   readonly pkg: PluginPackageView
   readonly t: Translate
   readonly busy: boolean
+  readonly highlighted: boolean
   readonly onOpen: () => void
   readonly onSetEnabled: (enabled: boolean) => void
 }): ReactNode {
   const title = pkg.title ?? shortName(pkg.name)
   const status = cardStatus(pkg)
   return (
-    <li className={`${css.card} ${css.cardLink}`} data-plugin-package={pkg.name} data-plugin-status={pkg.status}>
+    <li
+      className={`${css.card} ${css.cardLink}`}
+      data-plugin-package={pkg.name}
+      data-plugin-status={pkg.status}
+      {...highlighted ? { 'data-plugin-highlight': '' } : {}}
+    >
       <div className={css.cardHead}>
         <div className={css.cardMain}>
           <div className={css.titleRow}>
@@ -420,107 +445,204 @@ function terminalLabels(t: Translate): TerminalBlockLabels {
   }
 }
 
+/** 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',
+  'network': 'installProblemNetwork',
+  'unknown': 'installProblemUnknown',
+} satisfies Record<InstallInputError['problem'], PluginManagerLocaleKey>
+
+/** 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 failure line: the Host's refusal in its words, except a pnpm failure,
- * which the terminal above already shows — unless none of its output reached
- * the dialog, in which case the Host's captured tail stands in.
+ * The failed screen's one line: a pnpm failure by its kind, any other
+ * refusal in the Host's words; the run's output stays behind the details.
  */
-function failureText(install: InstallState, t: Translate): string {
-  if (install.failure === null) return t('installFailed')
-  if (install.failure.code === 'plugins/install-failed' && install.runs.length === 0) {
-    return t('installFailedTail', { reason: install.failure.reason })
-  }
-  return refusalText(install.failure, t)
+function failureText(failure: InstallState['failure'], t: Translate): string {
+  if (failure === null) return t('installFailureGeneric')
+  if (failure.code === 'plugins/install-failed') return t(FAILURE_KIND_KEYS[failure.kind ?? 'unknown'])
+  return refusalText(failure, t)
+}
+
+/** The package the install is about: its title, 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.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, the enable choice, the run's progress, and a terminal per pnpm run. */
-function InstallDialog({ install, t, onClose, onEditSpec, onToggleEnable, onRun, onCancel }: {
+/** The install dialog: the spec and its check, then the installing, installed, and failed screens over the same subject card. */
+function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onToggleDetails, onEnableNow }: {
   readonly install: InstallState
   readonly t: Translate
   readonly onClose: () => void
   readonly onEditSpec: (text: string) => void
-  readonly onToggleEnable: () => void
   readonly onRun: () => void
   readonly onCancel: () => void
+  readonly onToggleDetails: () => void
+  readonly onEnableNow: () => void
 }): ReactNode {
-  const running = isInstallPending(install.phase)
-  const exampleId = useId()
+  const errorId = useId()
+  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>}
+        </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?.code === 'client/cancel-unconfirmed' ? install.failure.reason : undefined
   const firstRun = install.runs[0]
-  const outcomes: string[] = install.phase !== 'done'
-    ? []
-    : install.installed.length === 0 && install.removed.length === 0
-      ? [t('installDoneNothing')]
-      : install.installed.map(name => t(
-        install.enabled.includes(name)
-          ? 'installDoneEnabled'
-          : install.installedOnly.includes(name) ? 'installDoneBundle' : install.plain.includes(name) ? 'installDonePlugin' : 'installDoneOther',
-        { name },
-      ))
   return (
-    <Modal
-      open={install.open}
-      onClose={onClose}
-      title={t('installTitle')}
-      closeLabel={t('close')}
-      description={t('installDescription')}
-      className={css.installDialog as string}
-      footer={install.phase === 'done'
-        ? <Button variant="primary" onClick={onClose}>{t('installClose')}</Button>
-        : running
-          ? <Button variant="outline" disabled={install.phase !== 'running'} onClick={onCancel}>{t(install.phase === 'cancelling' ? 'installCancelling' : 'installCancel')}</Button>
-          : (
-            <>
-              <Button variant="outline" disabled={running} onClick={onClose}>{t(install.phase === 'idle' ? 'cancel' : 'installClose')}</Button>
-              <Button variant="primary" disabled={install.spec.trim() === ''} onClick={onRun}>
-                {t(install.phase === 'failed' || install.phase === 'cancelled' ? 'installRetry' : 'installRun')}
-              </Button>
-            </>
-          )}
-    >
-      <div className={css.installBody}>
-        <label className={css.installField}>
-          <span>{t('installSpecLabel')}</span>
-          <input
-            type="text"
-            value={install.spec}
-            placeholder={t('installSpecPlaceholder')}
-            aria-describedby={exampleId}
-            disabled={running}
-            onChange={(event) => { onEditSpec(event.currentTarget.value) }}
-          />
-        </label>
-        <span id={exampleId} className={css.installExample}>{t('installExample')}</span>
-        <label className={css.installOption}>
-          <input type="checkbox" checked={install.enable} disabled={running} onChange={onToggleEnable} />
-          <span>{t('installEnable')}</span>
-        </label>
-        {running
-          ? <p className={css.progress} role="status"><span className={css.spinner} aria-hidden="true" />{install.phase === 'starting' ? t('installStarting') : install.phase === 'cancelling' ? t('installCancelling') : install.phase === 'applying' ? t('installApplying') : t('installRunning', { spec: install.spec.trim() })}</p>
+    <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.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} />}
+        {phase === 'done' && install.installed.length === 0 && install.removed.length === 0
+          ? <p className={css.result} role="status">{t('installDoneNothing')}</p>
+          : null}
+        {phase === 'done'
+          ? install.plain.map(name => <p key={name} className={css.resultWarn} role="status">{t('installDoneNotBundle', { name })}</p>)
           : null}
-        {outcomes.map(line => <p key={line} className={css.result} role="status">{line}</p>)}
-        {install.phase === 'done'
+        {phase === 'done'
           ? install.removed.map(entry => <p key={entry.name} className={css.resultWarn} role="status">{removedText(entry, t)}</p>)
           : null}
-        {install.phase === 'cancelled' ? <p className={css.result} role="status">{t('installCancelled')}</p> : null}
-        {install.failure?.code === 'client/cancel-unconfirmed' ? <p className={css.reason} role="alert">{t('installCancelUnconfirmed', { reason: install.failure.reason })}</p> : null}
-        {install.phase === 'failed'
-          ? <p className={css.reason} role="alert">{failureText(install, t)}</p>
+        <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' ? <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}
-        {firstRun === undefined
+        {phase !== 'done'
           ? null
-          : <p className={css.installLocation}>{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), ...(install.phase === 'cancelled' || install.phase === 'cancelling' ? { failed: t('installCancelledShort') } : {}) }}
-            className={css.terminal}
-          />
-        ))}
+          : install.installedOnly.length > 0
+            ? <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>
     </Modal>
   )
@@ -590,7 +712,19 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
   const state = props.usePluginManager(snapshot => snapshot)
   // The package whose page is open; one that leaves the list (uninstalled) drops back to the cards.
   const [openPackage, setOpenPackage] = useState<string | null>(null)
+  // The folded group of dependencies that are not packs.
+  const [pluginsOpen, setPluginsOpen] = useState(false)
   useEffect(() => { ensure() }, [ensure])
+  // A package an install just enabled: scroll it into view and mark it for a moment.
+  const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight }
+  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; the bundles the profile
   // template supplies are inspected in the Settings Plugins section's Plugin list tab.
@@ -630,7 +764,17 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
       {restartPending.length > 0
         ? <p className={css.banner} role="status">{t('restartBanner', { names: restartPending.join(', ') })}</p>
         : null}
-      <NoticeLine notice={state.notice} t={t} onDismiss={props.dismissNotice} />
+      {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
@@ -655,26 +799,40 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
           ? <p className={css.empty}>{t('empty')}</p>
           : PACKAGE_GROUPS.map((group) => {
             const members = listed.filter(group.holds)
+            const open = !group.folds || pluginsOpen
             return members.length === 0
               ? null
               : (
                 <section key={group.key} className={css.group} data-plugin-scope="global" data-plugin-group={group.key}>
                   <div className={css.groupTitleRow}>
-                    <h3 className={css.groupTitle}>{t(group.titleKey)}</h3>
+                    {group.folds
+                      ? (
+                        <button type="button" className={css.groupToggle} aria-expanded={open} onClick={() => { setPluginsOpen(value => !value) }}>
+                          <IconChevronDownOutline14 className={css.groupChevron} aria-hidden="true" />
+                          <span className={css.groupTitle}>{t(group.titleKey)}</span>
+                        </button>
+                      )
+                      : <h3 className={css.groupTitle}>{t(group.titleKey)}</h3>}
                     <span className={css.count} data-plugin-count={members.length}>{`${String(members.length)} ${t('countUnit')}`}</span>
                   </div>
-                  <ul className={css.cards}>
-                    {members.map(pkg => (
-                      <PackageCard
-                        key={pkg.name}
-                        pkg={pkg}
-                        t={t}
-                        busy={state.busy.includes(pkg.name)}
-                        onOpen={() => { setOpenPackage(pkg.name) }}
-                        onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
-                      />
-                    ))}
-                  </ul>
+                  {group.introKey === undefined ? null : <p className={css.groupIntro}>{t(group.introKey)}</p>}
+                  {open
+                    ? (
+                      <ul className={css.cards}>
+                        {members.map(pkg => (
+                          <PackageCard
+                            key={pkg.name}
+                            pkg={pkg}
+                            t={t}
+                            busy={state.busy.includes(pkg.name)}
+                            highlighted={state.highlight === pkg.name}
+                            onOpen={() => { setOpenPackage(pkg.name) }}
+                            onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
+                          />
+                        ))}
+                      </ul>
+                    )
+                    : null}
                 </section>
               )
           })
@@ -684,9 +842,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
         t={t}
         onClose={props.closeInstall}
         onEditSpec={props.editInstallSpec}
-        onToggleEnable={props.toggleInstallEnable}
         onRun={props.runInstall}
         onCancel={props.cancelInstall}
+        onToggleDetails={props.toggleInstallDetails}
+        onEnableNow={props.enableInstalled}
       />
       {state.confirm === null
         ? null

+ 69 - 25
packages/client/ui-plugin-manager/src/client/locales.ts

@@ -14,9 +14,9 @@ export const zh = {
   addPlugin: '添加插件',
   restartBanner: '以下更改会在下次启动生效:{names}',
   restartNotice: '更改会在下次启动生效。',
-  dismiss: '知道了',
   bundlesTitle: '插件包',
-  pluginsTitle: '其他已安装包',
+  pluginsTitle: '非插件包依赖',
+  pluginsIntro: '这些包不是 dsh 插件包,不会被加载;可以在这里卸载。',
   countUnit: '个',
   statusRestart: '需重启',
   statusWaiting: '等待依赖',
@@ -57,12 +57,22 @@ export const zh = {
   uninstallLabel: '卸载 {name}',
   rowStateFailed: '异常',
   installTitle: '添加插件',
-  installDescription: '输入插件的包名、本地路径或 Git 地址。',
+  installDescription: '你可以从 Git 社区中获取插件 ID,例如 dsh-better-sidebar、github:someone/dsh-plugin,或输入本地路径例如 /path/to/plugin。',
   installSpecLabel: '包名或地址',
-  installSpecPlaceholder: 'dsh-better-sidebar 或 /path/to/plugin',
-  installExample: '例如 dsh-better-sidebar、github:someone/dsh-plugin、/path/to/plugin',
-  installEnable: '安装完成后直接启用',
+  installSpecPlaceholder: '输入插件的包名、本地路径或 Git 地址',
   installRun: '安装',
+  installChecking: '正在检查…',
+  installProblemInvalid: '无法识别这个包名或地址:{reason}',
+  installProblemInstalled: '该插件已安装',
+  installProblemNotFound: '未找到相关插件',
+  installProblemNotPackage: '该路径不存在或不是有效的插件包',
+  installProblemNetwork: '无法连接插件源,请检查网络后重试',
+  installProblemUnknown: '无法获取插件信息:{reason}',
+  installingTitle: '插件安装中…',
+  installedTitle: '已安装',
+  installFailedTitle: '插件安装失败',
+  installEdit: '编辑',
+  installEditAria: '返回编辑',
   installCancel: '取消安装',
   installStarting: '正在准备安装…',
   installCancelling: '正在停止安装…',
@@ -70,10 +80,25 @@ export const zh = {
   installCancelledShort: '已取消',
   installCancelled: '已取消安装,本次未继续启用插件。下载缓存或已解包文件可能保留,需要时可重新安装。',
   installCancelUnconfirmed: '尚未确认安装已停止,请重试取消或等待安装结果。{reason}',
-  installRunning: '正在安装 {spec}…',
-  installFailedTail: '安装失败:{reason}',
+  installEnableNow: '立即启用',
+  installDetailsShow: '查看安装详情',
+  installDetailsHide: '收起安装详情',
+  installVersion: '版本 {version}',
+  installSubjectPath: '本地目录',
+  installSubjectGit: 'Git 仓库',
+  installSubjectTarball: '压缩包',
   installLocation: '安装位置:{dir}',
   installRetry: '重试',
+  installFailureNetwork: '网络连接失败',
+  installFailureNotFound: '未找到相关插件',
+  installFailureNoMatchingVersion: '没有匹配的版本',
+  installFailureDiskFull: '磁盘空间不足,安装已停止',
+  installFailurePermission: '没有写入权限,无法安装',
+  installFailureBuildBlocked: '依赖的构建脚本被 pnpm 拦截,需要在 profile 的 pnpm-workspace.yaml 中放行后重试',
+  installFailureIntegrity: '下载的安装包校验失败',
+  installFailureTimeout: '安装超时',
+  installFailurePnpmMissing: '没有找到 pnpm,无法安装',
+  installFailureGeneric: '安装过程中出错,原因见安装详情',
   terminalRunning: '运行中',
   terminalFailed: '失败',
   terminalDone: '已完成',
@@ -87,11 +112,8 @@ export const zh = {
   terminalExitCode: '退出码 {code}',
   terminalSignal: '信号 {signal}',
   terminalNoExitCode: '未正常退出',
-  installDoneEnabled: '已安装并启用 {name}。',
-  installDoneBundle: '已安装 {name},打开它的开关即可启用。',
-  installDonePlugin: '已安装 {name},可通过 Cordis 配置手动加载。',
-  installDoneOther: '已安装 {name}。',
   installDoneNothing: '安装完成,没有新增依赖。',
+  installDoneNotBundle: '{name} 不是插件包,安装后不会自动加载。',
   installRemovedInvalid: '{name} 的插件包声明无效,已自动移除:{reason}',
   installRemovedConflict: '{name} 与已安装的插件包冲突,已自动移除:{reason}',
   installFailed: '安装失败。',
@@ -172,9 +194,9 @@ export const en = {
   addPlugin: 'Add plugin',
   restartBanner: 'These changes take effect at the next start: {names}',
   restartNotice: 'The change takes effect at the next start.',
-  dismiss: 'Got it',
   bundlesTitle: 'Plugin packs',
-  pluginsTitle: 'Other installed packages',
+  pluginsTitle: 'Dependencies that are not plugin packs',
+  pluginsIntro: 'These packages are not dsh plugin packs and never load; they can be uninstalled here.',
   countUnit: 'packages',
   statusRestart: 'Restart needed',
   statusWaiting: 'Waiting for a dependency',
@@ -215,23 +237,48 @@ export const en = {
   uninstallLabel: 'Uninstall {name}',
   rowStateFailed: 'Problem',
   installTitle: 'Add plugin',
-  installDescription: 'Enter the plugin\'s package name, a local path, or a Git URL.',
+  installDescription: 'Use a plugin ID from the Git community, such as dsh-better-sidebar or github:someone/dsh-plugin, or a local path such as /path/to/plugin.',
   installSpecLabel: 'Package or address',
-  installSpecPlaceholder: 'dsh-better-sidebar or /path/to/plugin',
-  installExample: 'For example dsh-better-sidebar, github:someone/dsh-plugin, /path/to/plugin',
-  installEnable: 'Enable right after installing',
+  installSpecPlaceholder: 'Package name, local path, or Git address',
   installRun: 'Install',
-  installCancel: 'Cancel installation',
+  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',
+  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 without proceeding to enable plugins. Downloaded or unpacked files may remain; reinstall if needed.',
   installCancelUnconfirmed: 'Installation has not been confirmed stopped. Retry cancellation or wait for the installation result. {reason}',
-  installRunning: 'Installing {spec}…',
-  installFailedTail: 'The install failed: {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: 'pnpm blocked a dependency\'s build script; allow it in the profile\'s 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',
@@ -245,11 +292,8 @@ export const en = {
   terminalExitCode: 'exit code {code}',
   terminalSignal: 'signal {signal}',
   terminalNoExitCode: 'no exit code',
-  installDoneEnabled: 'Installed and enabled {name}.',
-  installDoneBundle: 'Installed {name}; turn its switch on to enable it.',
-  installDonePlugin: 'Installed {name}; load it through Cordis configuration.',
-  installDoneOther: 'Installed {name}.',
   installDoneNothing: 'Install finished with no new dependency.',
+  installDoneNotBundle: '{name} is not a plugin pack and does not load on its own.',
   installRemovedInvalid: '{name} has invalid plugin pack declarations and was removed: {reason}',
   installRemovedConflict: '{name} conflicts with an installed plugin pack and was removed again: {reason}',
   installFailed: 'The install failed.',

+ 190 - 36
packages/client/ui-plugin-manager/src/client/manager-store.ts

@@ -11,18 +11,22 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {
   PluginDependents,
   PluginEnableResult,
+  PluginInspectProblem,
+  PluginInstallFailureKind,
   PluginInstallLogChunk,
+  PluginInstallProgress,
   PluginInstallRejection,
-  PluginInstallResult,
   PluginInstallRequestId,
-  PluginInstallProgress,
+  PluginInstallResult,
   PluginPackageView,
+  PluginSpecInspection,
 } from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 
-/** What the last action left to say. */
+/** 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 kind: 'restart'; readonly packageName: string; readonly seq: number }
+  | { readonly kind: 'cancelled'; readonly seq: number }
   | {
     readonly kind: 'failed'
     /** The Host's failure code, which selects the copy. */
@@ -31,8 +35,20 @@ export type ManagerNotice =
     readonly reason: string
     readonly packageName?: string
     readonly rowId?: string
+    readonly seq: number
   }
 
+/** The typed spec as the Host read it, on the installing, installed, and failed screens. */
+export interface InstallSubject extends PluginSpecInspection {
+  readonly spec: string
+}
+
+/** 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
@@ -46,30 +62,41 @@ export interface InstallRun {
   readonly exitCode?: number | null
 }
 
-/** The install dialog. */
+/**
+ * The install dialog: the spec is typed while `idle`, read by the Host while
+ * `checking`, then installed through the Host-owned phases — `starting` until
+ * the Host acknowledges the run, `running`, `cancelling` while the Host stops
+ * it, `applying` once configuration application closed the cancellation
+ * 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
-  /** Whether a newly installed bundle is enabled right away. */
-  readonly enable: boolean
-  readonly phase: 'idle' | 'starting' | 'running' | 'cancelling' | 'applying' | 'cancelled' | 'done' | 'failed'
+  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
   /** Dependencies the last run added and kept, once it finished. */
   readonly installed: readonly string[]
-  /** Bundles the run enabled at once. */
-  readonly enabled: readonly string[]
   /** Bundles the run installed and left off. */
   readonly installedOnly: readonly string[]
   /** Packages installed without a bundle patch. */
   readonly plain: readonly string[]
   /** Packages pnpm added that the Host removed again, each with its reason. */
   readonly removed: readonly PluginInstallRejection[]
-  /** The Host's refusal, when the run failed before or after pnpm. */
-  readonly failure: { readonly code: string; readonly reason: string } | null
+  /** The Host's refusal or the run's failure, once one settled the dialog; `kind` classifies a pnpm failure. */
+  readonly failure: { readonly code: string; readonly reason: string; readonly kind?: PluginInstallFailureKind } | null
+  /** Enabling the newly installed bundles from the installed screen is crossing the wire. */
+  readonly enabling: boolean
 }
 
 /**
@@ -108,6 +135,8 @@ export interface PluginManagerState {
   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. */
@@ -121,12 +150,18 @@ export interface PluginManagerFace {
   /** 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
-  toggleInstallEnable: () => void
+  /** Check the spec with the Host, then install it; from the failed screen, run it again. */
   runInstall: () => void
-  /** Request cancellation and wait for Host cleanup. */
+  /** Leave the check or the failed screen for the spec, or ask the Host to stop the run and wait for its cleanup. */
   cancelInstall: () => void
+  toggleInstallDetails: () => void
+  /** Enable the bundles the finished install added, then close the dialog and mark the first in the list. */
+  enableInstalled: () => void
+  /** Drop the list mark once it has been shown. */
+  clearHighlight: () => void
   /** Put a bundle into, or take it out of, the profile's layer list. */
   setEnabled: (packageName: string, enabled: boolean) => void
   /** Compose an enabled bundle again from scratch. */
@@ -186,9 +221,13 @@ export function rowKey(rowId: string): string {
 }
 
 const IDLE_INSTALL: InstallState = {
-  open: false, spec: '', enable: true, phase: 'idle', runs: [], installed: [], enabled: [], installedOnly: [], plain: [], removed: [], failure: null,
+  open: false, spec: '', phase: 'idle', inputError: null, subject: null, runs: [], detailsOpen: false,
+  installed: [], installedOnly: [], plain: [], removed: [], failure: null, enabling: false,
 }
 
+/** Refusals of an install that are about the moment, not the spec: they toast and leave the spec to try again. */
+const TRANSIENT_REFUSALS = new Set(['plugins/busy', 'plugins/agents-running'])
+
 /** Reads and mutates the profile's plugins through the `plugins` Remote. */
 export class PluginManagerController {
   private readonly store: SnapshotStore<PluginManagerState>
@@ -197,6 +236,9 @@ export class PluginManagerController {
   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.plugins` namespace answers.
@@ -206,7 +248,7 @@ export class PluginManagerController {
   ) {
     this.store = createSnapshotStore<PluginManagerState>({
       status: 'idle', packages: [], busy: [], notice: null,
-      install: IDLE_INSTALL, confirm: null,
+      install: IDLE_INSTALL, confirm: null, highlight: null,
     })
   }
 
@@ -238,18 +280,20 @@ export class PluginManagerController {
       },
       closeInstall: () => {
         if (isInstallPending(this.getSnapshot().install.phase)) return
+        this.abortInspect()
         this.patch({ install: IDLE_INSTALL })
       },
       editInstallSpec: (text) => {
         const install = this.getSnapshot().install
-        // A new spec after a settled run starts over: the outcome on screen belongs to the old spec.
-        this.patchInstall(install.phase === 'done' || install.phase === 'failed' || install.phase === 'cancelled'
-          ? { ...IDLE_INSTALL, open: true, enable: install.enable, spec: text }
-          : { spec: text })
+        // 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 })
       },
-      toggleInstallEnable: () => { this.patchInstall({ enable: !this.getSnapshot().install.enable }) },
       runInstall: () => { void this.runInstall() },
       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.setEnabled(packageName, enabled) },
       retry: (packageName) => {
         void this.run(packageName, { packageName }, async () => {
@@ -435,36 +479,94 @@ export class PluginManagerController {
     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 install = this.getSnapshot().install
+    const state = this.getSnapshot()
+    const install = state.install
     const spec = install.spec.trim()
-    if (isInstallPending(install.phase) || spec === '') return
+    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: [], installedOnly: [], plain: [], removed: [], failure: null,
+    })
+    const inspected = await this.ctx.remote.plugins.inspect(spec, controller.signal)
+    if (this.gone(controller.signal)) return
+    this.inspectAbort = undefined
+    if (!inspected.ok) {
+      const problem = detailOf(inspected.error, 'problem')
+      this.patchInstall({
+        phase: 'idle',
+        inputError: { problem: isInspectProblem(problem) ? problem : 'unknown', reason: reasonOf(inspected.error) },
+      })
+      return
+    }
     const requestId = randomUUID() as PluginInstallRequestId
-    this.patchInstall({ phase: 'starting', requestId, runs: [], installed: [], enabled: [], installedOnly: [], plain: [], removed: [], failure: null })
+    this.patchInstall({ phase: 'starting', requestId, subject: { spec, ...inspected.value } })
     // The Host announces `plugins/changed` while the run is still on the
-    // wire — enabling recomposes before the call answers — and every such
-    // event reads again; those reads must not cancel the run's settlement.
-    const result = await this.ctx.remote.plugins.add(spec, { enable: install.enable, requestId })
+    // wire, and every such event reads again; those reads must not cancel
+    // the run's settlement.
+    const result = await this.ctx.remote.plugins.add(spec, { requestId })
     if (this.disposed || this.getSnapshot().install.requestId !== requestId) return
     // A run whose last chunk never reached the dialog settles from the answer:
     // a finished install, and a refusal that followed pnpm — a bundle the tree
-    // rejected, a package check that refused it — both mean every run exited 0, and a
-    // pnpm failure names the code the failing run exited with.
+    // rejected — both mean every run exited 0, and a pnpm failure names the
+    // code the failing run exited with.
     const runs = this.getSnapshot().install.runs
     if (result.ok) {
       this.patchInstall({ phase: 'done', runs: settledRuns(runs, 0), ...outcomeOf(result.value) })
     } else if (result.error.code === 'plugins/install-cancelled') {
-      this.patchInstall({ phase: 'cancelled', runs: settledRuns(runs, null), failure: null })
+      this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq })
+    } else if (TRANSIENT_REFUSALS.has(result.error.code)) {
+      // The moment refused, not the spec: say so in passing and keep the spec to try again.
+      this.offerSpecAgain(this.failedNotice(result.error, {}))
     } else {
       const reason = detailOf(result.error, 'reason') ?? detailOf(result.error, 'log') ?? result.error.message
+      const kind = detailOf(result.error, 'kind')
       const exitCode = result.error.code === 'plugins/install-failed' ? exitCodeOf(result.error) ?? null : 0
-      this.patchInstall({ phase: 'failed', runs: settledRuns(runs, exitCode), failure: { code: result.error.code, reason } })
+      this.patchInstall({
+        phase: 'failed',
+        runs: settledRuns(runs, exitCode),
+        failure: { code: result.error.code, reason, ...isFailureKind(kind) ? { kind } : {} },
+      })
     }
     void this.load()
   }
 
+  /**
+   * 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 })
@@ -476,7 +578,7 @@ export class PluginManagerController {
       return
     }
     if (result.value.status === 'cancelled') {
-      this.patchInstall({ phase: 'cancelled', runs: settledRuns(current.runs, null), failure: null })
+      this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq })
       void this.load()
     } else if (result.value.status === 'too-late') {
       this.patchInstall({ phase: 'applying' })
@@ -485,6 +587,46 @@ export class PluginManagerController {
     }
   }
 
+  /**
+   * Back to the spec: the check, the run, and its request are forgotten and
+   * the spec is kept, with a toast when there is something to say — the Host
+   * stopped the run, or the moment refused it.
+   */
+  private offerSpecAgain(notice: ManagerNotice | null = null): void {
+    const { open, spec } = this.getSnapshot().install
+    this.patch({ install: { ...IDLE_INSTALL, open, spec }, ...notice === null ? {} : { notice } })
+  }
+
+  /**
+   * Enable every bundle the finished install added, one after the other, then
+   * close the dialog and mark the first of them in the list. A refusal toasts
+   * and still closes: the list shows what did and did not switch on.
+   */
+  private async enableInstalled(): Promise<void> {
+    const install = this.getSnapshot().install
+    if (install.phase !== 'done' || install.enabling) return
+    this.patchInstall({ enabling: true })
+    for (const name of install.installedOnly) {
+      const result = await this.ctx.remote.plugins.enable(name)
+      if (this.disposed) return
+      if (!result.ok) {
+        this.patch({ notice: this.failedNotice(result.error, { packageName: name }) })
+        break
+      }
+      if (result.value.effect === 'restart') this.patch({ notice: { kind: 'restart', packageName: name, seq: ++this.noticeSeq } })
+    }
+    this.patch({ install: IDLE_INSTALL, highlight: install.installedOnly[0] ?? null })
+    await this.load()
+  }
+
+  /** The toast a refused answer becomes. */
+  private failedNotice(
+    error: { code: string; message: string; details?: unknown },
+    subject: { packageName?: string; rowId?: string },
+  ): ManagerNotice {
+    return { kind: 'failed', code: error.code, reason: reasonOf(error), ...subject, seq: ++this.noticeSeq }
+  }
+
   /**
    * Run one action under a busy key, turn its failure into the notice, and
    * re-read the Host afterwards whatever happened.
@@ -504,7 +646,7 @@ export class PluginManagerController {
       const failure = error instanceof RemoteAnswerError
         ? error
         : new RemoteAnswerError('gateway/internal', error instanceof Error ? error.message : String(error))
-      this.patch({ notice: { kind: 'failed', code: failure.code, reason: failure.reason, ...subject } })
+      this.patch({ notice: { kind: 'failed', code: failure.code, reason: failure.reason, ...subject, seq: ++this.noticeSeq } })
     } finally {
       this.patch({ busy: this.getSnapshot().busy.filter(entry => entry !== key) })
     }
@@ -515,7 +657,7 @@ export class PluginManagerController {
   private effect(result: Answer<PluginEnableResult>, packageName: string): void {
     const value = this.answer(result)
     // Success needs no notice: the re-read list shows the new state. A restart the change waits for does.
-    if (value.effect === 'restart') this.patch({ notice: { kind: 'restart', packageName } })
+    if (value.effect === 'restart') this.patch({ notice: { kind: 'restart', packageName, seq: ++this.noticeSeq } })
   }
 
   /** Unwrap an answer, throwing its failure for {@link run} to report. */
@@ -535,16 +677,28 @@ export class PluginManagerController {
 }
 
 /** The lists one finished install run reports, as the dialog's state carries them. */
-function outcomeOf(result: PluginInstallResult): Pick<InstallState, 'installed' | 'enabled' | 'installedOnly' | 'plain' | 'removed'> {
+function outcomeOf(result: PluginInstallResult): Pick<InstallState, 'installed' | 'installedOnly' | 'plain' | 'removed'> {
   return {
     installed: result.installed,
-    enabled: result.enabled,
     installedOnly: result.installedOnly,
     plain: result.plain,
     removed: result.removed,
   }
 }
 
+const INSPECT_PROBLEMS: readonly PluginInspectProblem[] = ['invalid-spec', 'already-installed', 'not-found', 'not-a-package', 'network', 'unknown']
+const FAILURE_KINDS: readonly PluginInstallFailureKind[] = [
+  'pnpm-missing', 'timeout', 'not-found', 'no-matching-version', 'network', 'disk-full', 'permission', 'build-blocked', 'integrity', 'unknown',
+]
+
+function isInspectProblem(value: string | undefined): value is PluginInspectProblem {
+  return value !== undefined && (INSPECT_PROBLEMS as readonly string[]).includes(value)
+}
+
+function isFailureKind(value: string | undefined): value is PluginInstallFailureKind {
+  return value !== undefined && (FAILURE_KINDS as readonly string[]).includes(value)
+}
+
 /** A refused Remote answer, carried to the notice with the Host's code and reason. */
 class RemoteAnswerError extends Error {
   constructor(readonly code: string, readonly reason: string) {

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

@@ -69,6 +69,7 @@ export function refusalText(failure: { readonly code: string; readonly reason: s
 export function noticeText(notice: ManagerNotice, t: Translate): string {
   switch (notice.kind) {
     case 'restart': return t('restartNotice')
+    case 'cancelled': return t('installCancelled')
     case 'failed': {
       switch (notice.code) {
         case 'plugins/not-enableable': return t('notEnableable', { reason: notice.reason })

+ 228 - 90
packages/client/ui-plugin-manager/tests/components.client.spec.tsx

@@ -34,7 +34,8 @@ function pkg(overrides: Partial<PluginPackageView> = {}): PluginPackageView {
 }
 
 const IDLE_INSTALL: InstallState = {
-  open: false, spec: '', enable: true, phase: 'idle', runs: [], installed: [], enabled: [], installedOnly: [], plain: [], removed: [], failure: null,
+  open: false, spec: '', phase: 'idle', inputError: null, subject: null, runs: [], detailsOpen: false,
+  installed: [], installedOnly: [], plain: [], removed: [], failure: null, enabling: false,
 }
 
 const READY: PluginManagerState = {
@@ -44,6 +45,7 @@ const READY: PluginManagerState = {
   notice: null,
   install: IDLE_INSTALL,
   confirm: null,
+  highlight: null,
 }
 
 function renderTab(state: Partial<PluginManagerState> = {}) {
@@ -54,9 +56,11 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
     openInstall: vi.fn(),
     closeInstall: vi.fn(),
     editInstallSpec: vi.fn(),
-    toggleInstallEnable: vi.fn(),
     runInstall: vi.fn(),
     cancelInstall: vi.fn(),
+    toggleInstallDetails: vi.fn(),
+    enableInstalled: vi.fn(),
+    clearHighlight: vi.fn(),
     setEnabled: vi.fn(),
     retry: vi.fn(),
     uninstall: vi.fn(),
@@ -132,6 +136,7 @@ describe('PluginManagerPage', () => {
 
   it('shows a problem for a plugin with an invalid declaration', () => {
     renderTab({ packages: [pkg({ kind: 'unknown', status: 'plain', reason: 'invalid declaration' })] })
+    fireEvent.click(screen.getByRole('button', { name: en.pluginsTitle }))
     expect(screen.getByText(en.statusProblem)).toBeTruthy()
   })
 
@@ -159,7 +164,14 @@ describe('PluginManagerPage', () => {
     expect(document.querySelector('[data-plugin-group="bundles"] [data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('5')
     expect(document.querySelector('[data-plugin-group="plugins"] [data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('2')
     expect(screen.getByRole('heading', { name: en.bundlesTitle })).toBeTruthy()
-    expect(screen.getByRole('heading', { name: en.pluginsTitle })).toBeTruthy()
+    // The dependencies that are not packs stay folded until opened, their count and intro showing meanwhile.
+    const plugins = screen.getByRole('button', { name: en.pluginsTitle })
+    expect(plugins.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.getByText(en.pluginsIntro)).toBeTruthy()
+    expect(document.querySelector('[data-plugin-package="some-lib"]')).toBeNull()
+    fireEvent.click(plugins)
+    expect(plugins.getAttribute('aria-expanded')).toBe('true')
+    expect(document.querySelector('[data-plugin-package="some-lib"]')).not.toBeNull()
     for (const name of ['@deepseek-ai/dsh-bundle-first-party', 'unknown', 'dsh-untitled']) {
       expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull()
     }
@@ -276,6 +288,7 @@ describe('PluginManagerPage', () => {
     expect(screen.getByText(en.partsEmpty)).toBeTruthy()
     expect(screen.getByText(en.noDescription)).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+    fireEvent.click(screen.getByRole('button', { name: en.pluginsTitle }))
     fireEvent.click(screen.getByRole('button', { name: 'View tool-foo' }))
     expect(screen.queryByText(en.partsLabel)).toBeNull()
     expect(screen.queryByRole('button', { name: en.retryPackage })).toBeNull()
@@ -358,54 +371,71 @@ describe('PluginManagerPage', () => {
 
   it('shows a scoped non-bundle package with package details and uninstall', () => {
     renderTab({ packages: [pkg({ name: '@acme/example', kind: 'unknown', status: 'plain' })] })
+    fireEvent.click(screen.getByRole('button', { name: en.pluginsTitle }))
     fireEvent.click(screen.getByRole('button', { name: 'View example' }))
     expect(screen.getByText(en.unknownPackage)).toBeTruthy()
     expect(screen.getByRole('button', { name: 'Uninstall example' })).toBeTruthy()
     expect(screen.queryByRole('switch')).toBeNull()
   })
 
-  it('shows pending cancellation and lets a stopped installation be retried', () => {
-    const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'starting' } })
-    expect(screen.getByText(en.installStarting)).toBeTruthy()
-    expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true)
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'cancelling' } })
-    expect(screen.getByRole('button', { name: en.installCancelling })).toHaveProperty('disabled', true)
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'applying' } })
-    expect(screen.getByText(en.installApplying)).toBeTruthy()
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'cancelled', runs: [{ jobId: 'j', command: 'pnpm add slow', cwd: '/p', output: '', exitCode: null }] } })
-    expect(screen.getByText(en.installCancelled)).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: en.installRetry }))
-    expect(actions.runInstall).toHaveBeenCalledOnce()
-    set({ install: { ...IDLE_INSTALL, open: true, phase: 'running', failure: { code: 'client/cancel-unconfirmed', reason: 'offline' } } })
-    expect(screen.getByRole('alert').textContent).toContain('offline')
-  })
-
-  it('drives the install dialog through its phases and words each outcome', () => {
+  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.installExample)).toBeTruthy()
-    expect(screen.getByRole('button', { name: en.installRun })).toHaveProperty('disabled', true)
-    fireEvent.change(screen.getByRole('textbox', { name: en.installSpecLabel }), { target: { value: 'dsh-x' } })
+    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')
-    fireEvent.click(screen.getByRole('checkbox'))
-    expect(actions.toggleInstallEnable).toHaveBeenCalledTimes(1)
     expect(document.querySelector('[data-terminal]')).toBeNull()
+    expect(screen.queryByRole('checkbox')).toBeNull()
 
     set({ install: { ...IDLE_INSTALL, open: true, spec: ' dsh-x ' } })
-    fireEvent.click(screen.getByRole('button', { name: en.installRun }))
-    expect(actions.runInstall).toHaveBeenCalledTimes(1)
-    fireEvent.click(screen.getByRole('button', { name: en.cancel }))
+    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],
+      ['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)
+  })
 
-    set({
-      install: {
-        ...IDLE_INSTALL,
-        open: true,
-        spec: 'dsh-x',
-        phase: 'running',
-        runs: [{ jobId: 'j1', command: 'pnpm add dsh-x', cwd: '/home/u/.dsh/profiles/web', output: 'Progress: resolved \u001b[96m1\u001b[39m\n' }],
-      },
-    })
-    expect(screen.getByText(en.installRunning.replace('{spec}', 'dsh-x'))).toBeTruthy()
+  it('shows the subject while installing, folds the pnpm output behind the details, and stops through the Host', () => {
+    const subject = { spec: 'dsh-x', kind: 'registry', name: 'dsh-x', title: 'Sidebar', version: '1.4.2', description: 'A sidebar.', bundle: true } as const
+    const run = { jobId: 'j1', command: 'pnpm add dsh-x', cwd: '/home/u/.dsh/profiles/web', output: 'Progress: resolved \u001b[96m1\u001b[39m\n' }
+    const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, runs: [run] } })
+    expect(screen.getByRole('status').textContent).toBe(en.installingTitle)
+    expect(screen.getByText('Sidebar')).toBeTruthy()
+    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()
@@ -413,67 +443,163 @@ describe('PluginManagerPage', () => {
     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()
-    fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
-    expect(actions.cancelInstall).toHaveBeenCalledOnce()
     // A long log folds its middle behind an expand control, so the dialog keeps its height while pnpm talks.
     const lines = Array.from({ length: 15 }, (_line, index) => `line ${index + 1}`).join('\n')
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', runs: [{ jobId: 'j1', command: 'pnpm add dsh-x', cwd: '/p', output: `${lines}\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()
-    expect(screen.getByText(en.terminalCollapse)).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', 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: { code: 'client/cancel-unconfirmed', reason: 'offline' } } })
+    expect(screen.getByRole('alert').textContent).toContain('offline')
+    expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', false)
+  })
+
+  it('offers to enable what a finished install added, and words what it did not add', () => {
+    const subject = { spec: '/plugins/dsh-x', kind: 'path', name: 'dsh-x', bundle: true } as const
+    const { actions, set } = renderTab({
       install: {
         ...IDLE_INSTALL,
         open: true,
-        spec: 'dsh-x',
+        spec: '/plugins/dsh-x',
         phase: 'done',
-        runs: [{ jobId: 'j1', command: 'pnpm add dsh-x', cwd: '/p', output: 'Done in 1s\n', exitCode: 0 }],
-        installed: ['a', 'b', 'c', 'd'],
-        enabled: ['a'],
-        installedOnly: ['b'],
-        plain: ['c'],
+        subject,
+        runs: [{ jobId: 'j1', command: 'pnpm add /plugins/dsh-x', cwd: '/p', output: 'Done in 1s\n', exitCode: 0 }],
+        installed: ['dsh-x', 'dsh-lib'],
+        installedOnly: ['dsh-x'],
+        plain: ['dsh-lib'],
         removed: [
           { name: 'invalid-bundle', reason: 'invalid stage' },
           { name: 'clash', reason: 'row "x" is already declared by y' },
         ],
       },
     })
-    expect(screen.getByText(en.installDoneEnabled.replace('{name}', 'a'))).toBeTruthy()
-    expect(screen.getByText(en.installDoneBundle.replace('{name}', 'b'))).toBeTruthy()
-    expect(screen.getByText(en.installDonePlugin.replace('{name}', 'c'))).toBeTruthy()
-    expect(screen.getByText(en.installDoneOther.replace('{name}', 'd'))).toBeTruthy()
+    expect(screen.getByText(en.installedTitle)).toBeTruthy()
+    // A path without a description reads by its kind.
+    expect(screen.getByText('dsh-x')).toBeTruthy()
+    expect(screen.getByText(en.installSubjectPath)).toBeTruthy()
+    expect(screen.getByText(en.installDoneNotBundle.replace('{name}', 'dsh-lib'))).toBeTruthy()
     expect(screen.getByText(en.installRemovedInvalid.replace('{name}', 'invalid-bundle').replace('{reason}', 'invalid stage'))).toBeTruthy()
     expect(screen.getByText(en.installRemovedConflict.replace('{name}', 'clash').replace('{reason}', 'row "x" is already declared by y'))).toBeTruthy()
-    // A finished run leaves Done as the only action.
-    expect(screen.getByRole('button', { name: en.installClose })).toBeTruthy()
-    expect(screen.queryByRole('button', { name: en.installRun })).toBeNull()
-    expect(within(document.querySelector('[data-terminal]') as HTMLElement).getByText(en.terminalDone)).toBeTruthy()
-
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done' } })
+    // No way back to the spec from here; enabling is the one action.
+    expect(screen.queryByRole('button', { name: en.installEditAria })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: en.installEnableNow }))
+    expect(actions.enableInstalled).toHaveBeenCalledTimes(1)
+    set({ install: { ...IDLE_INSTALL, open: true, spec: '/plugins/dsh-x', phase: 'done', subject, installed: ['dsh-x'], installedOnly: ['dsh-x'], enabling: true } })
+    expect(screen.getByRole('button', { name: en.installEnableNow })).toHaveProperty('disabled', true)
+
+    // Nothing new, or nothing that switches on, leaves only Done.
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject: { spec: 'dsh-x', kind: 'registry', name: 'dsh-x', bundle: false } } })
     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)
+  })
 
-    // A pnpm failure with its run on screen points at the terminal; without a run, the Host's tail stands in.
-    set({
+  it('words a failed install by its kind and retries it', () => {
+    const subject = { spec: 'github:a/b', kind: 'git', bundle: null } as const
+    const { actions, set } = renderTab({
       install: {
         ...IDLE_INSTALL,
         open: true,
-        spec: 'dsh-x',
+        spec: 'github:a/b',
         phase: 'failed',
-        runs: [{ jobId: 'j1', command: 'pnpm add dsh-x', cwd: '/p', output: 'ERR\n', exitCode: 1 }],
-        failure: { code: 'plugins/install-failed', reason: 'ERR\n' },
+        subject,
+        detailsOpen: true,
+        runs: [{ jobId: 'j1', command: 'pnpm add github:a/b', cwd: '/p', output: 'ERR\n', exitCode: 1 }],
+        failure: { code: 'plugins/install-failed', reason: 'ERR\n', kind: 'network' },
       },
     })
-    expect(screen.getByRole('alert').textContent).toBe(en.installFailed)
-    expect(screen.getByRole('button', { name: en.installRetry })).toBeTruthy()
+    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()
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'failed', failure: { code: 'plugins/install-failed', reason: 'ERR' } } })
-    expect(screen.getByRole('alert').textContent).toBe(en.installFailedTail.replace('{reason}', 'ERR'))
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'failed', failure: { code: 'plugins/busy', reason: 'add x' } } })
-    expect(screen.getByRole('alert').textContent).toBe(en.busy.replace('{reason}', 'add x'))
-    set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'failed', failure: null } })
-    expect(screen.getByRole('alert').textContent).toBe(en.installFailed)
+    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.installFailureBuildBlocked], ['integrity', en.installFailureIntegrity], ['unknown', en.installFailureGeneric],
+    ]
+    for (const [kind, sentence] of kinds) {
+      set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { code: 'plugins/install-failed', reason: 'r', kind: kind as never } } })
+      expect(screen.getByText(sentence)).toBeTruthy()
+    }
+    // A pnpm failure without a kind, a refusal, and no failure at all.
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { code: 'plugins/install-failed', reason: 'r' } } })
+    expect(screen.getByText(en.installFailureGeneric)).toBeTruthy()
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { code: 'plugins/enable-failed', reason: 'r' } } })
+    expect(screen.getByText(en.actionFailed.replace('{reason}', 'r'))).toBeTruthy()
+    set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: null } })
+    expect(screen.getByText(en.installFailureGeneric)).toBeTruthy()
+  })
+
+  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('asks before switching a row off, naming the rows that inject what it provides', () => {
@@ -532,25 +658,37 @@ describe('PluginManagerPage', () => {
     expect(actions.confirm).toHaveBeenCalledTimes(2)
   })
 
-  it('words every notice and dismisses it', () => {
-    const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x' } })
-    expect(screen.getByRole('status').textContent).toContain(en.restartNotice)
-    const failures: [string, string][] = [
-      ['plugins/not-enableable', en.notEnableable.replace('{reason}', 'r')],
-      ['plugins/enable-failed', en.enableFailed.replace('{reason}', 'r')],
-      ['plugins/not-installed', en.notInstalled.replace('{name}', 'pkg-1')],
-      ['plugins/busy', en.busy.replace('{reason}', 'r')],
-      ['plugins/agents-running', en.agentsRunning.replace('{reason}', 'r')],
-      ['plugins/install-failed', en.installFailed],
-      ['gateway/internal', en.actionFailed.replace('{reason}', 'r')],
-    ]
-    for (const [code, text] of failures) {
-      set({ notice: { kind: 'failed', code, reason: 'r', packageName: 'pkg-1', rowId: 'row-1' } })
-      expect(screen.getByRole('alert').textContent).toContain(text)
+  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)
+      const failures: [string, string][] = [
+        ['plugins/not-enableable', en.notEnableable.replace('{reason}', 'r')],
+        ['plugins/enable-failed', en.enableFailed.replace('{reason}', 'r')],
+        ['plugins/not-installed', en.notInstalled.replace('{name}', 'pkg-1')],
+        ['plugins/busy', en.busy.replace('{reason}', 'r')],
+        ['plugins/agents-running', en.agentsRunning.replace('{reason}', 'r')],
+        ['plugins/install-failed', en.installFailed],
+        ['gateway/internal', en.actionFailed.replace('{reason}', 'r')],
+      ]
+      let seq = 1
+      for (const [code, text] of failures) {
+        set({ notice: { kind: 'failed', code, reason: 'r', packageName: 'pkg-1', rowId: 'row-1', seq: ++seq } })
+        expect(screen.getByRole('alert').textContent).toContain(text)
+      }
+      set({ notice: { kind: 'cancelled', seq: ++seq } })
+      expect(screen.getByRole('alert').textContent).toContain(en.installCancelled)
+      set({ notice: { kind: 'failed', code: 'plugins/not-installed', reason: 'r', seq: ++seq } })
+      expect(screen.getByRole('alert').textContent).toContain(en.notInstalled.replace('{name}', ''))
+      // 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()
     }
-    set({ notice: { kind: 'failed', code: 'plugins/not-installed', reason: 'r' } })
-    expect(screen.getByRole('alert').textContent).toContain(en.notInstalled.replace('{name}', ''))
-    fireEvent.click(screen.getByRole('button', { name: en.dismiss }))
-    expect(actions.dismissNotice).toHaveBeenCalledTimes(1)
   })
 })

+ 321 - 120
packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts

@@ -4,9 +4,9 @@
  */
 
 import { describe, expect, it, vi } from 'vitest'
-import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
+import type { PluginInstallRequestId, PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
-import { type InstallState, PluginManagerController, rowKey } from '../src/client/manager-store.ts'
+import { PluginManagerController, rowKey } from '../src/client/manager-store.ts'
 
 const BUNDLE: PluginPackageView = {
   name: 'dsh-better-sidebar',
@@ -31,6 +31,9 @@ type InstallValue = {
   jobId: string
 }
 
+/** What the check answers for a registry name. */
+const INSPECTED = { kind: 'registry' as const, name: 'dsh-better-sidebar', version: '1.0.0', bundle: true }
+
 function ok<T>(value: T) {
   return { ok: true as const, value }
 }
@@ -49,6 +52,7 @@ function deferred<T>() {
 function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}) {
   const plugins = {
     list: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
+    inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))),
     add: vi.fn(() => Promise.resolve(ok({ installed: ['a'], removed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j1' }))),
     cancelInstall: vi.fn(() => Promise.resolve(ok({ status: 'cancelled' }))),
     uninstall: vi.fn(() => Promise.resolve(ok(undefined))),
@@ -62,12 +66,13 @@ function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}
   const ctx = { remote: { plugins } } as never
   const controller = new PluginManagerController(ctx)
   const face = controller.inject()
-  return { plugins, controller, face, state: () => controller.getSnapshot() }
-}
-
-function installationRequest(controller: PluginManagerController): Pick<InstallState, 'requestId'> {
-  const requestId = controller.getSnapshot().install.requestId
-  return requestId === undefined ? {} : { requestId }
+  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, controller, face, state, started }
 }
 
 describe('PluginManagerController', () => {
@@ -118,7 +123,7 @@ describe('PluginManagerController', () => {
     expect(plugins.enable).toHaveBeenCalledTimes(1)
     gate.resolve(ok({ changed: true, effect: 'restart' }))
     await vi.waitFor(() => { expect(state().busy).toEqual([]) })
-    expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name })
+    expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name, seq: 1 })
     expect(plugins.list).toHaveBeenCalledTimes(2)
     face.dismissNotice()
     expect(state().notice).toBeNull()
@@ -134,7 +139,7 @@ describe('PluginManagerController', () => {
     face.setEnabled(BUNDLE.name, true)
     await vi.waitFor(() => { expect(state().notice).not.toBeNull() })
     expect(state().notice).toEqual({
-      kind: 'failed', code: 'plugins/not-enableable', reason: 'foreign cordis', packageName: BUNDLE.name,
+      kind: 'failed', code: 'plugins/not-enableable', reason: 'foreign cordis', packageName: BUNDLE.name, seq: 1,
     })
     expect(state().busy).toEqual([])
     face.setEnabled(BUNDLE.name, true)
@@ -146,7 +151,7 @@ describe('PluginManagerController', () => {
     await controller.load()
     face.setEnabled(BUNDLE.name, false)
     await vi.waitFor(() => { expect(plugins.disable).toHaveBeenCalledWith(BUNDLE.name) })
-    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name }) })
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name, seq: 1 }) })
 
     plugins.dependents.mockResolvedValueOnce(ok({
       services: [{ service: 'sidebar', providedBy: 'dsh-better-sidebar/better-sidebar', injectedBy: ['x'] }],
@@ -274,144 +279,320 @@ describe('PluginManagerController', () => {
     await controller.load()
     face.setRowDisabled('r', true)
     await vi.waitFor(() => {
-      expect(state().notice).toEqual({ kind: 'failed', code: 'gateway/internal', reason: 'offline', rowId: 'r' })
+      expect(state().notice).toEqual({ kind: 'failed', code: 'gateway/internal', reason: 'offline', rowId: 'r', seq: 1 })
     })
     face.setRowDisabled('r', true)
     await vi.waitFor(() => { expect(state().notice).toMatchObject({ reason: 'plain text' }) })
   })
 
-  it('waits for Host cancellation and ignores the old add reply after a retry starts', async () => {
+  it('checks the spec, hands the run to the Host, and folds the chunks that carry its request id', async () => {
+    const gate = deferred<ReturnType<typeof ok<InstallValue>>>()
+    const { plugins, face, state, controller, started } = bench({ add: vi.fn().mockReturnValueOnce(gate.promise) })
+    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.add).toHaveBeenCalledTimes(1)
+    expect(plugins.add).toHaveBeenCalledWith('dsh-new', { requestId })
+    // The Host's acknowledgement makes the run stoppable; a chunk of another request is not this run's.
+    controller.installProgress({ requestId, phase: 'installing' })
+    expect(state().install.phase).toBe('running')
+    controller.appendLog({ requestId: 'other' as PluginInstallRequestId, jobId: 'j1', argv: [], cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'x' })
+    expect(state().install.runs).toEqual([])
+    const argv = ['pnpm', 'add', 'dsh-new']
+    controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'Progress\n' })
+    // The Host's second pnpm run — removing a rejected package, under that
+    // package's name — is a run of its own, and a later chunk lands on the
+    // run it names.
+    controller.appendLog({ requestId, jobId: 'jr', argv: ['pnpm', 'remove', 'lib'], cwd: '/p', spec: 'lib', stream: 'stdout', text: '- lib\n', exitCode: 0 })
+    controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'Done\n' })
+    expect(state().install.runs).toEqual([
+      { jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\n' },
+      { jobId: 'jr', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
+    ])
+    face.toggleInstallDetails()
+    expect(state().install.detailsOpen).toBe(true)
+    gate.resolve(ok({
+      installed: ['dsh-new', 'dsh-tool-foo'], removed: [{ name: 'lib', reason: 'not a plugin' }],
+      enabled: [], installedOnly: ['dsh-new'], plain: ['dsh-tool-foo'], jobId: 'j1',
+    }))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    expect(state().install).toMatchObject({
+      installed: ['dsh-new', 'dsh-tool-foo'],
+      installedOnly: ['dsh-new'],
+      plain: ['dsh-tool-foo'],
+      removed: [{ name: 'lib', reason: 'not a plugin' }],
+      detailsOpen: true,
+    })
+    // The finished install settled its run; a trailing last chunk still lands
+    // on it, while a chunk for a run the dialog never saw is dropped.
+    controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'late', exitCode: 0 })
+    controller.appendLog({ requestId, jobId: 'j3', argv, cwd: '/p', spec: 'dsh-new', stream: 'stdout', text: 'stray' })
+    expect(state().install.runs).toEqual([
+      { jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\nlate', exitCode: 0 },
+      { jobId: 'jr', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
+    ])
+    await vi.waitFor(() => { expect(plugins.list).toHaveBeenCalledTimes(2) })
+    // Cancelling from the finished screen does nothing, nor does the Host's late progress; a new spec after it starts over.
+    face.cancelInstall()
+    controller.installProgress({ requestId, phase: 'applying' })
+    expect(state().install.phase).toBe('done')
+    face.editInstallSpec('another')
+    expect(state().install).toMatchObject({ phase: 'idle', spec: 'another', runs: [], installed: [], subject: null })
+    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(refused('plugins/inspect-rejected', 'not found', { spec: 'nope', problem: 'not-found', reason: 'E404' }))
+        .mockResolvedValueOnce(refused('plugins/inspect-rejected', 'odd', { spec: 'odd', problem: 'made-up', reason: 'strange' }))
+        .mockResolvedValueOnce(refused('plugins/unavailable', 'no profile', { reason: 'no profile runtime' })),
+    })
+    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.add).not.toHaveBeenCalled()
+    // A problem the dialog does not know reads as unknown; a refusal without a problem too.
+    face.editInstallSpec('odd')
+    face.runInstall()
+    await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'strange' }) })
+    face.editInstallSpec('x')
+    face.runInstall()
+    await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'no profile runtime' }) })
+  })
+
+  it('leaves the check or the failed screen for the spec at once', async () => {
+    const inspectGate = deferred<ReturnType<typeof ok<typeof INSPECTED>>>()
+    const { plugins, face, state, controller } = bench({
+      inspect: vi.fn().mockReturnValueOnce(inspectGate.promise).mockResolvedValue(ok(INSPECTED)),
+      add: vi.fn().mockResolvedValue(refused('plugins/install-failed', 'exit 1', { spec: 'dsh-x', exitCode: 1, log: 'ERR', kind: 'unknown' })),
+    })
+    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.add).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') })
+    face.cancelInstall()
+    expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'dsh-x', failure: null, subject: null })
+  })
+
+  it('asks the Host to stop a run, keeps the spec once it confirms, and forgets the stopped run', async () => {
     const first = deferred<ReturnType<typeof refused>>()
     const second = deferred<ReturnType<typeof ok<InstallValue>>>()
     const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
-    const { plugins, face, state, controller } = bench({
+    const { plugins, face, state, controller, started } = bench({
       add: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
       cancelInstall: vi.fn().mockReturnValueOnce(cancellation.promise),
     })
-    face.openInstall(); face.editInstallSpec('slow'); face.runInstall()
-    const requestId = state().install.requestId as NonNullable<InstallState['requestId']>
+    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()
+    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('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 = state().install.requestId
+    const nextId = await started()
     expect(nextId).not.toBe(requestId)
+    // The stopped run's answer, progress, and chunks belong to a request the dialog no longer has.
     first.resolve(refused('plugins/install-cancelled', 'cancelled', { requestId }))
-    await Promise.resolve(); await Promise.resolve()
+    await Promise.resolve()
+    await Promise.resolve()
     expect(state().install).toMatchObject({ requestId: nextId, phase: 'starting' })
     controller.installProgress({ requestId, phase: 'applying' })
     controller.appendLog({ requestId, jobId: 'old', argv: [], cwd: '/p', spec: 'slow', stream: 'stdout', text: 'late' })
-    expect(state().install.runs).toEqual([])
+    expect(state().install).toMatchObject({ phase: 'starting', runs: [] })
     second.resolve(ok({ installed: [], removed: [], enabled: [], installedOnly: [], plain: [], jobId: 'new' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
-    controller.installProgress({ requestId: nextId as typeof requestId, phase: 'installing' })
-    expect(state().install.phase).toBe('done')
   })
 
-  it.each(['too-late', 'not-running', 'offline'] as const)('keeps cancellation %s distinct from a stopped installation', async (status) => {
+  it.each(['too-late', 'not-running', 'offline'] as const)('keeps a stop the Host answered %s apart from a stopped run', async (status) => {
     const pending = deferred<ReturnType<typeof refused>>()
-    const { plugins, face, state, controller } = bench({
+    const { plugins, face, state, controller, started } = bench({
       add: 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 = state().install.requestId as NonNullable<InstallState['requestId']>
+    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(state().install.phase).not.toBe('cancelled')
     expect(plugins.cancelInstall).toHaveBeenCalledOnce()
+    // A stop the Host did not confirm says so over the running screen, with the transport's words when it has them.
+    expect(state().install.failure).toEqual(
+      status === 'too-late' ? null : { code: 'client/cancel-unconfirmed', reason: status === 'offline' ? 'offline' : '' },
+    )
+    // The Host's own word that it stopped the run still ends it.
     pending.resolve(refused('plugins/install-cancelled', 'cancelled', { requestId }))
-    await vi.waitFor(() => { expect(state().install.phase).toBe('cancelled') })
-    expect(state().install.failure).toBeNull()
+    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 cancellation response after the add settles or the page is disposed (%s)', async (dispose) => {
+  it.each([false, true])('drops a stop the Host confirms once the run settled, or after disposal (%s)', async (dispose) => {
     const answer = deferred<ReturnType<typeof ok<InstallValue>>>()
     const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
-    const { face, state, controller } = bench({
-      add: vi.fn().mockReturnValue(answer.promise), cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
+    const { face, state, controller, started } = bench({
+      add: vi.fn().mockReturnValue(answer.promise),
+      cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
     })
-    face.openInstall(); face.editInstallSpec('slow'); face.runInstall()
-    controller.installProgress({ requestId: state().install.requestId as NonNullable<InstallState['requestId']>, phase: 'installing' })
+    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({ installed: [], removed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j' }))
     if (!dispose) await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
-    const before = state()
     cancellation.resolve(ok({ status: 'cancelled' }))
-    await Promise.resolve(); await Promise.resolve()
-    expect(state()).toBe(before)
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(state().install.phase).toBe(dispose ? 'cancelling' : 'done')
+    expect(state().notice).toBeNull()
   })
 
-  it('runs an install, folds its own log chunks, and closes only once it settled', async () => {
-    const gate = deferred<ReturnType<typeof ok<InstallValue>>>()
-    const { plugins, face, state, controller } = bench({ add: vi.fn().mockReturnValueOnce(gate.promise) })
+  it('enables what a finished install added from its screen, closes, and marks the first in the list', async () => {
+    const { plugins, face, state, controller } = bench({
+      add: vi.fn().mockResolvedValue(ok({ installed: ['dsh-a', 'dsh-b', 'lib'], removed: [], enabled: [], installedOnly: ['dsh-a', 'dsh-b'], plain: ['lib'], jobId: 'j' })),
+      enable: vi.fn()
+        .mockResolvedValueOnce(ok({ changed: true, effect: 'live' }))
+        .mockResolvedValueOnce(ok({ changed: true, effect: 'restart' }))
+        .mockResolvedValueOnce(ok({ changed: true, effect: 'live' }))
+        .mockResolvedValueOnce(refused('plugins/enable-failed', 'plugin-manager: dsh-b rejected', { packageName: 'dsh-b', reason: 'the tree rejected it' })),
+    })
     await controller.load()
+    face.enableInstalled()
+    expect(plugins.enable).not.toHaveBeenCalled()
+    face.openInstall()
+    face.editInstallSpec('dsh-a')
     face.runInstall()
-    expect(plugins.add).not.toHaveBeenCalled()
+    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.enable.mock.calls).toEqual([['dsh-a'], ['dsh-b']])
+    // A restart one of them waits for is said in passing; the list marks the first.
+    expect(state().notice).toEqual({ kind: 'restart', packageName: 'dsh-b', seq: 1 })
+    expect(state().highlight).toBe('dsh-a')
+    face.clearHighlight()
+    face.clearHighlight()
+    expect(state().highlight).toBeNull()
+
+    // A refusal stops at the package it refused, toasts it, and still closes.
     face.openInstall()
-    expect(state().install).toMatchObject({ open: true, spec: '', enable: true, phase: 'idle' })
-    face.editInstallSpec('  dsh-better-sidebar ')
-    face.toggleInstallEnable()
+    face.editInstallSpec('dsh-a')
     face.runInstall()
+    await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    face.enableInstalled()
+    await vi.waitFor(() => { expect(state().install.open).toBe(false) })
+    expect(plugins.enable).toHaveBeenCalledTimes(4)
+    expect(state().notice).toEqual({ kind: 'failed', code: 'plugins/enable-failed', reason: 'the tree rejected it', packageName: 'dsh-b', seq: 2 })
+    expect(state().highlight).toBe('dsh-a')
+
+    // An install that added no pack has nothing to enable or mark: the screen just closes.
+    plugins.add.mockResolvedValueOnce(ok({ installed: ['lib'], removed: [], enabled: [], installedOnly: [], plain: ['lib'], jobId: 'j' }) as never)
+    face.openInstall()
+    face.editInstallSpec('lib')
     face.runInstall()
-    expect(plugins.add).toHaveBeenCalledTimes(1)
-    expect(plugins.add).toHaveBeenCalledWith('dsh-better-sidebar', { enable: false, requestId: state().install.requestId })
-    expect(state().install.phase).toBe('starting')
-    face.closeInstall()
-    expect(state().install.open).toBe(true)
-    const argv = ['pnpm', 'add', 'dsh-better-sidebar']
-    controller.appendLog({ ...installationRequest(controller), jobId: 'j1', argv, cwd: '/p', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'Progress\n' })
-    // The Host's second pnpm run — removing a rejected package, under that
-    // package's name — is a run of its own, and a later chunk lands on the
-    // run it names.
-    controller.appendLog({ ...installationRequest(controller), jobId: 'jr', argv: ['pnpm', 'remove', 'lib'], cwd: '/p', spec: 'lib', stream: 'stdout', text: '- lib\n', exitCode: 0 })
-    controller.appendLog({ ...installationRequest(controller), jobId: 'j1', argv, cwd: '/p', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'Done\n' })
-    expect(state().install.runs).toEqual([
-      { jobId: 'j1', command: 'pnpm add dsh-better-sidebar', cwd: '/p', output: 'Progress\nDone\n' },
-      { jobId: 'jr', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
-    ])
-    gate.resolve(ok({
-      installed: ['dsh-better-sidebar', 'dsh-tool-foo'], removed: [{ name: 'lib', reason: 'not a plugin' }],
-      enabled: [], installedOnly: ['dsh-better-sidebar'], plain: ['dsh-tool-foo'], jobId: 'j1',
-    }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
-    expect(state().install).toMatchObject({
-      installed: ['dsh-better-sidebar', 'dsh-tool-foo'],
-      enabled: [],
-      installedOnly: ['dsh-better-sidebar'],
-      plain: ['dsh-tool-foo'],
-      removed: [{ name: 'lib', reason: 'not a plugin' }],
+    face.enableInstalled()
+    await vi.waitFor(() => { expect(state().install.open).toBe(false) })
+    expect(plugins.enable).toHaveBeenCalledTimes(4)
+    expect(state().highlight).toBeNull()
+  })
+
+  it('drops an enable from the installed screen that settles after disposal', async () => {
+    const enableGate = deferred<ReturnType<typeof ok<{ changed: boolean; effect: 'live' }>>>()
+    const { plugins, face, state, controller } = bench({
+      add: vi.fn().mockResolvedValue(ok({ installed: ['dsh-a'], removed: [], enabled: [], installedOnly: ['dsh-a'], plain: [], jobId: 'j' })),
+      enable: vi.fn().mockReturnValueOnce(enableGate.promise),
     })
-    // 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({ ...installationRequest(controller), jobId: 'j1', argv, cwd: '/p', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'late', exitCode: 0 })
-    controller.appendLog({ ...installationRequest(controller), jobId: 'j3', argv, cwd: '/p', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'stray' })
-    expect(state().install.runs).toEqual([
-      { jobId: 'j1', command: 'pnpm add dsh-better-sidebar', cwd: '/p', output: 'Progress\nDone\nlate', exitCode: 0 },
-      { jobId: 'jr', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
-    ])
-    await vi.waitFor(() => { expect(plugins.list).toHaveBeenCalledTimes(2) })
-    // A new spec after the finished run starts over, keeping the enable choice.
-    face.editInstallSpec('another')
-    expect(state().install).toMatchObject({ phase: 'idle', spec: 'another', enable: false, runs: [], installed: [] })
-    face.closeInstall()
-    expect(state().install.open).toBe(false)
+    await controller.load()
+    face.openInstall()
+    face.editInstallSpec('dsh-a')
+    face.runInstall()
+    await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    face.enableInstalled()
+    expect(plugins.enable).toHaveBeenCalledWith('dsh-a')
+    const before = state()
+    controller.dispose()
+    enableGate.resolve(ok({ changed: true, effect: 'live' }))
+    await Promise.resolve()
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(state()).toBe(before)
   })
 
   it('settles an install and a confirmation while reads run beside them', async () => {
     const addGate = deferred<ReturnType<typeof ok<InstallValue>>>()
     const dependentsGate = deferred<ReturnType<typeof ok<{ services: never[]; references: never[] }>>>()
-    const { plugins, face, state, controller } = bench({
+    const { plugins, face, state, controller, started } = bench({
       add: vi.fn().mockReturnValueOnce(addGate.promise),
       dependents: vi.fn().mockReturnValueOnce(dependentsGate.promise),
     })
@@ -419,9 +600,10 @@ describe('PluginManagerController', () => {
     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()
-    addGate.resolve(ok({ installed: ['pkg'], removed: [], enabled: ['pkg'], installedOnly: [], plain: [], jobId: 'j' }))
+    addGate.resolve(ok({ installed: ['pkg'], removed: [], enabled: [], installedOnly: ['pkg'], plain: [], jobId: 'j' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
 
     face.uninstall(BUNDLE.name)
@@ -431,54 +613,70 @@ describe('PluginManagerController', () => {
     expect(plugins.list).toHaveBeenCalledTimes(4)
   })
 
-  it('keeps the Host reason of a failed install and settles a run whose last chunk never came', async () => {
-    const { face, state, controller, plugins } = bench({
-      add: vi.fn()
-        .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ERR_PNPM' }))
-        .mockResolvedValueOnce(refused('gateway/internal', 'offline'))
-        .mockResolvedValueOnce(refused('plugins/install-failed', 'killed', { spec: 'x', exitCode: null, log: 'tail' }))
-        .mockResolvedValueOnce(refused('plugins/enable-failed', 'plugin-manager: x rejected', { packageName: 'x', reason: 'the tree rejected it' }))
-        .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x' }))
-        .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 'one' })),
+  it('keeps the Host reason and kind of a failed install, settles a run whose last chunk never came, and toasts a refusal of the moment', async () => {
+    // Every run waits on its own gate, so chunks can land while it is installing.
+    const gates: ReturnType<typeof deferred<Awaited<ReturnType<typeof refused>>>>[] = []
+    const { face, state, controller, plugins, started } = bench({
+      add: vi.fn(() => {
+        const gate = deferred<Awaited<ReturnType<typeof refused>>>()
+        gates.push(gate)
+        return gate.promise
+      }),
     })
     const argv = ['pnpm', 'add', 'x']
     await controller.load()
     face.openInstall()
     face.editInstallSpec('x')
-    // No chunk arrived: the Host's captured tail is the reason, and there is no run.
-    face.runInstall()
+    let requestId = '' as PluginInstallRequestId
+    const installing = async (): Promise<void> => {
+      face.runInstall()
+      requestId = await started()
+    }
+    const answer = (value: ReturnType<typeof refused>): void => { gates[gates.length - 1]?.resolve(value) }
+    // No chunk arrived: the Host's captured tail is the reason, and there is no run; the kind is kept.
+    await installing()
+    answer(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ERR_PNPM', kind: 'network' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
     expect(state().install.runs).toEqual([])
-    expect(state().install.failure).toEqual({ code: 'plugins/install-failed', reason: 'ERR_PNPM' })
-    face.runInstall()
-    await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(2) })
+    expect(state().install.failure).toEqual({ code: 'plugins/install-failed', reason: 'ERR_PNPM', kind: 'network' })
+    expect(state().install.subject).toEqual({ spec: 'x', ...INSPECTED })
+    await installing()
+    answer(refused('gateway/internal', 'offline'))
     await vi.waitFor(() => { expect(state().install.failure).toEqual({ code: 'gateway/internal', reason: 'offline' }) })
-    // A pnpm failure settles the open run with the code the answer names — here none.
-    face.runInstall()
-    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stderr', text: 'streamed' })
-    await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(3) })
+    // A pnpm failure settles the open run with the code the answer names — here none; a kind the dialog does not know is dropped.
+    await installing()
+    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stderr', text: 'streamed' })
+    answer(refused('plugins/install-failed', 'killed', { spec: 'x', exitCode: null, log: 'tail', kind: 'made-up' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
     expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'streamed', exitCode: null }])
     expect(state().install.failure).toEqual({ code: 'plugins/install-failed', reason: 'tail' })
     // A refusal after pnpm means pnpm itself exited 0.
-    face.runInstall()
-    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'Done' })
-    await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(4) })
+    await installing()
+    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'Done' })
+    answer(refused('plugins/enable-failed', 'plugin-manager: x rejected', { packageName: 'x', reason: 'the tree rejected it' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
     expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'Done', exitCode: 0 }])
     expect(state().install.failure).toEqual({ code: 'plugins/enable-failed', reason: 'the tree rejected it' })
     // Details without an exit code settle the run as having none.
-    face.runInstall()
-    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'partial' })
-    await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(5) })
+    await installing()
+    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'partial' })
+    answer(refused('plugins/install-failed', 'exit 1', { spec: 'x' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
     expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'partial', exitCode: null }])
     // So does an exit code the answer types wrongly.
-    face.runInstall()
-    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'odd' })
-    await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(6) })
+    await installing()
+    controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'odd' })
+    answer(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 'one' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
     expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'odd', exitCode: null }])
+    // A running session refuses the moment, not the spec: a toast, and the spec is back to try again.
+    await installing()
+    answer(refused('plugins/agents-running', 'plugin-manager: add waits for 1 running session(s)', { operation: 'add', running: 1 }))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
+    expect(state().install).toMatchObject({ open: true, spec: 'x', subject: null, runs: [], failure: null })
+    expect(state().install.requestId).toBeUndefined()
+    expect(state().notice).toEqual({ kind: 'failed', code: 'plugins/agents-running', reason: 'plugin-manager: add waits for 1 running session(s)', seq: 1 })
+    expect(plugins.add).toHaveBeenCalledTimes(7)
     // Editing the spec after a failure starts over too.
     face.editInstallSpec('y')
     expect(state().install).toMatchObject({ phase: 'idle', spec: 'y', runs: [], failure: null })
@@ -487,7 +685,7 @@ describe('PluginManagerController', () => {
   it('drops every late settlement after disposal', async () => {
     const enableGate = deferred<ReturnType<typeof ok<{ changed: boolean; effect: 'live' }>>>()
     const installGate = deferred<ReturnType<typeof ok<InstallValue>>>()
-    const { face, state, controller } = bench({
+    const { face, state, controller, started } = bench({
       enable: vi.fn().mockReturnValueOnce(enableGate.promise),
       add: vi.fn().mockReturnValueOnce(installGate.promise),
     })
@@ -495,6 +693,7 @@ describe('PluginManagerController', () => {
     face.openInstall()
     face.editInstallSpec('x')
     face.runInstall()
+    await started()
     face.setEnabled(BUNDLE.name, true)
     const before = state()
     controller.dispose()
@@ -536,16 +735,18 @@ describe('PluginManagerController', () => {
   })
 
   it('leaves a run its own exit code when its last chunk beat the answer', async () => {
-    const { face, state, controller } = bench({
-      add: vi.fn().mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'same tail' })),
-    })
+    const gate = deferred<ReturnType<typeof refused>>()
+    const { face, state, controller, started } = bench({ add: vi.fn().mockReturnValueOnce(gate.promise) })
     await controller.load()
     face.openInstall()
     face.editInstallSpec('x')
     face.runInstall()
-    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv: ['pnpm', 'add', 'x'], cwd: '/p', spec: 'x', stream: 'stderr', text: 'same tail' })
-    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv: ['pnpm', 'add', 'x'], cwd: '/p', spec: 'x', stream: 'stdout', text: '', exitCode: 1 })
+    const requestId = await started()
+    controller.appendLog({ requestId, jobId: 'j', argv: ['pnpm', 'add', 'x'], cwd: '/p', spec: 'x', stream: 'stderr', text: 'same tail' })
+    controller.appendLog({ requestId, jobId: 'j', argv: ['pnpm', 'add', 'x'], cwd: '/p', spec: 'x', stream: 'stdout', text: '', exitCode: 1 })
+    gate.resolve(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'same tail', kind: 'unknown' }))
     await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
     expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'same tail', exitCode: 1 }])
+    expect(state().install.failure).toEqual({ code: 'plugins/install-failed', reason: 'same tail', kind: 'unknown' })
   })
 })

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

@@ -1408,6 +1408,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [],
         returns: 'the views, bundles first in layer order.',
       },
+      {
+        signature: '@Remote(\'inspect\') async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection>',
+        description: 'Read what a spec names before installing it: its form, the package\'s name, version, description, and title where they are known ahead of the install, and whether it declares a bundle.',
+        parameters: [{ name: 'spec', description: 'what would be installed, in pnpm\'s own vocabulary.' }, { name: 'signal', description: 'cancels the registry lookup.' }],
+        returns: 'the inspection.',
+      },
       {
         signature: '@Remote(\'add\') async add(spec: string, options?: PluginInstallOptions): Promise<PluginInstallResult>',
         description: 'Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.',
@@ -4657,6 +4663,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'InspectorJsonValue',
     declaration: 'export type InspectorJsonValue = InspectorJsonPrimitive | readonly InspectorJsonValue[] | InspectorJsonObject;',
   },
+  {
+    name: 'InstallSpecKind',
+    declaration: 'export type InstallSpecKind = \'registry\' | \'path\' | \'git\' | \'tarball\';',
+  },
   {
     name: 'InvariantFailure',
     declaration: 'export type InvariantFailure = (message: string) => never;',
@@ -5093,6 +5103,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PluginServiceDependent',
     declaration: 'export interface PluginServiceDependent {\n    readonly service: string;\n    readonly providedBy: string;\n    readonly injectedBy: readonly string[];\n}',
   },
+  {
+    name: 'PluginSpecInspection',
+    declaration: 'export interface PluginSpecInspection {\n    readonly kind: InstallSpecKind;\n    readonly name?: string;\n    readonly version?: string;\n    readonly description?: string;\n    readonly title?: string;\n    readonly bundle: boolean | null;\n}',
+  },
   {
     name: 'PostToolDecision',
     declaration: 'export type PostToolDecision = {\n    kind: \'accept\';\n    content?: ContentBlock[];\n    value?: never;\n    additionalContexts?: UserMessage[];\n} | {\n    kind: \'accept\';\n    value: JsonValue;\n    content?: never;\n    additionalContexts?: UserMessage[];\n} | {\n    kind: \'block\';\n    feedback: ContentBlock[];\n    additionalContexts?: UserMessage[];\n};',

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/plugin-manager/README.md
-README.md: c60c326c223af4e9ef2d2038e373f5df62167101
-README.zh.md: 8cdb2c4f9bd983021459958f3436fd81358d6f39
+README.md: 30563409009e14b21e7519a57305652953b2dbd6
+README.zh.md: 7d6e1816969413fcba66513a265bb0de9b796418

+ 3 - 2
packages/host/plugin-manager/README.md

@@ -29,11 +29,11 @@ Mount the row in a host composition beside the plugin inventory; the web bundle
 
 ### The Remote
 
-`plugins/list`, `plugins/add`, `plugins/uninstall`, `plugins/enable`, `plugins/disable`, `plugins/retry`, `plugins/setRowDisabled`, and `plugins/dependents` carry the arguments and answers the manager's methods of the same names define; the [manager README](../../boot/plugin-manager/README.md#use-this-package) documents each. The `./types` export re-exports the manager's payload types unchanged, so a client imports one vocabulary.
+`plugins/list`, `plugins/inspect`, `plugins/add`, `plugins/uninstall`, `plugins/enable`, `plugins/disable`, `plugins/retry`, `plugins/setRowDisabled`, and `plugins/dependents` carry the arguments and answers the manager's methods of the same names define; the [manager README](../../boot/plugin-manager/README.md#use-this-package) documents each. `plugins/inspect` takes a trailing `AbortSignal`: a client that aborts it, or disconnects, ends the registry lookup. The `./types` export re-exports the manager's payload types unchanged, so a client imports one vocabulary.
 
 ### Failure codes
 
-A manager failure reaches the client as a `RemoteError` with the same `code` and `details`: `plugins/unavailable`, `plugins/not-installed`, `plugins/not-enableable`, `plugins/enable-failed`, `plugins/install-failed`, `plugins/install-cancelled`, `plugins/busy`, and `plugins/agents-running`, each declared in the Remote failure map with the manager's details type. The manager's generic refusal, `plugins/bad-request`, crosses as the Gateway's `gateway/bad-request`. Any other error the manager throws propagates untouched, which the Gateway reports as `gateway/internal`.
+A manager failure reaches the client as a `RemoteError` with the same `code` and `details`: `plugins/unavailable`, `plugins/not-installed`, `plugins/not-enableable`, `plugins/enable-failed`, `plugins/install-failed`, `plugins/install-cancelled`, `plugins/inspect-rejected`, `plugins/busy`, and `plugins/agents-running`, each declared in the Remote failure map with the manager's details type. The manager's generic refusal, `plugins/bad-request`, crosses as the Gateway's `gateway/bad-request`. Any other error the manager throws propagates untouched, which the Gateway reports as `gateway/internal`.
 
 ### Configuration
 
@@ -43,6 +43,7 @@ A manager failure reaches the client as a `RemoteError` with the same `code` and
 | `installTimeoutMs` | `600000` | Bound on one install or remove run. |
 | `installKillGraceMs` | `5000` | Grace before forced termination. |
 | `installLogTailBytes` | `16384` | How much trailing output an install failure reports. |
+| `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs. |
 
 -----
 

+ 3 - 2
packages/host/plugin-manager/README.zh.md

@@ -29,11 +29,11 @@ kind: "package-reference"
 
 ### Remote
 
-`plugins/list`、`plugins/add`、`plugins/uninstall`、`plugins/enable`、`plugins/disable`、`plugins/retry`、`plugins/setRowDisabled` 与 `plugins/dependents` 携带管理器同名方法定义的参数与答复;[管理器 README](../../boot/plugin-manager/README.zh.md#use-this-package) 逐一说明。`./types` 导出原样 re-export 管理器的载荷类型,客户端只需导入一套词汇。
+`plugins/list`、`plugins/inspect`、`plugins/add`、`plugins/uninstall`、`plugins/enable`、`plugins/disable`、`plugins/retry`、`plugins/setRowDisabled` 与 `plugins/dependents` 携带管理器同名方法定义的参数与答复;[管理器 README](../../boot/plugin-manager/README.zh.md#use-this-package) 逐一说明。`plugins/inspect` 接受末尾的 `AbortSignal`:客户端中止它或断开连接,注册表查询即结束。`./types` 导出原样 re-export 管理器的载荷类型,客户端只需导入一套词汇。
 
 ### 失败码
 
-管理器的失败以同样的 `code` 与 `details` 作为 `RemoteError` 到达客户端:`plugins/unavailable`、`plugins/not-installed`、`plugins/not-enableable`、`plugins/enable-failed`、`plugins/install-failed`、`plugins/install-cancelled`、`plugins/busy` 与 `plugins/agents-running`,各自以管理器的 details 类型声明在 Remote 失败表里。管理器的通用拒绝 `plugins/bad-request` 以 Gateway 的 `gateway/bad-request` 过线。管理器抛出的其他错误原样传播,由 Gateway 报为 `gateway/internal`。
+管理器的失败以同样的 `code` 与 `details` 作为 `RemoteError` 到达客户端:`plugins/unavailable`、`plugins/not-installed`、`plugins/not-enableable`、`plugins/enable-failed`、`plugins/install-failed`、`plugins/install-cancelled`、`plugins/inspect-rejected`、`plugins/busy` 与 `plugins/agents-running`,各自以管理器的 details 类型声明在 Remote 失败表里。管理器的通用拒绝 `plugins/bad-request` 以 Gateway 的 `gateway/bad-request` 过线。管理器抛出的其他错误原样传播,由 Gateway 报为 `gateway/internal`。
 
 ### 配置
 
@@ -43,6 +43,7 @@ kind: "package-reference"
 | `installTimeoutMs` | `600000` | 单次安装或移除运行的上限。 |
 | `installKillGraceMs` | `5000` | 强制终止前的宽限期。 |
 | `installLogTailBytes` | `16384` | 安装失败时报告多少尾部输出。 |
+| `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限。 |
 
 -----
 

+ 17 - 0
packages/host/plugin-manager/src/index.ts

@@ -23,6 +23,7 @@ import {
   type PluginInstallCancellation,
   type PluginOperationFailure,
   type PluginPackageView,
+  type PluginSpecInspection,
   type SpawnLike,
 } from '@deepseek-ai/dsh-plugin-manager'
 import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
@@ -48,6 +49,8 @@ export interface Config {
   installKillGraceMs: number
   /** How many trailing bytes of an install run's output an install failure reports. */
   installLogTailBytes: number
+  /** Bound on one registry lookup an inspection runs, in milliseconds. */
+  inspectTimeoutMs: number
 }
 
 /** Test seams: the child spawner, the static metadata reader, or a manager standing in for the shared one. */
@@ -74,6 +77,7 @@ export class PluginManagerRemote extends TypertRemoteService {
     installTimeoutMs: z.number().min(1_000).default(600_000),
     installKillGraceMs: z.number().min(1).default(5_000),
     installLogTailBytes: z.number().min(256).default(16_384),
+    inspectTimeoutMs: z.number().min(1_000).default(20_000),
   })
 
   private readonly manager: PluginManager
@@ -135,6 +139,18 @@ export class PluginManagerRemote extends TypertRemoteService {
     return relay(() => this.manager.list())
   }
 
+  /**
+   * Read what a spec names before installing it: its form, the package's name, version,
+   * description, and title where they are known ahead of the install, and whether it declares a bundle.
+   * @param spec - what would be installed, in pnpm's own vocabulary.
+   * @param signal - cancels the registry lookup.
+   * @returns the inspection.
+   */
+  @Remote('inspect')
+  async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection> {
+    return relay(() => this.manager.inspect(spec, signal))
+  }
+
   /**
    * Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.
    * @param spec - what to install, in pnpm's own vocabulary.
@@ -254,6 +270,7 @@ export function remoteErrorOf(failure: PluginOperationFailure): RemoteError {
     case 'plugins/enable-failed': return new RemoteError(failure.code, failure.message, failure.details, options)
     case 'plugins/install-cancelled': return new RemoteError(failure.code, failure.message, failure.details, options)
     case 'plugins/install-failed': return new RemoteError(failure.code, failure.message, failure.details, options)
+    case 'plugins/inspect-rejected': return new RemoteError(failure.code, failure.message, failure.details, options)
     case 'plugins/busy': return new RemoteError(failure.code, failure.message, failure.details, options)
     case 'plugins/agents-running': return new RemoteError(failure.code, failure.message, failure.details, options)
     case 'plugins/bad-request': return new RemoteError('gateway/bad-request', failure.message, {}, options)

+ 3 - 1
packages/host/plugin-manager/src/types.ts

@@ -20,8 +20,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
     'plugins/not-enableable': PluginOperationDetailsMap['plugins/not-enableable']
     /** Preparation or the root Include rejected enablement; the layer selection was reverted. */
     'plugins/enable-failed': PluginOperationDetailsMap['plugins/enable-failed']
-    /** pnpm exited non-zero, could not be spawned, or timed out. */
+    /** pnpm exited non-zero, could not be spawned, or timed out; `kind` classifies the failure. */
     'plugins/install-failed': PluginOperationDetailsMap['plugins/install-failed']
+    /** The spec cannot be installed as given; `problem` says why. */
+    'plugins/inspect-rejected': PluginOperationDetailsMap['plugins/inspect-rejected']
     /** The installation stopped and its manifest and lockfile were restored. */
     'plugins/install-cancelled': PluginOperationDetailsMap['plugins/install-cancelled']
     /** Another mutation is still running; the manager runs one at a time and refuses rather than queues. */

+ 7 - 3
packages/host/plugin-manager/tests/plugin-manager.spec.ts

@@ -21,7 +21,7 @@ import PluginManagerRemote, { remoteErrorOf, type Config } from '@deepseek-ai/ds
 import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
 
 /** A complete config: the schema fills defaults at load, the type does not. */
-const CONFIG: Config = { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installKillGraceMs: 50, installLogTailBytes: 16_384 }
+const CONFIG: Config = { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installKillGraceMs: 50, installLogTailBytes: 16_384, inspectTimeoutMs: 1_000 }
 
 const contexts: Context[] = []
 afterEach(async () => {
@@ -249,7 +249,7 @@ describe('PluginManagerRemote', () => {
     const remote = await mount()
     expect(remote.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
     expect(remoteMethods(remote).map(marker => marker.method)).toEqual([
-      'list', 'add', 'cancelInstall', 'uninstall', 'enable', 'disable', 'retry', 'setRowDisabled', 'dependents',
+      'list', 'inspect', 'add', 'cancelInstall', 'uninstall', 'enable', 'disable', 'retry', 'setRowDisabled', 'dependents',
     ])
   })
 
@@ -275,7 +275,9 @@ describe('PluginManagerRemote', () => {
     }) as PluginManager
     const remote = await mount(stub)
 
+    const signal = AbortSignal.abort()
     await expect(remote.list()).resolves.toEqual({ method: 'list' })
+    await remote.inspect('spec', signal)
     await remote.add('spec', { enable: true })
     await remote.cancelInstall('f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId)
     await remote.uninstall('pkg')
@@ -287,6 +289,7 @@ describe('PluginManagerRemote', () => {
 
     expect(calls).toEqual([
       ['list'],
+      ['inspect', 'spec', signal],
       ['add', 'spec', { enable: true }],
       ['cancelInstall', 'f2340b6d-40bb-46b7-8b94-217bdf5010bd'],
       ['uninstall', 'pkg'],
@@ -357,7 +360,8 @@ describe('remoteErrorOf', () => {
       new PluginOperationError('plugins/not-enableable', 'm', { packageName: 'p', reason: 'r' }),
       new PluginOperationError('plugins/enable-failed', 'm', { packageName: 'p', reason: 'r' }),
       new PluginOperationError('plugins/install-cancelled', 'm', { requestId: 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId }),
-      new PluginOperationError('plugins/install-failed', 'm', { spec: 's', exitCode: 1, log: 'l' }),
+      new PluginOperationError('plugins/install-failed', 'm', { spec: 's', exitCode: 1, log: 'l', kind: 'unknown' }),
+      new PluginOperationError('plugins/inspect-rejected', 'm', { spec: 's', problem: 'not-found', reason: 'r' }),
       new PluginOperationError('plugins/busy', 'm', { operation: 'add', subject: 'y', active: { operation: 'add', subject: 'x' } }),
       new PluginOperationError('plugins/agents-running', 'm', { operation: 'add', running: 1 }),
     ]

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

@@ -729,6 +729,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   PluginInstallOptions: 'plugin manager installation control is owned by packages/host/plugin-manager/README.md',
   PluginInstallProgress: 'plugin manager installation control is owned by packages/host/plugin-manager/README.md',
   PluginInstallCancellation: 'plugin manager installation control is owned by packages/host/plugin-manager/README.md',
+  PluginSpecInspection: 'plugin manager spec inspections are owned by packages/host/plugin-manager/README.md',
   PluginInstallLogChunk: 'plugin manager install log chunks are owned by packages/host/plugin-manager/README.md',
   PluginPackageView: 'plugin manager package views are owned by packages/host/plugin-manager/README.md',
   PluginInstallResult: 'plugin manager install results are owned by packages/host/plugin-manager/README.md',