Parcourir la source

feat(web): cancel plugin installation with Host confirmation

Yichen Jiang il y a 3 jours
Parent
commit
bdc496cc06

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
-2026-09-09-plugin-management-in-the-web-sidebar.md: 229cde95a133d7ae96fa57eb3b071fa9eb887d59
-2026-09-09-plugin-management-in-the-web-sidebar.zh.md: 1238179a15d64806c50eb5a634a8c85e1d9902d4
+2026-09-09-plugin-management-in-the-web-sidebar.md: 562fafcb9307f1ff61b361c94fdde79cbf5175dc
+2026-09-09-plugin-management-in-the-web-sidebar.zh.md: 58515c1f0d25a0ffb7d902abcccc8da46d3b281d

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

@@ -14,6 +14,8 @@ Installed packages belong to the running profile, while Settings is a modal over
 
 **One store follows Host state.** The manager controller reads package views, refreshes after management operations and reconnects, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings.
 
+**Installation results belong to a request.** The dialog generates a fresh request id for every install or retry and filters Host progress, logs, and responses by that id. A cancellation acknowledgement can arrive before the original add response, so that response cannot settle a subsequent retry. Cancellation uses the manager's explicit cleanup acknowledgement; local RPC cancellation and connection loss never imply that pnpm has stopped. The application phase closes the cancellation window.
+
 ## Alternatives considered
 
 **A settings section that opens the management page.** Rejected: the dialog covers the main column, so such an entry would have to close Settings to show the page.

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

@@ -14,6 +14,8 @@ Status: implemented
 
 **一个 store 跟随 Host 状态。** 管理器控制器读取包视图,在管理操作和重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。
 
+**安装结果属于一次请求。** 每次安装或重试都生成新的请求 ID,对话框按该 ID 筛选 Host 进度、日志和返回结果。取消确认可能先于原 add 响应到达,因此旧响应不能结束随后的重试。取消采用管理器的明确清理确认;本地 RPC 取消和断线均不表示 pnpm 已停止。进入配置应用阶段后关闭取消窗口。
+
 ## 考虑过的替代方案
 
 **用一个设置分区打开管理页。** 否决:对话框盖住主列,这样的入口必须先关掉设置才能显示页面。

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

@@ -0,0 +1,19 @@
+- dialog "添加插件":
+  - heading "添加插件" [level=2]
+  - button "关闭":
+    - img
+  - paragraph: 输入插件的包名、本地路径或 Git 地址。
+  - text: 包名或地址
+  - textbox "包名或地址":
+    - /placeholder: dsh-better-sidebar 或 /path/to/plugin
+    - 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 "重试"

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

