diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml
index 87f591bbca..9b184f0b3e 100644
--- a/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.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-04-plugin-management-in-web-settings.md
-2026-09-04-plugin-management-in-web-settings.md: b0ccbe9934ac14ce4ee77b26d2a677f36f623be9
-2026-09-04-plugin-management-in-web-settings.zh.md: 40fc1933deb71331c1bc1c2c16baa90afd097e62
+2026-09-04-plugin-management-in-web-settings.md: f7c58cfcb1ad0981fffac4d7c117bdf649e7cbbc
+2026-09-04-plugin-management-in-web-settings.zh.md: 4f0530d5cde92be1021ae348fe4511c8e4b1d62a
diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
index b0ccbe9934..f7c58cfcb1 100644
--- a/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
+++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
@@ -14,7 +14,7 @@ The Host could manage a profile's plugins — install, enable, disable, retry, c
**Cards stage per scope, components stay scope-blind.** `ui-settings-plugins` holds one `ScopeSelection` for the configurable tab and a `ScopedCardForms` per card: one `CardForm` per scope, bound lazily, with drafts that belong to the scope they were typed under. The card components read the same hooks and call the same actions, which route to the selected form at call time. A field a named scope does not override reports `inherited` when the global user layer carries it; a reset stages the inherited value. The tab's switch lists the roster's presets plus any scope the document holds a section for. The filesystem skill provider gets its first card, a line-list of extra roots.
-**A separate tab for management.** `ui-settings-plugin-manager` registers the **Manage plugins** tab between configuration and the read-only list. It reads packages from the `plugins` Remote and preset compositions from `pluginInventory`, re-reads after every action and every `plugins/changed`, and streams `plugins/install-log` into its install dialog. Destructive actions wait for an acknowledged confirmation listing the dependents the Host reports. The read-only list gains the provenance facts the Host already carried: which layer inserted a preset row and who switched a row off.
+**A separate tab for management.** `ui-settings-plugin-manager` registers the **Manage plugins** tab between configuration and the read-only list. It reads packages from the `plugins` Remote and preset compositions from `pluginInventory`, re-reads after every action and every `plugins/changed`, and streams `plugins/install-log` into its install dialog. Destructive actions wait for an acknowledged confirmation listing the dependents the Host reports. The read-only list gains the provenance facts the Host already carried: which layer inserted a preset row and who switched a row off. The install dialog also lists the packages the Host removed again after `pnpm add` — a package that is not a dsh package, a bundle whose row id another layer owns — with the Host's reason, and every action renders the two refusals any mutation can meet, `plugins/busy` and `plugins/agents-running`, in the Host's words.
**The install verb is `add`.** The client namespace service reserves `install` and `remove` for its own members and refuses a mounted method of that name at page load, after every unit suite has passed. The Host's method is `plugins/add`, as on the CLI, and `packages/api/remotes/tests/remote-method-names.host.spec.ts` checks every `@Remote('
{t('installFailed')}
: null} + {install.phase === 'done' && install.removed.length > 0 + ? ( +{install.failure === null ? t('installFailed') : refusalText(install.failure, t)}
+ : null} {install.log === '' && install.phase === 'idle' ? null :{install.log}}
@@ -451,12 +462,21 @@ function noticeText(notice: ManagerNotice, t: Translate): string {
case 'plugins/enable-failed': return t('enableFailed', { reason: notice.reason })
case 'plugins/row-conflict': return t('rowConflict', { row: notice.rowId ?? '' })
case 'plugins/not-installed': return t('notInstalled', { name: notice.packageName ?? '' })
- default: return t('actionFailed', { reason: notice.reason })
+ default: return refusalText(notice, t)
}
}
}
}
+/** The copy for a refusal every mutation can meet: the manager is busy, or a session is running. */
+function refusalText(failure: { readonly code: string; readonly reason: string }, t: Translate): string {
+ switch (failure.code) {
+ case 'plugins/busy': return t('busy', { reason: failure.reason })
+ case 'plugins/agents-running': return t('agentsRunning', { reason: failure.reason })
+ default: return failure.code === 'plugins/install-failed' ? t('installFailed') : t('actionFailed', { reason: failure.reason })
+ }
+}
+
/** Render the plugin manager: packages first, then the selected preset's composition. */
export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps): ReactNode {
const { t, presetName, ensure } = props
diff --git a/packages/client/ui-settings-plugin-manager/src/client/locales.ts b/packages/client/ui-settings-plugin-manager/src/client/locales.ts
index 0e562142e6..d828bac46c 100644
--- a/packages/client/ui-settings-plugin-manager/src/client/locales.ts
+++ b/packages/client/ui-settings-plugin-manager/src/client/locales.ts
@@ -85,6 +85,10 @@ export const zh = {
installDone: '安装完成:{names}',
installDoneNothing: '安装完成,没有新增依赖。',
installFailed: '安装失败。',
+ installRemovedLabel: '已移除的包',
+ installRemoved: '已移除 {name}:{reason}',
+ busy: '另一个改动还在进行,等它结束再试:{reason}',
+ agentsRunning: '有会话正在运行,等它空闲后再改动插件:{reason}',
installClose: '完成',
close: '关闭',
cancel: '取消',
@@ -194,6 +198,10 @@ export const en = {
installDone: 'Installed: {names}',
installDoneNothing: 'Install finished with no new dependency.',
installFailed: 'The install failed.',
+ installRemovedLabel: 'Removed packages',
+ installRemoved: 'Removed {name}: {reason}',
+ busy: 'Another change is still running; try again when it finishes: {reason}',
+ agentsRunning: 'A session is running; change plugins once it is idle: {reason}',
installClose: 'Done',
close: 'Close',
cancel: 'Cancel',
diff --git a/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts b/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts
index 0429636488..c26fda44dd 100644
--- a/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts
+++ b/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts
@@ -8,7 +8,12 @@
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {
- PluginDependents, PluginEnableResult, PluginInstallLogChunk, PluginInventorySnapshot, PluginPackageView,
+ PluginDependents,
+ PluginEnableResult,
+ PluginInstallLogChunk,
+ PluginInstallRejection,
+ PluginInventorySnapshot,
+ PluginPackageView,
PluginRowTarget,
} from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -43,8 +48,12 @@ export interface InstallState {
readonly phase: 'idle' | 'running' | 'done' | 'failed'
/** pnpm's output so far, stdout and stderr interleaved as they arrived. */
readonly log: string
- /** Dependencies the last run added, once it finished. */
+ /** Dependencies the last run added and kept, once it finished. */
readonly installed: 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
}
/** A destructive action waiting for the user's acknowledgement. */
@@ -135,7 +144,7 @@ export function rowKey(target: PluginRowTarget, rowId: string): string {
return `${target.kind === 'global' ? 'global' : `preset:${target.preset}`}:${rowId}`
}
-const IDLE_INSTALL: InstallState = { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] }
+const IDLE_INSTALL: InstallState = { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [], removed: [], failure: null }
/** Reads and mutates the profile's plugins through the `plugins` and `pluginInventory` Remotes. */
export class PluginManagerController {
@@ -335,14 +344,14 @@ export class PluginManagerController {
const install = this.getSnapshot().install
const spec = install.spec.trim()
if (install.phase === 'running' || spec === '') return
- this.patchInstall({ phase: 'running', log: '', installed: [] })
+ this.patchInstall({ phase: 'running', log: '', installed: [], 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
if (result.ok) {
- this.patchInstall({ phase: 'done', installed: result.value.installed })
+ this.patchInstall({ phase: 'done', installed: result.value.installed, removed: result.value.removed })
} else {
// The Host's reason follows whatever streamed: pnpm's captured log when
// no chunk arrived, else the refusal that followed a successful pnpm
@@ -350,7 +359,7 @@ export class PluginManagerController {
const current = this.getSnapshot().install.log
const reason = detailOf(result.error, 'reason') ?? detailOf(result.error, 'log') ?? result.error.message
const log = current === '' || current.endsWith(reason) ? (current === '' ? reason : current) : `${current}\n${reason}`
- this.patchInstall({ phase: 'failed', log })
+ this.patchInstall({ phase: 'failed', log, failure: { code: result.error.code, reason } })
}
void this.load()
}
diff --git a/packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx
index e49949ee84..986bff59db 100644
--- a/packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx
+++ b/packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx
@@ -47,7 +47,7 @@ const READY: PluginManagerState = {
selectedPreset: null,
busy: [],
notice: null,
- install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] },
+ install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [], removed: [], failure: null },
confirm: null,
}
@@ -302,7 +302,7 @@ describe('PluginManagerSettingsTab', () => {
})
it('drives the install dialog through its phases', () => {
- const { actions, set } = renderTab({ install: { open: true, spec: '', enable: true, phase: 'idle', log: '', installed: [] } })
+ const { actions, set } = renderTab({ install: { open: true, spec: '', enable: true, phase: 'idle', log: '', installed: [], removed: [], failure: null } })
const dialog = screen.getByRole('dialog', { name: en.installTitle })
expect(dialog).toBeTruthy()
const spec = screen.getByLabelText(en.installSpecLabel) as HTMLInputElement
@@ -313,33 +313,39 @@ describe('PluginManagerSettingsTab', () => {
expect(screen.getByRole('button', { name: en.installRun })).toHaveProperty('disabled', true)
expect(screen.queryByLabelText(en.installLogLabel)).toBeNull()
- set({ install: { open: true, spec: 'pkg', enable: false, phase: 'idle', log: '', installed: [] } })
+ set({ install: { open: true, spec: 'pkg', enable: false, phase: 'idle', log: '', installed: [], removed: [], failure: null } })
fireEvent.click(screen.getByRole('button', { name: en.installRun }))
expect(actions.runInstall).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: en.cancel }))
expect(actions.closeInstall).toHaveBeenCalledTimes(1)
- set({ install: { open: true, spec: 'pkg', enable: false, phase: 'running', log: 'Progress', installed: [] } })
+ set({ install: { open: true, spec: 'pkg', enable: false, phase: 'running', log: 'Progress', installed: [], removed: [], failure: null } })
expect(screen.getByRole('button', { name: en.installRunning })).toHaveProperty('disabled', true)
expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('Progress')
expect(screen.getByLabelText(en.installSpecLabel)).toHaveProperty('disabled', true)
- set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: 'Progress', installed: ['pkg'] } })
+ set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: 'Progress', installed: ['pkg'], removed: [], failure: null } })
expect(screen.getByRole('status').textContent).toBe('Installed: pkg')
- set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: '', installed: [] } })
+ set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: '', installed: [], removed: [], failure: null } })
expect(screen.getByRole('status').textContent).toBe(en.installDoneNothing)
+ set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: '', installed: [], removed: [{ name: 'lib', reason: 'declares neither a dsh bundle nor a plugin module' }], failure: null } })
+ expect(screen.getByLabelText(en.installRemovedLabel).textContent).toBe('Removed lib: declares neither a dsh bundle nor a plugin module')
fireEvent.click(screen.getByRole('button', { name: en.installClose }))
expect(actions.closeInstall).toHaveBeenCalledTimes(2)
- set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [] } })
+ set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [], removed: [], failure: null } })
expect(screen.getByRole('alert').textContent).toBe(en.installFailed)
+ set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [], removed: [], failure: { code: 'plugins/busy', reason: 'add x is still running' } } })
+ expect(screen.getByRole('alert').textContent).toBe('Another change is still running; try again when it finishes: add x is still running')
+ set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [], removed: [], failure: { code: 'plugins/agents-running', reason: '1 running' } } })
+ expect(screen.getByRole('alert').textContent).toBe('A session is running; change plugins once it is idle: 1 running')
expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('ERR')
fireEvent.click(screen.getByRole('button', { name: en.close }))
expect(actions.closeInstall).toHaveBeenCalledTimes(3)
fireEvent.keyDown(document, { key: 'Escape' })
expect(actions.closeInstall).toHaveBeenCalledTimes(4)
- set({ install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] } })
+ set({ install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [], removed: [], failure: null } })
expect(screen.queryByRole('dialog')).toBeNull()
})
diff --git a/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts
index 5797a3efe6..8707945bb6 100644
--- a/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts
+++ b/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts
@@ -32,7 +32,14 @@ const STANDARD: PresetGroup = {
}
/** What one install run answers. */
-type InstallValue = { installed: string[]; enabled: string[]; installedOnly: string[]; plain: string[]; jobId: string }
+type InstallValue = {
+ installed: string[]
+ removed: { name: string; reason: string }[]
+ enabled: string[]
+ installedOnly: string[]
+ plain: string[]
+ jobId: string
+}
function ok