@@ -0,0 +1,66 @@
+// A real Host/Remote/browser composition with a controllable package-manager process.
+// No model call is needed: installation state and profile files are the observable result.
+import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright'
+import { expect, it } from 'vitest'
+import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
+import { ZH_BROWSER_LOCALE } from './support.ts'
+
+it('cancels installation through the UI, restores files, and accepts a retry', 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`)
+  let scaffold: WebScaffold | undefined
+  try {
+    scaffold = await launchWebScaffold({ profileRuntime: { packages: [] }, extraOverlayPath: overlay })
+    const browser = await chromium.launch()
+    try {
+      const profile = join(scaffold.harnessHome, 'profiles', 'scaffold')
+      const manifestPath = join(profile, 'package.json')
+      const manifest = await readFile(manifestPath, 'utf8')
+      const lockPath = join(profile, 'pnpm-lock.yaml')
+      await writeFile(lockPath, 'original lockfile\n')
+      // Node stands in for the pnpm executable; the same installer owns and stops this real child.
+      await writeFile(join(profile, 'add'), `
+        import('node:fs').then(fs => {
+        fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { partial: '1.0.0' } }));
+        fs.writeFileSync('pnpm-lock.yaml', 'partial lockfile');
+        console.log('Waiting for package download');
+        setInterval(() => {}, 1000);
+        });
+      `)
+      const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+      const tripwire = watchConsole(page)
+      await page.goto(scaffold.authenticatedUrl)
+      await page.waitForSelector('[class*="frame"]')
+      if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape')
+      await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+      const panel = page.locator('[data-plugin-panel]')
+      await panel.getByRole('button', { name: '添加插件', exact: true }).click()
+      const dialog = page.getByRole('dialog', { name: '添加插件' })
+      await dialog.getByRole('textbox').fill('slow-package')
+      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()
+      expect(await readFile(manifestPath, 'utf8')).toBe(manifest)
+      expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n')
+      expect(await dialog.getByRole('textbox').inputValue()).toBe('slow-package')
+      const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+        .split(process.execPath).join('{{node}}')
+        .split(scaffold.harnessHome).join('{{harnessHome}}')
+      await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/cancelled.expected.md', import.meta.url)), snapshot, webSnapshotMode())
+      await writeFile(join(profile, 'add'), 'console.log("Retry completed")\n')
+      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 {
+    await scaffold?.close()
+    await rm(scratch, { recursive: true, force: true })
+  }
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -47,6 +47,7 @@
     "tests/document-preview.e2e.ts",
     "tests/plugin-config.e2e.ts",
     "tests/plugin-manager.e2e.ts",
+    "tests/plugin-install-cancel.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",
     "tests/deepseek-messages-settings.e2e.ts",

+ 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: 206b014932c72674891f21a0336beda7f5c00b34
-README.zh.md: 584beace4ab880c480415bfe111eb57e5afd37da
+README.md: bebcef310371aff791960c9cf2415d140655294f
+README.zh.md: 315eca1bbf5e0924ff9a7510cc48405124d08456

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

@@ -45,6 +45,8 @@ Non-bundle dependencies remain installed and appear under **Other installed pack
 
 -----
 
+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.
+
 <a id="understand-the-implementation"></a>
 ## Understand the implementation
 

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

@@ -45,6 +45,8 @@ kind: "package-reference"
 
 -----
 
+安装期间可点击**取消安装**,页面显示**正在停止安装**,直到 Host 确认清理完成。配置应用阶段不可取消。取消后的对话框保留包名和日志,并提供重试;下载或解包文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功。
+
 <a id="understand-the-implementation"></a>
 ## 理解实现
 

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

@@ -60,7 +60,8 @@
     "@types/react": "~18.3.1",
     "clsx": "^2.1.1",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "@deepseek-ai/dsh-util-crypto": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 19 - 12
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -5,6 +5,7 @@
  * 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 {
@@ -433,15 +434,16 @@ function failureText(install: InstallState, t: Translate): string {
 }
 
 /** 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 }: {
+function InstallDialog({ install, t, onClose, onEditSpec, onToggleEnable, onRun, onCancel }: {
   readonly install: InstallState
   readonly t: Translate
   readonly onClose: () => void
   readonly onEditSpec: (text: string) => void
   readonly onToggleEnable: () => void
   readonly onRun: () => void
+  readonly onCancel: () => void
 }): ReactNode {
-  const running = install.phase === 'running'
+  const running = isInstallPending(install.phase)
   const exampleId = useId()
   const firstRun = install.runs[0]
   const outcomes: string[] = install.phase !== 'done'
@@ -464,14 +466,16 @@ function InstallDialog({ install, t, onClose, onEditSpec, onToggleEnable, onRun
       className={css.installDialog as string}
       footer={install.phase === 'done'
         ? <Button variant="primary" onClick={onClose}>{t('installClose')}</Button>
-        : (
-          <>
-            <Button variant="outline" disabled={running} onClick={onClose}>{t(install.phase === 'idle' ? 'cancel' : 'installClose')}</Button>
-            <Button variant="primary" disabled={running || install.spec.trim() === ''} onClick={onRun}>
-              {t(install.phase === 'failed' ? 'installRetry' : 'installRun')}
-            </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}>
@@ -491,12 +495,14 @@ function InstallDialog({ install, t, onClose, onEditSpec, onToggleEnable, onRun
           <span>{t('installEnable')}</span>
         </label>
         {running
-          ? <p className={css.progress} role="status"><span className={css.spinner} aria-hidden="true" />{t('installRunning', { spec: install.spec.trim() })}</p>
+          ? <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>
           : null}
         {outcomes.map(line => <p key={line} className={css.result} role="status">{line}</p>)}
         {install.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>
           : null}
@@ -511,7 +517,7 @@ function InstallDialog({ install, t, onClose, onEditSpec, onToggleEnable, onRun
             running={run.exitCode === undefined}
             exitCode={run.exitCode}
             maxLines={INSTALL_TERMINAL_LINES}
-            labels={terminalLabels(t)}
+            labels={{ ...terminalLabels(t), ...(install.phase === 'cancelled' || install.phase === 'cancelling' ? { failed: t('installCancelledShort') } : {}) }}
             className={css.terminal}
           />
         ))}
@@ -680,6 +686,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
         onEditSpec={props.editInstallSpec}
         onToggleEnable={props.toggleInstallEnable}
         onRun={props.runInstall}
+        onCancel={props.cancelInstall}
       />
       {state.confirm === null
         ? null

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

@@ -66,6 +66,7 @@ export function apply(ctx: ClientContext): void {
     const disposers = [
       ctx.remote.$on('plugins/changed', refresh),
       ctx.remote.$on('plugins/install-log', (chunk) => { controller.appendLog(chunk) }),
+      ctx.remote.$on('plugins/install-state', (progress) => { controller.installProgress(progress) }),
       ctx.on('connection/reset', refresh),
     ]
     return () => { for (const dispose of disposers) dispose() }

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

@@ -63,6 +63,13 @@ export const zh = {
   installExample: '例如 dsh-better-sidebar、github:someone/dsh-plugin、/path/to/plugin',
   installEnable: '安装完成后直接启用',
   installRun: '安装',
+  installCancel: '取消安装',
+  installStarting: '正在准备安装…',
+  installCancelling: '正在停止安装…',
+  installApplying: '正在应用配置,请稍候…',
+  installCancelledShort: '已取消',
+  installCancelled: '已取消安装,本次未继续启用插件。下载缓存或已解包文件可能保留,需要时可重新安装。',
+  installCancelUnconfirmed: '尚未确认安装已停止,请重试取消或等待安装结果。{reason}',
   installRunning: '正在安装 {spec}…',
   installFailedTail: '安装失败:{reason}',
   installLocation: '安装位置:{dir}',
@@ -214,6 +221,13 @@ export const en = {
   installExample: 'For example dsh-better-sidebar, github:someone/dsh-plugin, /path/to/plugin',
   installEnable: 'Enable right after installing',
   installRun: 'Install',
+  installCancel: 'Cancel installation',
+  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}',
   installLocation: 'Installs into {dir}',

+ 69 - 16
packages/client/ui-plugin-manager/src/client/manager-store.ts

@@ -6,6 +6,7 @@
  * change made on another surface shows here without a manual refresh.
  */
 
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {
   PluginDependents,
@@ -13,6 +14,8 @@ import type {
   PluginInstallLogChunk,
   PluginInstallRejection,
   PluginInstallResult,
+  PluginInstallRequestId,
+  PluginInstallProgress,
   PluginPackageView,
 } from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -50,7 +53,9 @@ export interface InstallState {
   readonly spec: string
   /** Whether a newly installed bundle is enabled right away. */
   readonly enable: boolean
-  readonly phase: 'idle' | 'running' | 'done' | 'failed'
+  readonly phase: 'idle' | 'starting' | 'running' | 'cancelling' | 'applying' | 'cancelled' | 'done' | 'failed'
+  /** Identifies this dialog's installation, including log and cancellation messages. */
+  readonly requestId?: PluginInstallRequestId
   /** The pnpm runs of the open install, in the order they started. */
   readonly runs: readonly InstallRun[]
   /** Dependencies the last run added and kept, once it finished. */
@@ -67,6 +72,15 @@ export interface InstallState {
   readonly failure: { readonly code: string; readonly reason: string } | null
 }
 
+/**
+ * Whether an installation is still owned by the Host.
+ * @param phase - the dialog's current installation phase.
+ * @returns true until an authoritative result settles the installation.
+ */
+export function isInstallPending(phase: InstallState['phase']): boolean {
+  return phase === 'starting' || phase === 'running' || phase === 'cancelling' || phase === 'applying'
+}
+
 /** A destructive action waiting for the user's confirmation: a package's uninstall or disable, or one row's switch-off. */
 export type ConfirmState =
   | {
@@ -111,6 +125,8 @@ export interface PluginManagerFace {
   editInstallSpec: (text: string) => void
   toggleInstallEnable: () => void
   runInstall: () => void
+  /** Request cancellation and wait for Host cleanup. */
+  cancelInstall: () => 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. */
@@ -217,20 +233,23 @@ export class PluginManagerController {
       hooks: { pluginManager: this.store },
       ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
       refresh: () => { void this.load() },
-      openInstall: () => { this.patch({ install: { ...IDLE_INSTALL, open: true } }) },
+      openInstall: () => {
+        if (!isInstallPending(this.getSnapshot().install.phase)) this.patch({ install: { ...IDLE_INSTALL, open: true } })
+      },
       closeInstall: () => {
-        if (this.getSnapshot().install.phase === 'running') return
+        if (isInstallPending(this.getSnapshot().install.phase)) return
         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'
+        this.patchInstall(install.phase === 'done' || install.phase === 'failed' || install.phase === 'cancelled'
           ? { ...IDLE_INSTALL, open: true, enable: install.enable, spec: text }
           : { spec: text })
       },
       toggleInstallEnable: () => { this.patchInstall({ enable: !this.getSnapshot().install.enable }) },
       runInstall: () => { void this.runInstall() },
+      cancelInstall: () => { void this.cancelInstall() },
       setEnabled: (packageName, enabled) => { void this.setEnabled(packageName, enabled) },
       retry: (packageName) => {
         void this.run(packageName, { packageName }, async () => {
@@ -251,18 +270,27 @@ export class PluginManagerController {
   }
 
   /**
-   * Fold one install-log chunk into its pnpm run. The Host runs one mutation
-   * at a time, so while this dialog's install is in flight every chunk is its
-   * own: the `add` run, then the removals that follow it under the removed
-   * packages' names. A run's last chunk may trail the answer that settled the
-   * dialog and still lands on its run; a chunk for a run the dialog has not
-   * seen counts only while the install is in flight.
+   * Follow the Host's cancellation window for this dialog's installation.
+   * @param progress - a request id and phase received from the Host.
+   */
+  installProgress(progress: PluginInstallProgress): void {
+    const install = this.getSnapshot().install
+    if (install.requestId !== progress.requestId || !isInstallPending(install.phase)) return
+    // A queued start notification cannot undo the local user's cancellation request.
+    if (install.phase === 'cancelling' && progress.phase === 'installing') return
+    this.patchInstall({ phase: progress.phase === 'installing' ? 'running' : progress.phase })
+  }
+
+  /**
+   * Fold a chunk belonging to this installation into its pnpm command.
+   * A final chunk may arrive after the add response and still updates an existing run.
    * @param chunk - the chunk the Host forwarded.
    */
   appendLog(chunk: PluginInstallLogChunk): void {
     const install = this.getSnapshot().install
+    if (chunk.requestId !== install.requestId) return
     const index = install.runs.findIndex(run => run.jobId === chunk.jobId)
-    if (index === -1 && install.phase !== 'running') return
+    if (index === -1 && !isInstallPending(install.phase)) return
     const settled = chunk.exitCode === undefined ? {} : { exitCode: chunk.exitCode }
     const runs = index === -1
       ? [...install.runs, { jobId: chunk.jobId, command: chunk.argv.join(' '), cwd: chunk.cwd, output: chunk.text, ...settled }]
@@ -410,20 +438,23 @@ export class PluginManagerController {
   private async runInstall(): Promise<void> {
     const install = this.getSnapshot().install
     const spec = install.spec.trim()
-    if (install.phase === 'running' || spec === '') return
-    this.patchInstall({ phase: 'running', runs: [], installed: [], enabled: [], installedOnly: [], plain: [], removed: [], failure: null })
+    if (isInstallPending(install.phase) || spec === '') return
+    const requestId = randomUUID() as PluginInstallRequestId
+    this.patchInstall({ phase: 'starting', requestId, runs: [], installed: [], enabled: [], installedOnly: [], plain: [], removed: [], failure: null })
     // 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 })
-    if (this.disposed) return
+    const result = await this.ctx.remote.plugins.add(spec, { enable: install.enable, 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 probe that refused it — both mean every run exited 0, and a
+    // 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.
     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 })
     } else {
       const reason = detailOf(result.error, 'reason') ?? detailOf(result.error, 'log') ?? result.error.message
       const exitCode = result.error.code === 'plugins/install-failed' ? exitCodeOf(result.error) ?? null : 0
@@ -432,6 +463,28 @@ export class PluginManagerController {
     void this.load()
   }
 
+  private async cancelInstall(): Promise<void> {
+    const install = this.getSnapshot().install
+    if (install.phase !== 'running' || install.requestId === undefined) return
+    const requestId = install.requestId
+    this.patchInstall({ phase: 'cancelling', failure: null })
+    const result = await this.ctx.remote.plugins.cancelInstall(requestId)
+    const current = this.getSnapshot().install
+    if (this.disposed || current.requestId !== requestId || !isInstallPending(current.phase)) return
+    if (!result.ok) {
+      this.patchInstall({ phase: 'running', failure: { code: 'client/cancel-unconfirmed', reason: result.error.message } })
+      return
+    }
+    if (result.value.status === 'cancelled') {
+      this.patchInstall({ phase: 'cancelled', runs: settledRuns(current.runs, null), failure: null })
+      void this.load()
+    } else if (result.value.status === 'too-late') {
+      this.patchInstall({ phase: 'applying' })
+    } else {
+      this.patchInstall({ phase: 'running', failure: { code: 'client/cancel-unconfirmed', reason: '' } })
+    }
+  }
+
   /**
    * Run one action under a busy key, turn its failure into the notice, and
    * re-read the Host afterwards whatever happened.

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

@@ -90,6 +90,7 @@ describe('ui-plugin-manager browser plugin', () => {
     face.openInstall()
     face.editInstallSpec('pkg')
     b.remote.emit('plugins/install-log', [{ jobId: 'j', argv: ['pnpm', 'add', 'pkg'], cwd: '/p', spec: 'pkg', stream: 'stdout', text: 'early' }])
+    b.remote.emit('plugins/install-state', [{ requestId: 'foreign', phase: 'installing' }])
     expect(face.hooks.pluginManager.getSnapshot().install.runs).toEqual([])
 
     await fiber.dispose()

+ 19 - 1
packages/client/ui-plugin-manager/tests/components.client.spec.tsx

@@ -56,6 +56,7 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
     editInstallSpec: vi.fn(),
     toggleInstallEnable: vi.fn(),
     runInstall: vi.fn(),
+    cancelInstall: vi.fn(),
     setEnabled: vi.fn(),
     retry: vi.fn(),
     uninstall: vi.fn(),
@@ -363,6 +364,22 @@ describe('PluginManagerPage', () => {
     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', () => {
     const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
     expect(screen.getByText(en.installExample)).toBeTruthy()
@@ -396,7 +413,8 @@ 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()
-    expect(screen.getByRole('button', { name: en.installRun })).toHaveProperty('disabled', true)
+    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` }] } })

+ 92 - 14
packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts

@@ -6,7 +6,7 @@
 import { describe, expect, it, vi } from 'vitest'
 import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
-import { PluginManagerController, rowKey } from '../src/client/manager-store.ts'
+import { type InstallState, PluginManagerController, rowKey } from '../src/client/manager-store.ts'
 
 const BUNDLE: PluginPackageView = {
   name: 'dsh-better-sidebar',
@@ -50,6 +50,7 @@ function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}
   const plugins = {
     list: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
     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))),
     enable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'live' }))),
     disable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'restart' }))),
@@ -64,6 +65,11 @@ function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}
   return { plugins, controller, face, state: () => controller.getSnapshot() }
 }
 
+function installationRequest(controller: PluginManagerController): Pick<InstallState, 'requestId'> {
+  const requestId = controller.getSnapshot().install.requestId
+  return requestId === undefined ? {} : { requestId }
+}
+
 describe('PluginManagerController', () => {
   it('starts idle, reads packages on first use, and folds concurrent loads', async () => {
     const gate = deferred<ReturnType<typeof ok<PluginPackageView[]>>>()
@@ -274,6 +280,78 @@ describe('PluginManagerController', () => {
     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 () => {
+    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({
+      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.cancelInstall()
+    expect(plugins.cancelInstall).not.toHaveBeenCalled()
+    controller.installProgress({ requestId, phase: 'installing' })
+    face.cancelInstall(); face.cancelInstall(); face.closeInstall(); face.openInstall()
+    expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
+    expect(state().install).toMatchObject({ phase: 'cancelling', open: true, spec: 'slow' })
+    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') })
+    face.runInstall()
+    const nextId = state().install.requestId
+    expect(nextId).not.toBe(requestId)
+    first.resolve(refused('plugins/install-cancelled', 'cancelled', { requestId }))
+    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([])
+    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) => {
+    const pending = deferred<ReturnType<typeof refused>>()
+    const { plugins, face, state, controller } = 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']>
+    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()
+    pending.resolve(refused('plugins/install-cancelled', 'cancelled', { requestId }))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('cancelled') })
+    expect(state().install.failure).toBeNull()
+  })
+
+  it.each([false, true])('drops a cancellation response after the add settles or the page is disposed (%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),
+    })
+    face.openInstall(); face.editInstallSpec('slow'); face.runInstall()
+    controller.installProgress({ requestId: state().install.requestId as NonNullable<InstallState['requestId']>, phase: 'installing' })
+    face.cancelInstall()
+    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)
+  })
+
   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) })
@@ -287,17 +365,17 @@ describe('PluginManagerController', () => {
     face.runInstall()
     face.runInstall()
     expect(plugins.add).toHaveBeenCalledTimes(1)
-    expect(plugins.add).toHaveBeenCalledWith('dsh-better-sidebar', { enable: false })
-    expect(state().install.phase).toBe('running')
+    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({ jobId: 'j1', argv, cwd: '/p', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'Progress\n' })
+    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({ jobId: 'jr', argv: ['pnpm', 'remove', 'lib'], cwd: '/p', spec: 'lib', stream: 'stdout', text: '- lib\n', exitCode: 0 })
-    controller.appendLog({ jobId: 'j1', argv, cwd: '/p', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'Done\n' })
+    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 },
@@ -316,8 +394,8 @@ describe('PluginManagerController', () => {
     })
     // 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({ jobId: 'j1', argv, cwd: '/p', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'late', exitCode: 0 })
-    controller.appendLog({ jobId: 'j3', argv, cwd: '/p', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'stray' })
+    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 },
@@ -377,27 +455,27 @@ describe('PluginManagerController', () => {
     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({ jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stderr', text: 'streamed' })
+    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stderr', text: 'streamed' })
     await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(3) })
     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({ jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'Done' })
+    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'Done' })
     await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(4) })
     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({ jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'partial' })
+    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'partial' })
     await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(5) })
     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({ jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'odd' })
+    controller.appendLog({ ...installationRequest(controller), jobId: 'j', argv, cwd: '/p', spec: 'x', stream: 'stdout', text: 'odd' })
     await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(6) })
     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 }])
@@ -465,8 +543,8 @@ describe('PluginManagerController', () => {
     face.openInstall()
     face.editInstallSpec('x')
     face.runInstall()
-    controller.appendLog({ jobId: 'j', argv: ['pnpm', 'add', 'x'], cwd: '/p', spec: 'x', stream: 'stderr', text: 'same tail' })
-    controller.appendLog({ jobId: 'j', argv: ['pnpm', 'add', 'x'], cwd: '/p', spec: 'x', stream: 'stdout', text: '', exitCode: 1 })
+    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 })
     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 }])
   })

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

@@ -40,6 +40,9 @@
     },
     {
       "path": "../ui-slots"
+    },
+    {
+      "path": "../../util/crypto"
     }
   ]
 }

+ 3 - 0
pnpm-lock.yaml

@@ -3546,6 +3546,9 @@ importers:
       '@deepseek-ai/dsh-host-plugin-manager':
         specifier: workspace:^
         version: link:../../host/plugin-manager
+      '@deepseek-ai/dsh-util-crypto':
+        specifier: workspace:^
+        version: link:../../util/crypto
       '@testing-library/react':
         specifier: ^16.1.0
         version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)

+ 1 - 0
tsconfig.host.json

@@ -39,6 +39,7 @@
     "apps/web/tests/document-preview.e2e.ts",
     "apps/web/tests/plugin-config.e2e.ts",
     "apps/web/tests/plugin-manager.e2e.ts",
+    "apps/web/tests/plugin-install-cancel.e2e.ts",
     "apps/web/tests/settings-chrome.e2e.ts",
     "apps/web/tests/models-settings.e2e.ts",
     "apps/web/tests/deepseek-messages-settings.e2e.ts",