Browse Source

feat: add current-profile plugin manager service and Web controls

Turtle 3 weeks ago
parent
commit
98b92b683c
67 changed files with 2751 additions and 283 deletions
  1. 29 0
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  2. 29 0
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  3. 1 1
      THIRD_PARTY_NOTICES.md
  4. 1 1
      apps/cli/README.md
  5. 1 1
      apps/cli/README.zh.md
  6. 3 1
      apps/cli/package.json
  7. 1 1
      apps/cli/src/bin.ts
  8. 17 157
      apps/cli/src/plugin.ts
  9. 67 57
      apps/cli/src/profile-boot.ts
  10. 50 6
      apps/cli/tests/built-bin.e2e.ts
  11. 6 0
      apps/cli/tsconfig.json
  12. 2 0
      docs/architecture.md
  13. 2 0
      docs/architecture.zh.md
  14. 20 0
      docs/config-catalog.md
  15. 8 0
      docs/module-graph.md
  16. 8 0
      docs/module-graph.zh.md
  17. 1 0
      docs/subsystems/README.md
  18. 1 0
      docs/subsystems/README.zh.md
  19. 103 0
      docs/subsystems/boot.md
  20. 103 0
      docs/subsystems/boot.zh.md
  21. 50 0
      docs/tool-catalog.md
  22. 2 1
      packages/api/remotes/package.json
  23. 4 1
      packages/api/remotes/src/client/index.ts
  24. 3 0
      packages/api/remotes/tsconfig.client.json
  25. 4 1
      packages/boot/README.md
  26. 4 1
      packages/boot/README.zh.md
  27. 1 1
      packages/boot/app-boot/README.md
  28. 1 1
      packages/boot/app-boot/README.zh.md
  29. 22 15
      packages/boot/app-boot/src/index.ts
  30. 38 0
      packages/boot/app-boot/src/profile-runtime.ts
  31. 31 0
      packages/boot/app-boot/tests/user-patches.spec.ts
  32. 111 0
      packages/boot/plugin-manager/README.md
  33. 111 0
      packages/boot/plugin-manager/README.zh.md
  34. 90 0
      packages/boot/plugin-manager/package.json
  35. 334 0
      packages/boot/plugin-manager/src/index.ts
  36. 171 0
      packages/boot/plugin-manager/src/operations.ts
  37. 39 0
      packages/boot/plugin-manager/src/patch.ts
  38. 63 0
      packages/boot/plugin-manager/src/tools.ts
  39. 42 0
      packages/boot/plugin-manager/src/types.ts
  40. 291 0
      packages/boot/plugin-manager/tests/manager.spec.ts
  41. 171 0
      packages/boot/plugin-manager/tests/operations.spec.ts
  42. 63 0
      packages/boot/plugin-manager/tests/patch.spec.ts
  43. 82 0
      packages/boot/plugin-manager/tests/tools.spec.ts
  44. 48 0
      packages/boot/plugin-manager/tsconfig.json
  45. 8 0
      packages/bundle/base/cordis.patch.yml
  46. 2 1
      packages/bundle/base/package.json
  47. 3 0
      packages/bundle/web-app/cordis.patch.yml
  48. 5 5
      packages/client/ui-settings-plugin-inventory/README.md
  49. 5 5
      packages/client/ui-settings-plugin-inventory/README.zh.md
  50. 61 0
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css
  51. 17 2
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
  52. 15 1
      packages/client/ui-settings-plugin-inventory/src/client/index.ts
  53. 8 0
      packages/client/ui-settings-plugin-inventory/src/client/locales.ts
  54. 92 0
      packages/client/ui-settings-plugin-inventory/src/client/management.tsx
  55. 44 0
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  56. 122 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  57. 31 22
      packages/host/plugin-inventory/src/index.ts
  58. 2 0
      packages/host/plugin-inventory/src/types.ts
  59. 4 0
      packages/preset/agent-presets/presets/cordis/agent.cordis.yml
  60. 4 0
      packages/preset/agent-presets/presets/ptc/agent.cordis.yml
  61. 4 0
      packages/preset/agent-presets/presets/standard/agent.cordis.yml
  62. 67 0
      pnpm-lock.yaml
  63. 9 0
      scripts/gen-cordis-catalog.ts
  64. 14 0
      scripts/gen-tool-catalog.ts
  65. 0 1
      scripts/verify-subsystem-pages.ts
  66. 4 0
      tsconfig.base.json
  67. 1 0
      tsconfig.host.json

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

@@ -0,0 +1,29 @@
+# Agent Note: Current-profile plugin management shares CLI transactions
+
+Status: implemented
+
+English | [中文](2026-09-14-current-profile-plugin-management.zh.md)
+
+## Problem
+
+Web and agent controls need to change a running profile without creating an independent package installer or overwriting user-authored YAML. A file watcher can otherwise load the intermediate manifest written during package installation, or report success before removed plugins finish releasing resources.
+
+## Decision
+
+[Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies the current profile and resolution locations. CLI and service mutations hold the profile manifest's writer lock; the live launcher serializes service mutations and file-triggered recomposition through one queue. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence.
+
+Profile files remain the persisted state: entry toggles edit only `disabled` in the YAML document, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
+
+This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Startup profiles keep their process composition, and Desktop package management remains shell-owned. Web controls and agent tools call the same service, whose batched durable notices inform live Agents without waking them.
+
+## Alternatives considered
+
+**Spawning another dsh process from the service.** This duplicates lifecycle coordination and cannot establish that the current Loader finished unloading before pnpm removes files. Sharing the operation module retains one implementation while letting each caller own its presentation.
+
+**A second desired-state database or automatic rollback.** These require synchronizing package-manager side effects with another state store. Profile files remain inspectable and repairable; partial installation and loading failures are reported rather than concealed by an incomplete rollback.
+
+**Source-module hot replacement for package updates.** Configuration changes can reuse the loaded module cache, whereas replacing installed JavaScript needs a new process generation. Replacing an existing dependency reports a required restart.
+
+## Consequences
+
+The same profile can be managed through CLI, Web and tools, with file-level coordination and preserved patch precedence. Operators must repair failed package operations using the reported files and diagnostics. A startup process must stop before its loaded packages can be removed through CLI. Management components remain protected against service-initiated removal.

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

@@ -0,0 +1,29 @@
+# Agent Note:当前 profile 插件管理共享 CLI 事务
+
+Status: implemented
+
+[English](2026-09-14-current-profile-plugin-management.md) | 中文
+
+## 问题
+
+Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安装器或覆盖用户编写的 YAML。文件监听器可能读到安装期间写入的中间 manifest,也可能在被删除插件尚未释放资源时报告成功。
+
+## 决策
+
+[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 提供当前 profile 和解析位置。CLI 与 service 修改持有 profile manifest 的写锁;实时 launcher 通过同一队列串行执行 service 修改与文件触发的重新组合。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
+
+profile 文件保持为持久状态:条目开关只修改 YAML 文档中的 `disabled`,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
+
+这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。
+
+## 考虑过的替代方案
+
+**由 service 启动另一个 dsh 进程。** 这会重复生命周期协调,也无法确认当前 Loader 已完成卸载后才让 pnpm 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。
+
+**第二份目标状态数据库或自动回滚。** 这些方案需要将包管理器副作用与另一份状态同步。profile 文件保持可检查、可修复;安装与加载的部分失败直接报告,不用不完整的回滚掩盖。
+
+**包更新时热替换源码模块。** 配置变化可以复用已加载模块缓存,替换已安装 JavaScript 则需要新的进程。替换已有依赖会报告需要重启。
+
+## 影响
+
+同一 profile 可以通过 CLI、Web 和工具管理,操作通过文件锁协调并保留 patch 优先级。运维人员需要根据报告的文件和诊断修复失败的包操作。startup 进程必须先停止,才能通过 CLI 删除其加载的包。管理组件受保护,不能通过 service 删除。

+ 1 - 1
THIRD_PARTY_NOTICES.md

@@ -77,6 +77,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
 | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
 | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
+| [`execa`](https://github.com/sindresorhus/execa) | MIT |
 | [`fflate`](https://github.com/101arrowz/fflate) | MIT |
 | [`immer`](https://github.com/immerjs/immer) | MIT |
 | [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
@@ -193,7 +194,6 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT |
 | [`esbuild`](https://github.com/evanw/esbuild) | MIT |
 | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
-| [`execa`](https://github.com/sindresorhus/execa) | MIT |
 | [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause |
 | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
 | [`http-server`](https://github.com/http-party/http-server) | MIT |

+ 1 - 1
apps/cli/README.md

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profiles
 
-A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile and home-level patch files; `startup` applies them once.
+A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload; `startup` applies them once. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections.
 
 The tree composes over an empty root:
 - each bundle's patch in `dsh.profile.bundles` order

+ 1 - 1
apps/cli/README.zh.md

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profile
 
-profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表与 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile 与 home 级 patch 文件,`startup` 则只应用一次。
+profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表与 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层;`startup` 则只应用一次。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。
 
 配置树以空根为起点,依次叠加以下配置层:
 - `dsh.profile.bundles` 中各组合包的 patch

+ 3 - 1
apps/cli/package.json

@@ -100,7 +100,9 @@
     "js-yaml": "^4.2.0",
     "node-addon-require-builtin": "^0.1.4",
     "@deepseek-ai/dsh-http-proxy": "workspace:^",
-    "@deepseek-ai/dsh-mcp-resources": "workspace:^"
+    "@deepseek-ai/dsh-mcp-resources": "workspace:^",
+    "@deepseek-ai/dsh-plugin-manager": "workspace:^",
+    "@deepseek-ai/dsh-atomic-write": "workspace:^"
   },
   "devDependencies": {
     "@agentclientprotocol/sdk": "1.4.0",

+ 1 - 1
apps/cli/src/bin.ts

@@ -42,7 +42,7 @@ export async function runCli(): Promise<void> {
     }
     case 'plugin': {
       const { runPlugin } = await import('./plugin.ts')
-      process.exit(runPlugin(invocation.profile, invocation.args))
+      process.exit(await runPlugin(invocation.profile, invocation.args))
       break
     }
     case 'dump-config': {

+ 17 - 157
apps/cli/src/plugin.ts

@@ -1,163 +1,23 @@
-/**
- * `dsh plugin --profile <name> <args...>` — profile plugin management as a
- * thin pnpm forwarder: initialize the profile on first use, run
- * `pnpm <args...>` in the profile directory, then reconcile the
- * `dsh.profile.bundles` layer list against the installed state (a dependency
- * resolving to a package that declares `dsh.bundle` joins the layer stack; a
- * removed or bundle-less dependency leaves it). Reconciling by installed
- * state, not by dependency diff, means `update` activates a package that
- * gained its `dsh.bundle` declaration in a newer version.
- * @module @deepseek-ai/dsh/plugin
- */
-
-import { spawnSync } from 'node:child_process'
-import { existsSync } from 'node:fs'
-import { join, resolve } from 'node:path'
-import {
-  DEFAULT_PROFILE_BUNDLES,
-  initProfile,
-  PROFILE_TEMPLATES,
-  readProfileManifest,
-  resolveBundleDir,
-  resolveProfileDir,
-  writeProfileManifest,
-  type ProfileManifest,
-} from '@deepseek-ai/dsh-app-boot'
+/** dsh plugin forwards pnpm through the shared profile package operations. */
+import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations'
 import { INSTALL_ANCHOR } from './profile-boot.ts'
+import { resolveProfileDir } from '@deepseek-ai/dsh-app-boot'
+import { join } from 'node:path'
 
-const NAME = 'dsh'
-
-/**
- * Whether a resolved dependency exports a profile patch, i.e. is a bundle.
- * @param packageName - the dependency's package name.
- * @param profileDir - the profile directory (resolution anchor).
- * @returns true when the package manifest declares `dsh.bundle`.
+/** Run package management for a profile.
+ * @param profile Profile name.
+ * @param args Pnpm arguments relative to the invoking directory.
+ * @returns Pnpm exit code.
  */
-function exportsPatch(packageName: string, profileDir: string): boolean {
-  let dir: string
-  try {
-    dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir)
-  } catch {
-    return false // pnpm reported success yet the package is unresolvable — treat as plain
-  }
-  const manifest = readProfileManifest(NAME, dir)
-  return manifest.dsh?.bundle?.patch !== undefined
-}
-
-/**
- * Reconcile `dsh.profile.bundles` against the installed state: pnpm has
- * already written the real installed names (so a git/path/tarball/alias spec
- * on the command line reconciles by its true package name) and materialized
- * the packages. A dependency that resolves to a `dsh.bundle`-declaring
- * package joins the layer stack (appended in dependency order); a
- * dependency-listed name that no longer does — removed, or the installed
- * version dropped the declaration — leaves it. In-box bundles from the
- * profile template are not dependencies and are never touched. Warns once
- * per newly-added bundle-less dependency (a plain library is fine; the
- * warning is orientation).
- */
-function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
-  const after = readProfileManifest(NAME, profileDir)
-  const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
-  const dependencies = Object.keys(after.dependencies ?? {})
-  const plugins = after.dsh?.profile?.bundles ?? []
-  let changed = false
-  for (const packageName of dependencies) {
-    const isBundle = exportsPatch(packageName, profileDir)
-    if (isBundle && !plugins.includes(packageName)) {
-      plugins.push(packageName)
-      changed = true
-    } else if (!isBundle && !beforeDeps.has(packageName)) {
-      process.stderr.write(
-        `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
-        + '(a later update that gains one activates it automatically)\n',
-      )
-    }
-  }
-  const dependencySet = new Set(dependencies)
-  for (const packageName of [...plugins]) {
-    // Only dependency-managed entries are subject to removal; template
-    // bundles (dsh-base and friends) are not dependencies.
-    const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName)
-    const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir)
-    if (wasDependency && !stillBundle) {
-      plugins.splice(plugins.indexOf(packageName), 1)
-      changed = true
-    }
-  }
-  if (!changed) return
-  after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } }
-  writeProfileManifest(profileDir, after)
-}
-
-/**
- * Rewrite relative filesystem specs against the user's invoking directory.
- * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
- * (or their `file:`/`link:` forms) would silently resolve inside the profile
- * — `add .` from a plugin checkout would self-link the profile. Absolute
- * specs, registry names, and every other pnpm argument pass through
- * untouched.
- * @param argument - one pnpm argument, verbatim from argv.
- * @param cwd - the directory `dsh` was invoked from.
- * @returns the argument with a relative path spec anchored to `cwd`.
- */
-function anchorPathSpec(argument: string, cwd: string): string {
-  const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument)
-  if (match?.groups?.path === undefined) return argument
-  // A bare path stays bare and a prefixed spec keeps its prefix: pnpm's
-  // link-vs-copy semantics differ between `file:` and a plain directory
-  // path, and the anchor must not change which one the user asked for.
-  const prefix = match.groups.prefix ?? ''
-  return `${prefix}${resolve(cwd, match.groups.path)}`
-}
-
-/**
- * Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile.
- * @param profile - the profile name.
- * @param args - pnpm arguments with relative path specs anchored to the invoking directory.
- * @returns the pnpm exit code.
- */
-export function runPlugin(profile: string, args: readonly string[]): number {
-  const dir = resolveProfileDir(profile)
-  if (!existsSync(join(dir, 'package.json'))) {
-    const template = PROFILE_TEMPLATES[profile]
-    initProfile(
-      dir,
-      template?.bundles ?? DEFAULT_PROFILE_BUNDLES,
-      template?.patchReload,
-    )
-    process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
-  }
-  const before = readProfileManifest(NAME, dir)
-  // Windows resolves pnpm through its .cmd shim, which spawn() refuses
-  // without a shell since the CVE-2024-27980 hardening.
-  const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), {
-    cwd: dir,
-    stdio: 'inherit',
-    shell: process.platform === 'win32',
+export async function runPlugin(profile: string, args: readonly string[]): Promise<number> {
+  const result = await runPluginCommand({ profile, installAnchor: INSTALL_ANCHOR, cwd: process.cwd() }, args, {
+    outputBytes: 16384,
+    lockWaitMs: 120000,
+    onOutput: (text, stream) => { process[stream].write(text) },
   })
-  if (result.error !== undefined) {
-    const code = (result.error as NodeJS.ErrnoException).code
-    if (code === 'ENOENT') {
-      process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`)
-      return 127
-    }
-    throw result.error
-  }
-  const exitCode = result.status ?? 1
-  if (exitCode === 0) {
-    reconcilePlugins(before, dir)
-  } else {
-    // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
-    // one; the profile owns it, and the commonest failure here is pnpm ≥10
-    // blocking a git dependency's prepare (build) script until allowlisted.
-    process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`)
-    if (args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
-      process.stderr.write(
-        `${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — `
-        + `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`,
-      )
-    }
+  if (result.exitCode !== 0) process.stderr.write(`dsh: pnpm failed; diagnostics: ${result.logPath}\n`)
+  if (result.exitCode !== 0 && args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
+    process.stderr.write(`dsh: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — add the exact key pnpm printed above under allowBuilds in ${join(resolveProfileDir(profile), 'pnpm-workspace.yaml')}, then re-run\n`)
   }
-  return exitCode
+  return result.exitCode
 }

+ 67 - 57
apps/cli/src/profile-boot.ts

@@ -11,12 +11,12 @@
  * @module @deepseek-ai/dsh/profile-boot
  */
 
-import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { dirname, join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
+import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
 import { FiberState, type Context } from '@deepseek-ai/cordis'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import {
   boot,
   composeEntries,
@@ -29,7 +29,10 @@ import {
   PROFILE_PATCH_FILENAME,
   PROFILE_TEMPLATES,
   resolveProfileDir,
-  watchUserPatches,
+  watchConfig,
+  loadProfileDirectory,
+  reconcileProfilePatches,
+  type ProfileRuntime,
   type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
@@ -194,24 +197,10 @@ export function prepareProfile(name: string, userLayer = true, fromDefaultProfil
 /** One profile's patch layers, in application order. */
 interface ComposedProfile {
   profile: Profile
-  /** Bundle layers concatenated — the part below the user layers on a live reload. */
-  bundlePatches: PatchOptions[]
-  /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
-  homePatches: PatchOptions[]
-  /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
+  /** Command-line overlay contents, frozen for this invocation. */
   overlays: PatchOptions[]
 }
 
-/** The full patch stack of one composed profile, in application order. */
-function allPatches(composed: ComposedProfile): PatchOptions[] {
-  return [
-    ...composed.bundlePatches,
-    ...composed.profile.patches,
-    ...composed.homePatches,
-    ...composed.overlays,
-  ]
-}
-
 /**
  * Load `name` and compose its effective patch stack: bundle layers in
  * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
@@ -230,17 +219,8 @@ async function composeProfile(
 ): Promise<ComposedProfile> {
   const profile = prepareProfile(name, true, fromDefaultProfile)
   await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, profile })
-  const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
   const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
-  const bundlePatches = profile.layers.flatMap(layer => layer.patches)
-  const rows = new Map<string, EntryOptions>()
-  for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
-    if (typeof row.id === 'string') rows.set(row.id, row)
-  }
-  const composedOverlays = [...overlays]
-  const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
-  if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
-  return { profile, bundlePatches, homePatches, overlays: composedOverlays }
+  return { profile, overlays }
 }
 
 /** Options for {@link runProfile}. */
@@ -313,28 +293,55 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   })
 
   const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
-  // Recomposition for the live user layers: bundle layers below, overlays
-  // above, so a user edit can never displace them. Parsed app arguments are
-  // not in here at all — they live in app-provided services that survive a
-  // recomposition. BOTH
-  // user files are re-read per generation (the HMR watcher hands us only the
-  // changed file's patches, which one of the reads duplicates — fresh reads
-  // keep the two watchers from stitching in each other's stale copy).
-  // Fresh clones per generation: the include pushes `insert` rows into the
-  // mounted tree BY REFERENCE and later id-targeted patches mutate those
-  // objects in place. Reusing one parsed patch object across applications
-  // would bake a user override into the bundle's in-memory insert row, so
-  // removing the override could never revert the row to the bundle default.
-  const composeLive = (): PatchOptions[] => structuredClone([
-    ...composed.bundlePatches,
-    ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
-    ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
-    ...composed.overlays,
-  ])
+  let operations: Promise<unknown> = Promise.resolve()
+  const manifestPath = join(composed.profile.dir, 'package.json')
+  const watchedFiles = [composed.profile.patchPath, homePatchPath(), manifestPath]
+  const readInputs = (): string => JSON.stringify(watchedFiles.map((filename) => {
+    try {
+      return readFileSync(filename, 'utf8')
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
+      throw error
+    }
+  }))
+  let lastInputs = readInputs()
+  const enqueue = <T>(operation: () => Promise<T>): Promise<T> => {
+    const task = operations.then(operation)
+    operations = task.catch(() => {})
+    return task
+  }
+  const composeLive = (): PatchOptions[] => {
+    const profile = loadProfileDirectory(NAME, composed.profile.dir, INSTALL_ANCHOR)
+    const patches = structuredClone([
+      ...profile.layers.flatMap(layer => layer.patches),
+      ...profile.patches,
+      ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
+      ...composed.overlays,
+    ])
+    const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED,
+      composeEntries([patches]).some(row => row.id === TELEMETRY_ROW_ID))
+    if (telemetryPatch !== undefined) patches.push(telemetryPatch)
+    return patches
+  }
+  const runtime: ProfileRuntime = {
+    name: options.profile, dir: composed.profile.dir, installAnchor: INSTALL_ANCHOR,
+    startedBundles: composed.profile.layers.map(layer => layer.packageName),
+    cwd: process.cwd(), home: resolveDshHome(), patchReload: composed.profile.patchReload,
+    read: () => loadProfileDirectory(NAME, composed.profile.dir, INSTALL_ANCHOR),
+    entries: () => composeEntries([composeLive()]),
+    mutate: (operation, waitMs) => enqueue(() => withFileLock(manifestPath, operation, waitMs === undefined ? undefined : { waitMs })),
+    async reload() {
+      if (composed.profile.patchReload === 'startup') return
+      if (app.current === undefined) throw new Error('dsh: profile is not running')
+      lastInputs = readInputs()
+      await reconcileProfilePatches(app.current, composeLive(), NAME)
+    },
+  }
   // Cloned for the same insert-aliasing reason as composeLive: the boot
   // application must not mutate the objects later reloads recompose from.
-  const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
+  const ctx = await boot(NAME, rootConfig, composeLive(), (hostCtx) => {
     app.current = hostCtx
+    hostCtx.provide('profileRuntime', runtime)
     // Before any config-tree entry mounts, so plugins resolve all launch-time
     // environment values from the same immutable launch snapshot.
     hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
@@ -370,16 +377,19 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
         await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
         await ctx.loader.await()
       }
-      await watchUserPatches(ctx, {
-        binName: NAME,
-        filename: composed.profile.patchPath,
-        compose: composeLive,
-      })
-      await watchUserPatches(ctx, {
-        binName: NAME,
-        filename: homePatchPath(),
-        compose: composeLive,
-      })
+      const hmr = ctx.get('hmr')
+      if (hmr === undefined) throw new Error('dsh: configuration watcher did not start')
+      const refresh = async (): Promise<void> => {
+        // Package writers hold this file across pnpm and manifest reconciliation.
+        // Its removal triggers another refresh after the complete write finishes.
+        if (existsSync(`${manifestPath}.lock`) || readInputs() === lastInputs) return
+        await runtime.mutate(async () => {
+          if (readInputs() !== lastInputs) await runtime.reload()
+        })
+      }
+      for (const filename of [...watchedFiles, `${manifestPath}.lock`]) {
+        await watchConfig(ctx, filename, hmr.config, refresh)
+      }
     } catch (error) {
       suppressShutdownError(ctx, signalShutdown.signal, error)
     }

+ 50 - 6
apps/cli/tests/built-bin.e2e.ts

@@ -11,6 +11,7 @@ import {
   PROTOCOL_VERSION,
   type SessionNotification,
 } from '@agentclientprotocol/sdk'
+import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
 import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
 import { execa } from 'execa'
@@ -839,6 +840,53 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
       expect(result.signal).toBeUndefined()
       expect(existsSync(fixture.disposed)).toBe(true)
+    } catch (error) {
+      child.kill('SIGKILL')
+      const result = await child
+      throw new Error(`${String(error)}\n${result.stderr}`, { cause: error })
+    } finally {
+      child.kill('SIGKILL')
+      await child
+      rmSync(fixture.home, { recursive: true, force: true })
+    }
+  }, SPAWN_TIMEOUT_MS + 30_000)
+
+  it('recomposes bundle selections after a shared profile transaction releases its lock', async () => {
+    const fixture = createProfileLifecycleFixture()
+    const dir = join(fixture.home, 'profiles', 'lifecycle')
+    const manifestPath = join(dir, 'package.json')
+    const bundleDir = join(dir, 'node_modules', 'extra-bundle')
+    const mounted = join(fixture.home, 'extra-mounted')
+    const unmounted = join(fixture.home, 'extra-unmounted')
+    mkdirSync(bundleDir, { recursive: true })
+    writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
+      name: 'extra-bundle', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
+    }))
+    writeFileSync(join(bundleDir, 'cordis.patch.yml'), '- insert:\n    - id: extra\n      name: ./plugin.mjs\n')
+    writeFileSync(join(bundleDir, 'plugin.mjs'), `
+      import { writeFileSync } from 'node:fs'
+      export function apply(ctx) {
+        writeFileSync(${JSON.stringify(mounted)}, 'mounted')
+        ctx.effect(() => () => { writeFileSync(${JSON.stringify(unmounted)}, 'unmounted') })
+      }
+    `)
+    const child = startProfileLifecycle(fixture)
+    try {
+      await waitForFile(fixture.settled)
+      await withFileLock(manifestPath, async () => {
+        const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
+        manifest.dsh.profile.bundles.push('extra-bundle')
+        await writeFileAtomic(manifestPath, JSON.stringify(manifest), { mode: 0o600 })
+      })
+      await waitForFile(mounted)
+      await withFileLock(manifestPath, async () => {
+        const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
+        manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter(name => name !== 'extra-bundle')
+        await writeFileAtomic(manifestPath, JSON.stringify(manifest), { mode: 0o600 })
+      })
+      await waitForFile(unmounted)
+      requestProfileShutdown(child, fixture)
+      expect((await child).exitCode).toBe(0)
     } finally {
       child.kill('SIGKILL')
       await child
@@ -972,11 +1020,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS * 2 + 30_000)
 
-  it('activates a dependency that gained dsh.bundle in a later update', async () => {
-    // Reconcile runs against the INSTALLED state on every successful pnpm
-    // run, so `update` (not only `add`) activates a package whose newer
-    // version declares dsh.bundle. Simulated without a registry: hand-place
-    // the installed package, flip its manifest, and run a benign pnpm verb.
+  it('keeps existing dependencies inactive when package metadata gains a bundle declaration', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
     try {
       const profileDir = join(home, 'profiles', 'up')
@@ -1003,7 +1047,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
       expect(second.code).toBe(0)
       manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
-      expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
+      expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
     } finally {
       rmSync(home, { recursive: true, force: true })
     }

+ 6 - 0
apps/cli/tsconfig.json

@@ -70,6 +70,12 @@
     },
     {
       "path": "../../packages/shell/tool-bash"
+    },
+    {
+      "path": "../../packages/boot/plugin-manager"
+    },
+    {
+      "path": "../../packages/util/atomic-write"
     }
   ]
 }

+ 2 - 0
docs/architecture.md

@@ -28,6 +28,8 @@ Layers apply to an empty entry list in this order: each bundle in the profile's
 
 Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, `sdk-minimal`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle.
 
+The base bundle provides [Plugin Manager](../packages/boot/plugin-manager/README.md) for current-profile changes from Web settings and agent tools. Live profiles serialize manifest and patch reloads with package operations; startup profiles retain their running composition until restart.
+
 To see the tree your machine boots:
 
 ```sh

+ 2 - 0
docs/architecture.zh.md

@@ -28,6 +28,8 @@
 
 自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk`、`sdk-minimal` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。
 
+base 组合包提供[插件管理器](../packages/boot/plugin-manager/README.zh.md),通过 Web 设置和 Agent 工具修改当前 profile。实时 profile 将 manifest 与 patch 重载和包操作串行化;仅启动时加载的 profile 在重启前保留运行中的组合。
+
 要查看你的机器启动的配置树:
 
 ```sh

+ 20 - 0
docs/config-catalog.md

@@ -1775,6 +1775,26 @@ export interface PlanModeConfig {
 
 Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/src/index.ts)
 
+<a id="deepseek-aidsh-plugin-manager"></a>
+
+## `@deepseek-ai/dsh-plugin-manager`
+
+Requires: `loader` · `profileRuntime`
+
+```ts config-catalog
+/** Limits for package diagnostics and change notifications. */
+export interface Config {
+  /** Maximum retained pnpm diagnostic bytes per operation. */
+  outputBytes?: number
+  /** Delay for combining consecutive management notices in one durable injection. */
+  notificationDelayMs?: number
+  /** Maximum time to wait for another process's profile package operation. */
+  lockWaitMs?: number
+}
+```
+
+Source: [`packages/boot/plugin-manager/src/index.ts:19`](../packages/boot/plugin-manager/src/index.ts)
+
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
 ## `@deepseek-ai/dsh-plugin-package-inventory-deepseek`

+ 8 - 0
docs/module-graph.md

@@ -126,6 +126,7 @@ flowchart TD
   subgraph group_boot["packages/boot"]
     pkg_app_boot["app-boot"]
     pkg_cmdline["cmdline"]
+    pkg_plugin_manager["plugin-manager"]
   end
   subgraph group_browser_use["packages/browser-use"]
     pkg_browser_use["browser-use"]
@@ -1080,6 +1081,12 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
+  pkg_plugin_manager --> pkg_agent
+  pkg_plugin_manager --> pkg_host_plugin_inventory
+  pkg_plugin_manager --> pkg_llm
+  pkg_plugin_manager --> pkg_subprocess
+  pkg_plugin_manager --> pkg_tools
+  pkg_plugin_manager --> pkg_typert_protocol
   pkg_headless --> pkg_agent
   pkg_headless --> pkg_agent_default_model
   pkg_headless --> pkg_fs
@@ -1531,6 +1538,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 8 - 0
docs/module-graph.zh.md

@@ -128,6 +128,7 @@ flowchart TD
   subgraph group_boot["packages/boot"]
     pkg_app_boot["app-boot"]
     pkg_cmdline["cmdline"]
+    pkg_plugin_manager["plugin-manager"]
   end
   subgraph group_browser_use["packages/browser-use"]
     pkg_browser_use["browser-use"]
@@ -1082,6 +1083,12 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
+  pkg_plugin_manager --> pkg_agent
+  pkg_plugin_manager --> pkg_host_plugin_inventory
+  pkg_plugin_manager --> pkg_llm
+  pkg_plugin_manager --> pkg_subprocess
+  pkg_plugin_manager --> pkg_tools
+  pkg_plugin_manager --> pkg_typert_protocol
   pkg_headless --> pkg_agent
   pkg_headless --> pkg_agent_default_model
   pkg_headless --> pkg_fs
@@ -1533,6 +1540,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 1 - 0
docs/subsystems/README.md

@@ -6,6 +6,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
 
 | Page | Owns |
 |---|---|
+| [boot.md](boot.md) | Current-profile plugin management and launcher reload coordination |
 | [core.md](core.md) | how `packages/core` controls the agent loop: the package-by-package loop description, agent creation and ownership (`AgentHandle`), the `Agent` handle's delivery/cancellation/interception contracts, and the repo-wide type patterns (`…Map → derived-union`, branded ids) |
 | [llm-streaming.md](llm-streaming.md) | the `packages/llm` conversation types — `Message`/`ContentBlock`, the assembled model request, the `StreamChunk` wire protocol and adapter contract, `BlockAssembler`, and the `LlmAdapter` provider contract |
 | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions |

+ 1 - 0
docs/subsystems/README.zh.md

@@ -6,6 +6,7 @@
 
 | 页面 | 负责内容 |
 |---|---|
+| [boot.zh.md](boot.zh.md) | 当前 profile 插件管理与 launcher 重载协调 |
 | [core.md](core.zh.md) | `packages/core` 如何控制 agent loop(智能体循环):逐包的循环说明、agent 创建与所有权(`AgentHandle`)、`Agent` 句柄的投递/取消/拦截约定,以及全仓通用类型模式(`…Map → derived-union`、品牌化 id) |
 | [llm-streaming.md](llm-streaming.zh.md) | `packages/llm` 的对话类型——`Message`/`ContentBlock`、组装完成的模型请求、`StreamChunk` wire protocol 和适配器约定(adapter contract)、`BlockAssembler`,以及 `LlmAdapter` 提供方约定 |
 | [token-meter.md](token-meter.zh.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 |

+ 103 - 0
docs/subsystems/boot.md

@@ -0,0 +1,103 @@
+# Profile management
+
+English | [中文](boot.zh.md)
+
+The [boot package group](../../packages/boot/README.md) owns launcher-provided profile access and the plugin manager. [Plugin Manager](../../packages/boot/plugin-manager/README.md) documents persistence, reload and package-operation behavior.
+
+## Management records
+
+`PluginEntryId` identifies one Loader entry; callers obtain it from `listPlugins` rather than constructing a patch id.
+
+`PluginInfo` carries module identity, effective enablement and fiber phase, plus a unique `patchId` or a `readOnlyReason`.
+
+`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error.
+
+`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer.
+
+`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. `message` describes the result. Optional `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path.
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxpluginmanager--pluginmanager"></a>
+
+### `ctx.pluginManager` — `PluginManager`
+
+Manage profile files and apply their declared reload lifecycle.
+
+```ts cordis-catalog
+/** Read current plugins, including why a row cannot be changed through the profile patch.
+ * @returns Current runtime entries with persistent patch targets.
+ */
+@Remote async listPlugins(): Promise<PluginInfo[]>
+
+/** Read installed bundles and bundles supplied by this dsh installation.
+ * @returns Package versions, activation selections and removal availability.
+ */
+@Remote listBundles(): Promise<BundleInfo[]>
+
+/** Persist a plugin entry's desired enablement and apply it on live profiles.
+ * @param id Loader entry identity returned by listPlugins.
+ * @param enabled Whether the plugin should run.
+ * @returns Saved and runtime outcomes, including higher-priority overrides.
+ */
+@Remote setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>
+
+/** Select or remove a bundle layer while retaining installed dependencies.
+ * @param name Bundle package name.
+ * @param enabled Whether the bundle contributes its patch layer.
+ * @returns Persisted and runtime outcomes.
+ */
+@Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
+
+/** Install a package using the same pnpm implementation as dsh plugin.
+ * @param spec One package spec, including local paths relative to the invocation directory.
+ * @param options Whether to activate the installed bundle; defaults to true.
+ * @returns Package-manager diagnostics and observed activation outcome.
+ */
+@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>
+
+/** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
+ * @param name Installed dependency name.
+ * @returns Removal diagnostics and the remaining profile state.
+ */
+@Remote removeBundle(name: string): Promise<ChangeResult>
+```
+
+Source: [`packages/boot/plugin-manager/src/index.ts`](../../packages/boot/plugin-manager/src/index.ts)
+
+<a id="ctxprofileruntime--profileruntime"></a>
+
+### `ctx.profileRuntime` — `ProfileRuntime`
+
+Current-process profile operations; callbacks run under the shared profile write lock.
+
+```ts cordis-catalog
+/** Read the current manifest and bundle patch layers without initializing a profile.
+ * @returns Resolved disk configuration.
+ */
+read(): Profile
+
+/** Compose disk configuration with the invocation's higher-priority layers.
+ * @returns Effective entry options in composition order.
+ */
+entries(): EntryOptions[]
+
+/** Serialize a mutation with file watching and other profile writers.
+ * @param operation Work performed while holding the profile manifest lock.
+ * @param waitMs Maximum lock acquisition time; omission uses the file writer default.
+ * @returns The operation's result.
+ */
+mutate<T>(operation: () => Promise<T>, waitMs?: number): Promise<T>
+
+/** Apply the current disk configuration; call only inside mutate. */
+reload(): Promise<void>
+```
+
+Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)
+<!-- END GENERATED cordis-surface -->

+ 103 - 0
docs/subsystems/boot.zh.md

@@ -0,0 +1,103 @@
+# Profile 管理
+
+[English](boot.md) | 中文
+
+[boot 包组](../../packages/boot/README.zh.md)负责 launcher 提供的 profile 访问与插件管理器。[插件管理器](../../packages/boot/plugin-manager/README.zh.md)文档说明持久化、重载与包操作行为。
+
+## 管理记录
+
+`PluginEntryId` 标识一个 Loader 条目;调用方从 `listPlugins` 获取,不自行拼接 patch id。
+
+`PluginInfo` 包含模块标识、实际启停状态和 fiber 阶段,以及唯一的 `patchId` 或 `readOnlyReason`。
+
+`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。
+
+`InstallBundleOptions.enabled` 默认为 true。False 表示安装但不选择该组合包层。
+
+`ChangeResult.changed` 独立报告磁盘修改,`application` 为 `applied`、`restart-required`、`overridden` 或 `failed`。`message` 描述结果。可选的 `packageResult` 记录 pnpm 退出码、有界输出、截断标记与完整诊断日志路径。
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxpluginmanager--pluginmanager"></a>
+
+### `ctx.pluginManager` — `PluginManager`
+
+Manage profile files and apply their declared reload lifecycle.
+
+```ts cordis-catalog
+/** Read current plugins, including why a row cannot be changed through the profile patch.
+ * @returns Current runtime entries with persistent patch targets.
+ */
+@Remote async listPlugins(): Promise<PluginInfo[]>
+
+/** Read installed bundles and bundles supplied by this dsh installation.
+ * @returns Package versions, activation selections and removal availability.
+ */
+@Remote listBundles(): Promise<BundleInfo[]>
+
+/** Persist a plugin entry's desired enablement and apply it on live profiles.
+ * @param id Loader entry identity returned by listPlugins.
+ * @param enabled Whether the plugin should run.
+ * @returns Saved and runtime outcomes, including higher-priority overrides.
+ */
+@Remote setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>
+
+/** Select or remove a bundle layer while retaining installed dependencies.
+ * @param name Bundle package name.
+ * @param enabled Whether the bundle contributes its patch layer.
+ * @returns Persisted and runtime outcomes.
+ */
+@Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
+
+/** Install a package using the same pnpm implementation as dsh plugin.
+ * @param spec One package spec, including local paths relative to the invocation directory.
+ * @param options Whether to activate the installed bundle; defaults to true.
+ * @returns Package-manager diagnostics and observed activation outcome.
+ */
+@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>
+
+/** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
+ * @param name Installed dependency name.
+ * @returns Removal diagnostics and the remaining profile state.
+ */
+@Remote removeBundle(name: string): Promise<ChangeResult>
+```
+
+Source: [`packages/boot/plugin-manager/src/index.ts`](../../packages/boot/plugin-manager/src/index.ts)
+
+<a id="ctxprofileruntime--profileruntime"></a>
+
+### `ctx.profileRuntime` — `ProfileRuntime`
+
+Current-process profile operations; callbacks run under the shared profile write lock.
+
+```ts cordis-catalog
+/** Read the current manifest and bundle patch layers without initializing a profile.
+ * @returns Resolved disk configuration.
+ */
+read(): Profile
+
+/** Compose disk configuration with the invocation's higher-priority layers.
+ * @returns Effective entry options in composition order.
+ */
+entries(): EntryOptions[]
+
+/** Serialize a mutation with file watching and other profile writers.
+ * @param operation Work performed while holding the profile manifest lock.
+ * @param waitMs Maximum lock acquisition time; omission uses the file writer default.
+ * @returns The operation's result.
+ */
+mutate<T>(operation: () => Promise<T>, waitMs?: number): Promise<T>
+
+/** Apply the current disk configuration; call only inside mutate. */
+reload(): Promise<void>
+```
+
+Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)
+<!-- END GENERATED cordis-surface -->

+ 50 - 0
docs/tool-catalog.md

@@ -15,6 +15,7 @@ This table connects model-visible tool names to the plugin package and service s
 
 | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |
 | --- | --- | --- | --- | --- | --- |
+| `@deepseek-ai/dsh-plugin-manager` | `plugin_manager` | `ctx.tools`, `ctx.pluginManager` | `tool/call`, `tool/result`, `user/message` | - | - |
 | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
@@ -44,6 +45,55 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
 
+<a id="deepseek-aidsh-plugin-manager"></a>
+
+## `@deepseek-ai/dsh-plugin-manager`
+
+### `plugin_manager`
+
+List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "action": {
+      "type": "string",
+      "description": "Management operation.",
+      "enum": [
+        "list_plugins",
+        "list_bundles",
+        "set_plugin",
+        "set_bundle",
+        "install_bundle",
+        "remove_bundle"
+      ]
+    },
+    "target": {
+      "type": "string",
+      "description": "Plugin entry id, bundle package name, or installation spec, according to action."
+    },
+    "enabled": {
+      "type": "boolean",
+      "description": "Required for set operations; defaults to true for installation."
+    },
+    "offset": {
+      "type": "number",
+      "description": "Zero-based list offset; defaults to 0."
+    },
+    "limit": {
+      "type": "number",
+      "description": "List page size, from 1 to 100; defaults to 25."
+    }
+  },
+  "required": [
+    "action"
+  ]
+}
+```
+
+Source: [`packages/boot/plugin-manager/src/tools.ts`](../packages/boot/plugin-manager/src/tools.ts)
+
 <a id="deepseek-aidsh-mcp-resources"></a>
 
 ## `@deepseek-ai/dsh-mcp-resources`

+ 2 - 1
packages/api/remotes/package.json

@@ -87,6 +87,7 @@
     "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
     "zod": "^4.4.3",
     "@deepseek-ai/dsh-command-feedback": "workspace:^",
-    "@deepseek-ai/dsh-api-terminal-controller": "workspace:^"
+    "@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
+    "@deepseek-ai/dsh-plugin-manager": "workspace:^"
   }
 }

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

@@ -7,6 +7,7 @@ import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/r
 import goalsRemote from '@deepseek-ai/dsh-goal/remote'
 import llmRemote from '@deepseek-ai/dsh-llm/remote'
 import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
+import pluginManagerRemote from '@deepseek-ai/dsh-plugin-manager/remote'
 import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
 import permissionPresetsRemote from '@deepseek-ai/dsh-permission-presets/remote'
@@ -21,6 +22,8 @@ import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
 
 export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
+export type { PluginInfo, BundleInfo, ChangeResult, PluginEntryId } from '@deepseek-ai/dsh-plugin-manager/types'
+export type {} from '@deepseek-ai/dsh-plugin-manager/remote'
 export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
 export type {} from '@deepseek-ai/dsh-agent-presets/remote'
 export type {} from '@deepseek-ai/dsh-commands/remote'
@@ -158,7 +161,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
   try {
     for (const contribution of [
       agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
-      pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
+      pluginInventoryRemote, pluginManagerRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
       permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))

+ 3 - 0
packages/api/remotes/tsconfig.client.json

@@ -88,6 +88,9 @@
     },
     {
       "path": "../terminal-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../boot/plugin-manager"
     }
   ]
 }

+ 4 - 1
packages/boot/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-The boot group provides what every dsh app bin needs to start: `app-boot` turns a `cordis.yml` plus your environment and patch layers into a running app with clear failure messages, and `cmdline` lets the app own its command-line flags and `--help`. With these packages you can run `dsh` or write a new application or test fixture that boots the same way. Both are libraries imported by `apps/cli` and test-only Loader fixtures, never plugins a composition loads. This page maps the group; each package README owns its per-package contract.
+The boot group launches profile applications and manages their installed composition. `app-boot` resolves configuration and starts the Loader, `cmdline` supplies application arguments, and `plugin-manager` exposes current-profile operations shared with the CLI. Each package README owns its details.
 
 ## Table of Contents
 
@@ -24,6 +24,7 @@ The boot group provides what every dsh app bin needs to start: `app-boot` turns
 |---|---|---|
 | [`app-boot`](app-boot/README.md) | Boots a dsh app from a `cordis.yml`: loads `.env`, applies profile and patch layers, and reports startup failures clearly | (library for the bins) |
 | [`cmdline`](cmdline/README.md) | Lets the app own its flags, `--help`, and exit code; passes everything after the launcher's flags through verbatim | `cmdlineArgs`, `appExit` |
+| [`plugin-manager`](plugin-manager/README.md) | Manages current-profile plugins and bundle packages through shared CLI operations | `pluginManager` |
 
 <a id="related-documentation"></a>
 ## Related documentation
@@ -33,6 +34,8 @@ The boot group provides what every dsh app bin needs to start: `app-boot` turns
 - [dsh-home-paths](../util/home-paths/README.md) — the harness-home resolver both packages build on.
 - [dsh-cmdline](cmdline/README.md) — how an app owns its flag family instead of the launcher.
 
+- [Profile management](../../docs/subsystems/boot.md) — service methods and result records.
+
 <a id="dev-note"></a>
 ## Dev Note
 

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

@@ -9,7 +9,7 @@ kind: "package-group"
 
 ## 概述
 
-boot 组提供每个 dsh app bin 启动所需的全部能力:`app-boot` 把 `cordis.yml` 连同你的环境与 patch 层变成运行中的应用,并给出清晰的失败信息;`cmdline` 让应用持有自己的命令行 flag 与 `--help`。借助这些包,你可以运行 `dsh`,也可以编写以同样方式启动的新应用或测试用 fixture(测试前置数据)。两者都是 `apps/cli` 与测试专用 Loader fixture 导入的库,绝不是组合加载的插件。本页列出该包组的构成;各包 README 负责各自的包级约定。
+boot 组负责启动 profile 应用并管理其已安装组合。`app-boot` 解析配置并启动 Loader,`cmdline` 提供应用参数,`plugin-manager` 提供与 CLI 共享的当前 profile 操作。各包 README 负责各自的细节。
 
 ## 目录
 
@@ -24,6 +24,7 @@ boot 组提供每个 dsh app bin 启动所需的全部能力:`app-boot` 把 `c
 |---|---|---|
 | [`app-boot`](app-boot/README.zh.md) | 从 `cordis.yml` 启动 dsh 应用:加载 `.env`、应用 profile 与 patch 层,并清晰报告启动失败 | (供各 bin 使用的库) |
 | [`cmdline`](cmdline/README.zh.md) | 让应用持有自己的 flag、`--help` 与退出码;启动器自身 flag 之后的一切原样传入 | `cmdlineArgs`、`appExit` |
+| [`plugin-manager`](plugin-manager/README.zh.md) | 通过共享 CLI 操作管理当前 profile 插件与组合包 | `pluginManager` |
 
 <a id="related-documentation"></a>
 ## 相关文档
@@ -33,6 +34,8 @@ boot 组提供每个 dsh app bin 启动所需的全部能力:`app-boot` 把 `c
 - [dsh-home-paths](../util/home-paths/README.zh.md)——两个包都依赖的 harness home 解析器。
 - [dsh-cmdline](cmdline/README.zh.md)——flag 家族如何由应用持有而非启动器。
 
+- [Profile 管理](../../docs/subsystems/boot.zh.md)——服务方法与结果记录。
+
 <a id="dev-note"></a>
 ## 开发备注
 

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

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

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

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

+ 22 - 15
packages/boot/app-boot/src/index.ts

@@ -19,6 +19,8 @@ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
 import { watchConfig } from './watch-config.ts'
+export { watchConfig } from './watch-config.ts'
+export type { ProfileRuntime } from './profile-runtime.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
 declare module '@deepseek-ai/cordis' {
@@ -258,21 +260,7 @@ export async function watchUserPatches(
   const entry = bootstrapIncludes.get(ctx)
   if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
   const register = watchConfig(ctx, filename, hmr.config, async () => {
-    // Re-read the include's non-patch options per refresh so a writer that
-    // updates another option between refreshes is not silently reverted.
-    const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
-    const userPatches = loadOptionalPatches(binName, filename) ?? []
-    const patches = compose(userPatches)
-    await entry.update({
-      config: {
-        ...includeConfig,
-        patches,
-      },
-    })
-    await ctx.loader.await()
-    await Promise.allSettled([...ctx.loader.entries()].map(entry => Promise.resolve(entry.fiber?.await())))
-    const failures = await inactiveEntries(ctx)
-    if (failures.length > 0) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd())
+    await reconcileProfilePatches(ctx, compose(loadOptionalPatches(binName, filename) ?? []), binName)
   })
   try {
     return await register
@@ -286,6 +274,25 @@ export async function watchUserPatches(
   }
 }
 
+/** Apply one complete patch generation and wait for Loader activation diagnostics.
+ * @param ctx Booted root context.
+ * @param patches Complete ordered patch list.
+ * @param binName Diagnostic prefix.
+ */
+export async function reconcileProfilePatches(ctx: Context, patches: PatchOptions[], binName: string): Promise<void> {
+  const entry = bootstrapIncludes.get(ctx)
+  if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`)
+  // Removed entries leave the Loader store before their async disposers finish.
+  const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber])
+  const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config
+  await entry.update({ config: { ...includeConfig, patches } })
+  const results = await Promise.allSettled(previousFibers.map(fiber => fiber.await()))
+  await ctx.loader.await()
+  const failures = await inactiveEntries(ctx)
+  if (failures.length > 0) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd())
+  for (const result of results) if (result.status === 'rejected') throw result.reason
+}
+
 /**
  * Load an optional patch-list file: a top-level YAML array of loader patch
  * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config

+ 38 - 0
packages/boot/app-boot/src/profile-runtime.ts

@@ -0,0 +1,38 @@
+/** Launcher-provided access to the current profile's serialized configuration lifecycle. */
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { Profile } from './profile.ts'
+
+/** Current-process profile operations; callbacks run under the shared profile write lock. */
+export interface ProfileRuntime {
+  readonly name: string
+  readonly dir: string
+  readonly installAnchor: string
+  readonly cwd: string
+  readonly home: string
+  /** Bundle packages used to start this process, before any persisted edits. */
+  readonly startedBundles: readonly string[]
+  readonly patchReload: 'live' | 'startup'
+  /** Read the current manifest and bundle patch layers without initializing a profile.
+   * @returns Resolved disk configuration.
+   */
+  read(): Profile
+  /** Compose disk configuration with the invocation's higher-priority layers.
+   * @returns Effective entry options in composition order.
+   */
+  entries(): EntryOptions[]
+  /** Serialize a mutation with file watching and other profile writers.
+   * @param operation Work performed while holding the profile manifest lock.
+   * @param waitMs Maximum lock acquisition time; omission uses the file writer default.
+   * @returns The operation's result.
+   */
+  mutate<T>(operation: () => Promise<T>, waitMs?: number): Promise<T>
+  /** Apply the current disk configuration; call only inside mutate. */
+  reload(): Promise<void>
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Present only in a profile launched by dsh. */
+    profileRuntime: ProfileRuntime
+  }
+}

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

@@ -20,6 +20,7 @@ import {
   loadOptionalPatches,
   loadOverlayPatches,
   PROFILE_PATCH_FILENAME,
+  reconcileProfilePatches,
   watchUserPatches,
 } from '../src/index.ts'
 
@@ -355,6 +356,36 @@ describe('Loader entry disabled interpolation', () => {
   })
 })
 
+describe('profile reconciliation settlement', () => {
+  it('waits for a removed plugin to release its resources', async () => {
+    const dir = tmp()
+    const started = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    writeFileSync(join(dir, 'cordis.yml'), '[]\n')
+    writeFileSync(join(dir, 'held.mjs'), [
+      'export function apply(ctx) {',
+      '  ctx.effect(() => async () => {',
+      '    ctx.get("reloadProbe").started()',
+      '    await ctx.get("reloadProbe").release',
+      '  })',
+      '}',
+      '',
+    ].join('\n'))
+    const ctx = await boot(NAME, join(dir, 'cordis.yml'), [{ insert: [{ id: 'held', name: './held.mjs' }] }], (host) => {
+      host.provide('reloadProbe', { started: () => started.resolve(undefined), release: release.promise })
+    })
+    onTestFinished(async () => { release.resolve(undefined); await ctx.fiber.dispose() })
+    let settled = false
+    const operation = reconcileProfilePatches(ctx, [], NAME).then(() => { settled = true })
+    await started.promise
+    expect([...ctx.loader.entries()].some(entry => entry.options.id === 'held')).toBe(false)
+    expect(settled).toBe(false)
+    release.resolve(undefined)
+    await operation
+    expect(settled).toBe(true)
+  })
+})
+
 describe('boot with user patches', () => {
   it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
     const dir = tmp()

+ 111 - 0
packages/boot/plugin-manager/README.md

@@ -0,0 +1,111 @@
+---
+description: "Enable profile plugins and install, remove or select bundles from the Web settings page or an agent."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-plugin-manager
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Manage the current profile's plugins without editing configuration by hand. Enable or disable individual plugin entries, select installed bundles, and install or remove external bundles. Live profiles apply configuration changes immediately; startup-only profiles retain their running composition until restart. Changes affect every session using the profile.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Base-backed profiles provide the manager. In Web Settings, open Plugins and select Plugin list to manage bundles and uniquely addressable global plugin entries. Agent-preset rows remain read-only. The `plugin_manager` tool exposes the same operations.
+
+A plugin toggle writes only its `disabled` override in the profile's `cordis.patch.yml`. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority.
+
+### Configuration
+
+| Field | Default | Meaning |
+|---|---|---|
+| `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
+| `notificationDelayMs` | `250` | Delay in milliseconds for combining operation notices. |
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile and serializes file watching with management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. Package operations hold the profile manifest lock; file watchers read the completed state after its release.
+
+Saved configuration, package-manager completion and runtime activation are separate outcomes. Failures retain partial changes and diagnostics rather than automatically restoring files or packages. The manager reads files and Loader state directly instead of maintaining a second desired-state registry; it therefore publishes no separate runtime invariant companion.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [App boot](../app-boot/README.md) — profile layers and startup policy.
+- [Plugin inventory](../../host/plugin-inventory/README.md) — current Loader and preset observations.
+- [Plugin settings](../../client/ui-settings-plugin-inventory/README.md) — Web controls.
+
+<a id="model-experience"></a>
+## Model Experience
+
+### Management tool
+
+#### What the model sees
+
+The [`plugin_manager` tool](../../../docs/tool-catalog.md#plugin-manager) lists plugin entries and bundles and performs profile-wide changes. Its results include saved-state changes, application status and package diagnostics.
+
+#### Token effect
+
+The tool declaration is present when its consumer is mounted; each invocation adds its returned inventory or change result.
+
+#### KV Cache effect
+
+Tool results append to the transcript. Enabling or disabling other tools can change subsequent tool declarations and their cache reuse.
+
+### Configuration change notices
+
+#### What the model sees
+
+Consecutive operation results are combined within `notificationDelayMs` and injected into each affected live Agent. Notices include the application outcome, disclose omitted results when the configured output bound is reached, and do not wake an idle Agent.
+
+#### Token effect
+
+Notices add conditional user-message context to each affected Agent.
+
+#### KV Cache effect
+
+Notices append context; they do not rewrite earlier messages.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- Package replacements require restarting the process to load a fresh JavaScript module generation.
+- Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`.
+- The manager cannot disable its own management components, change another profile, or edit an agent preset's composition.
+- Package failures may leave dependencies partially changed. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory.
+- Desktop package operations remain owned by the Desktop shell.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 111 - 0
packages/boot/plugin-manager/README.zh.md

@@ -0,0 +1,111 @@
+---
+description: "通过 Web 设置页或 agent 启停 profile 插件,并安装、删除或选择组合包。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-plugin-manager
+
+[English](README.md) | 中文
+
+## Summary
+
+管理当前 profile 的插件,无需手动编辑配置。启停单个插件条目、选择已安装的组合包,以及安装或删除外部组合包。live profile 立即应用配置变化;仅启动时加载的 profile 在重启前保留运行中的组合。改动影响使用该 profile 的全部会话。
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+基于 base 的 profile 提供管理服务。在 Web 设置中打开插件并选择插件列表,即可管理组合包和能唯一定位的全局插件条目。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作。
+
+插件开关只写入 profile 的 `cordis.patch.yml` 中的 `disabled` 覆盖项。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
+
+### Configuration
+
+| Field | Default | Meaning |
+|---|---|---|
+| `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
+| `notificationDelayMs` | `250` | 合并操作通知的延迟毫秒数。 |
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile,并串行执行文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。包管理操作持有 profile manifest 锁;文件监听器在锁释放后读取完成的状态。
+
+配置保存、包管理器完成和运行时激活分别报告。失败保留部分改动和诊断,不自动恢复文件或包。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [App boot](../app-boot/README.zh.md)——profile 配置层与启动策略。
+- [Plugin inventory](../../host/plugin-inventory/README.zh.md)——当前 Loader 和预设状态。
+- [Plugin settings](../../client/ui-settings-plugin-inventory/README.zh.md)——Web 控件。
+
+<a id="model-experience"></a>
+## Model Experience
+
+### Management tool
+
+#### What the model sees
+
+[`plugin_manager` 工具](../../../docs/tool-catalog.zh.md#plugin-manager) 列出插件条目和组合包,并执行影响整个 profile 的改动。结果包含保存状态变化、应用状态和包管理诊断。
+
+#### Token effect
+
+装配工具消费者时提供工具声明;每次调用追加返回的清单或改动结果。
+
+#### KV Cache effect
+
+工具结果追加到对话中。启停其他工具可能改变后续工具声明及其缓存复用。
+
+### Configuration change notices
+
+#### What the model sees
+
+连续操作结果在 `notificationDelayMs` 内合并后注入每个受影响的存活 Agent。通知包含应用结果,达到配置的输出上限时标明省略的结果数,不会唤醒空闲 Agent。
+
+#### Token effect
+
+通知按需向每个受影响 Agent 追加用户消息上下文。
+
+#### KV Cache effect
+
+通知追加上下文,不改写先前消息。
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。
+- 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。
+- 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。
+- 包管理失败可能留下部分依赖改动。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。
+- Desktop 包管理操作仍由 Desktop shell 负责。
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 90 - 0
packages/boot/plugin-manager/package.json

@@ -0,0 +1,90 @@
+{
+  "name": "@deepseek-ai/dsh-plugin-manager",
+  "description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools",
+  "version": "0.1.5-rc.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/boot/plugin-manager"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./typert": {
+      "types": "./lib/typert.host.d.ts",
+      "default": "./lib/typert.host.js"
+    },
+    "./remote": {
+      "types": "./lib/typert.remote-client.d.ts",
+      "default": "./lib/typert.remote-client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json",
+    "./operations": {
+      "types": "./lib/types/operations.d.ts",
+      "default": "./lib/types/operations.js"
+    },
+    "./tools": {
+      "types": "./lib/types/tools.d.ts",
+      "default": "./lib/types/tools.js"
+    }
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts",
+    "lib/typert.host.js",
+    "lib/typert.host.d.ts",
+    "lib/typert.remote-client.js",
+    "lib/typert.remote-client.d.ts"
+  ],
+  "license": "MIT",
+  "dependencies": {
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-atomic-write": "workspace:^",
+    "@deepseek-ai/schemastery": "workspace:^",
+    "execa": "^10.0.0",
+    "yaml": "^2.9.0",
+    "zod": "^4.4.3",
+    "@deepseek-ai/dsh-util-values": "workspace:^"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^"
+  },
+  "peerDependenciesMeta": {
+    "@deepseek-ai/dsh-agent": {
+      "optional": true
+    }
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^"
+  }
+}

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

@@ -0,0 +1,334 @@
+/** Current-profile plugin and bundle management over shared dsh plugin operations. */
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import z from '@deepseek-ai/schemastery'
+import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
+import { readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
+import { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries } from '@deepseek-ai/dsh-app-boot'
+import type { ProfileRuntime } from '@deepseek-ai/dsh-app-boot'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { bundleManifest, runProfilePnpm, saveManifest } from './operations.ts'
+import { writePluginEnabled } from './patch.ts'
+import type { BundleInfo, ChangeResult, InstallBundleOptions, PackageResult, PluginEntryId, PluginInfo } from './types.ts'
+export type * from './types.ts'
+
+/** Limits for package diagnostics and change notifications. */
+export interface Config {
+  /** Maximum retained pnpm diagnostic bytes per operation. */
+  outputBytes?: number
+  /** Delay for combining consecutive management notices in one durable injection. */
+  notificationDelayMs?: number
+  /** Maximum time to wait for another process's profile package operation. */
+  lockWaitMs?: number
+}
+
+const protectedModules = new Set([
+  '@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/cordis-plugin-loader',
+  '@deepseek-ai/cordis-plugin-include', '@deepseek-ai/dsh-api-gateway',
+  '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules',
+  '@deepseek-ai/dsh-client-ui-settings-plugin-inventory',
+  '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection',
+  '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools',
+  '@deepseek-ai/dsh-plugin-manager/tools',
+])
+
+/** Flatten only the groups addressable by the profile's patch composer. */
+function flatten(rows: EntryOptions[]): EntryOptions[] {
+  return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])])
+}
+
+/** Preserve the exact observed diagnostic, including non-Error failures. */
+function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Persistent management of the current profile's composition and packages. */
+    pluginManager: PluginManager
+  }
+}
+
+/** Manage profile files and apply their declared reload lifecycle. */
+export class PluginManager extends TypertRemoteService {
+  static inject = ['loader', 'profileRuntime']
+  static Config: z<Config> = z.object({
+    outputBytes: z.number().step(1).min(1).default(16384),
+    notificationDelayMs: z.number().step(1).min(0).default(250),
+    lockWaitMs: z.number().step(1).min(0).default(120000),
+  })
+  private readonly ownerEntryId: string | undefined
+  private readonly packageOperations = new Set<Promise<PackageResult>>()
+  private readonly runtime: ProfileRuntime
+  private readonly outputBytes: number
+  private readonly notificationDelayMs: number
+  private readonly lockWaitMs: number
+  private readonly ownerContext: Context
+  private pendingNotice = ''
+  private omittedNotices = 0
+  private noticeTimer: ReturnType<typeof setTimeout> | undefined
+  private noticeDelivered: PromiseWithResolvers<void> | undefined
+  private readonly noticeAgents = new Set<Agent>()
+  private readonly abort = new AbortController()
+
+  constructor(ctx: Context, config: Config) {
+    super(ctx, 'pluginManager')
+    this.ownerEntryId = ctx.fiber.entry?.id
+    this.ownerContext = ctx
+    this.runtime = ctx.profileRuntime
+    this.outputBytes = (config as Required<Config>).outputBytes
+    this.notificationDelayMs = (config as Required<Config>).notificationDelayMs
+    this.lockWaitMs = (config as Required<Config>).lockWaitMs
+    ctx.effect(() => async () => {
+      this.abort.abort()
+      await Promise.allSettled([...this.packageOperations])
+      clearTimeout(this.noticeTimer)
+      this.flushNotice()
+    }, 'plugin-manager: package cancellation')
+  }
+
+  /** Read current plugins, including why a row cannot be changed through the profile patch.
+   * @returns Current runtime entries with persistent patch targets.
+   */
+  @Remote
+  async listPlugins(): Promise<PluginInfo[]> {
+    const rows = flatten(this.runtime.entries())
+    const snapshot = await readPluginInventory(this.ctx)
+    return snapshot.entries.map((entry) => {
+      const actual = [...this.ctx.loader.entries()].find(row => row.id === entry.entryId)
+      const candidates = rows.filter(row => row.id === actual?.options.id)
+      const candidate = candidates[0]
+      const readOnlyReason = protectedModules.has(entry.moduleName) || entry.entryId === this.ownerEntryId
+        ? 'Required for plugin management.'
+        : actual?.parent.tree.ctx.fiber.entry?.id !== 'include'
+          || candidates.length !== 1 || candidate?.name !== entry.moduleName
+          ? 'This entry is not uniquely addressable by the profile patch.' : undefined
+      return { ...entry,
+        ...(candidate !== undefined && readOnlyReason === undefined ? { patchId: candidate.id } : {}),
+        ...(readOnlyReason === undefined ? {} : { readOnlyReason }),
+      }
+    })
+  }
+
+  /** Read installed bundles and bundles supplied by this dsh installation.
+   * @returns Package versions, activation selections and removal availability.
+   */
+  @Remote
+  listBundles(): Promise<BundleInfo[]> {
+    const manifest = readProfileManifest('dsh', this.runtime.dir)
+    const selected = manifest.dsh?.profile?.bundles ?? []
+    const dependencies = Object.keys(manifest.dependencies ?? {})
+    const installation = JSON.parse(readFileSync(this.runtime.installAnchor, 'utf8')) as { dependencies?: Record<string, string> }
+    const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])]
+    const bundles: BundleInfo[] = []
+    for (const name of names) {
+      const removable = dependencies.includes(name) && !Object.hasOwn(installation.dependencies ?? {}, name)
+      try {
+        const info = bundleManifest(name, this.runtime.dir, this.runtime.installAnchor)
+        if (info === undefined) {
+          if (selected.includes(name)) bundles.push({ name, enabled: true, removable, error: `Not a bundle: ${name}` })
+          continue
+        }
+        const readOnlyReason = this.protectsManager(name) ? 'This bundle provides plugin management components' : undefined
+        bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
+          enabled: selected.includes(name), removable: removable && readOnlyReason === undefined,
+          ...(readOnlyReason === undefined ? {} : { readOnlyReason }) })
+      } catch (error) {
+        if (selected.includes(name) || dependencies.includes(name)) {
+          bundles.push({ name, enabled: selected.includes(name), removable, error: messageOf(error) })
+        }
+      }
+    }
+    return Promise.resolve(bundles)
+  }
+
+  /** Persist a plugin entry's desired enablement and apply it on live profiles.
+   * @param id Loader entry identity returned by listPlugins.
+   * @param enabled Whether the plugin should run.
+   * @returns Saved and runtime outcomes, including higher-priority overrides.
+   */
+  @Remote
+  setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult> {
+    return this.change(async () => {
+      const row = (await this.listPlugins()).find(item => item.entryId === id)
+      if (row === undefined) throw new Error(`Unknown plugin entry: ${id}`)
+      if (row.readOnlyReason !== undefined || row.patchId === undefined) throw new Error(row.readOnlyReason)
+      await writePluginEnabled(this.runtime.read().patchPath, row.patchId, enabled)
+      await this.runtime.reload()
+      const current = (await this.listPlugins()).find(item => item.entryId === id)
+      return current?.enabled !== enabled && this.runtime.patchReload === 'live' ? 'overridden' : undefined
+    }, `Plugin ${id}: ${enabled ? 'enabled' : 'disabled'}.`)
+  }
+
+  /** Select or remove a bundle layer while retaining installed dependencies.
+   * @param name Bundle package name.
+   * @param enabled Whether the bundle contributes its patch layer.
+   * @returns Persisted and runtime outcomes.
+   */
+  @Remote
+  setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult> {
+    return this.change(async () => {
+      await this.selectBundle(name, enabled)
+      await this.runtime.reload()
+    }, `Bundle ${name}: ${enabled ? 'enabled' : 'disabled'}.`)
+  }
+
+  /** Install a package using the same pnpm implementation as dsh plugin.
+   * @param spec One package spec, including local paths relative to the invocation directory.
+   * @param options Whether to activate the installed bundle; defaults to true.
+   * @returns Package-manager diagnostics and observed activation outcome.
+   */
+  @Remote
+  installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult> {
+    let packageResult: PackageResult | undefined
+    return this.change(async () => {
+      if (spec.trim() === '' || spec.startsWith('-')) throw new Error('A package spec is required')
+      const before = readProfileManifest('dsh', this.runtime.dir).dependencies ?? {}
+      packageResult = await this.runPnpm(['add', spec])
+      if (packageResult.exitCode !== 0) throw new Error(packageResult.output)
+      const after = readProfileManifest('dsh', this.runtime.dir).dependencies ?? {}
+      const installed = Object.keys(after).filter(name => before[name] !== after[name])
+      // An exact package-name request can repeat an installation without changing its saved range.
+      if (installed.length === 0 && Object.hasOwn(after, spec)) installed.push(spec)
+      const name = installed[0]
+      if (installed.length !== 1 || name === undefined) throw new Error('Cannot identify one installed bundle from the dependency change')
+      const dir = resolveBundleDir('dsh', name, this.runtime.installAnchor, this.runtime.dir)
+      const manifest = bundleManifest(name, this.runtime.dir, this.runtime.installAnchor)
+      if (manifest?.dsh?.bundle?.patch === undefined) throw new Error(`${name} declares no dsh.bundle.patch`)
+      loadOverlayPatches('dsh', join(dir, manifest.dsh.bundle.patch))
+      if (options?.enabled !== false) await this.selectBundle(name, true)
+      if (Object.hasOwn(before, name)) return 'restart-required'
+      await this.runtime.reload()
+    }, `Bundle installation: ${spec}.`, () => packageResult)
+  }
+
+  /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
+   * @param name Installed dependency name.
+   * @returns Removal diagnostics and the remaining profile state.
+   */
+  @Remote
+  removeBundle(name: string): Promise<ChangeResult> {
+    let packageResult: PackageResult | undefined
+    return this.change(async () => {
+      const bundle = (await this.listBundles()).find(item => item.name === name)
+      if (bundle === undefined || !bundle.removable) throw new Error(`Bundle is not a profile-owned dependency: ${name}`)
+      if (this.runtime.patchReload === 'startup' && this.runtime.startedBundles.includes(name)) {
+        throw new Error('Stop this startup-only profile and remove the bundle with dsh plugin')
+      }
+      await this.selectBundle(name, false)
+      await this.runtime.reload()
+      packageResult = await this.runPnpm(['remove', name])
+      if (packageResult.exitCode !== 0) throw new Error(packageResult.output)
+      await this.runtime.reload()
+    }, `Bundle removed: ${name}.`, () => packageResult)
+  }
+
+  private async runPnpm(args: readonly string[]): Promise<PackageResult> {
+    const task = runProfilePnpm({ ...this.runtime, profile: this.runtime.name }, args, {
+      signal: this.abort.signal, outputBytes: this.outputBytes, activateNewBundles: false,
+    })
+    this.packageOperations.add(task)
+    try { return await task }
+    finally { this.packageOperations.delete(task) }
+  }
+
+  private async selectBundle(name: string, enabled: boolean): Promise<void> {
+    const manifest = readProfileManifest('dsh', this.runtime.dir)
+    const previous = manifest.dsh?.profile?.bundles ?? []
+    if ((enabled || !previous.includes(name)) && bundleManifest(name, this.runtime.dir, this.runtime.installAnchor) === undefined) {
+      throw new Error(`Not a bundle: ${name}`)
+    }
+    if (!enabled && previous.includes(name)) {
+      if (this.protectsManager(name)) throw new Error('This bundle provides plugin management components')
+    }
+    const bundles = enabled ? [...previous, ...previous.includes(name) ? [] : [name]] : previous.filter(item => item !== name)
+    if (JSON.stringify(previous) === JSON.stringify(bundles)) return
+    manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
+    await saveManifest(this.runtime.dir, manifest)
+  }
+
+  private protectsManager(name: string): boolean {
+    const info = bundleManifest(name, this.runtime.dir, this.runtime.installAnchor)
+    if (info?.dsh?.bundle === undefined) return false
+    const dir = resolveBundleDir('dsh', name, this.runtime.installAnchor, this.runtime.dir)
+    const rows = flatten(composeEntries([loadOverlayPatches('dsh', join(dir, info.dsh.bundle.patch))]))
+    return rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
+  }
+
+  private async change(
+    operation: () => Promise<ChangeResult['application'] | void>, message: string, packageResult?: () => PackageResult | undefined,
+  ): Promise<ChangeResult> {
+    let notice: Promise<void> | undefined
+    const result = await this.runtime.mutate(async () => {
+      this.abort.signal.throwIfAborted()
+      const before = this.diskState()
+      let application: ChangeResult['application'] = this.runtime.patchReload === 'live' ? 'applied' : 'restart-required'
+      try {
+        application = await operation() ?? application
+      } catch (error) {
+        application = 'failed'
+        message = messageOf(error)
+      }
+      const result: ChangeResult = { changed: before !== this.diskState(), application, message }
+      const packages = packageResult?.()
+      if (packages !== undefined) result.packageResult = packages
+      notice = this.notify(result)
+      return result
+    }, this.lockWaitMs)
+    await notice
+    return result
+  }
+
+  private diskState(): string {
+    return ['package.json', 'cordis.patch.yml'].map((file) => {
+      try { return readFileSync(join(this.runtime.dir, file), 'utf8') }
+      catch (error) {
+        if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
+        throw error
+      }
+    }).join('\u0000')
+  }
+
+  private notify(result: ChangeResult): Promise<void> {
+    const agents = this.ownerContext.get('agents')?.list() ?? []
+    if (agents.length === 0) return Promise.resolve()
+    for (const agent of agents) this.noticeAgents.add(agent)
+    this.noticeDelivered ??= Promise.withResolvers<void>()
+    const delivered = this.noticeDelivered.promise
+    const notice = `Profile ${this.runtime.name}: ${result.message} (${result.application})`
+    if (Buffer.byteLength(this.pendingNotice + notice) > this.outputBytes) {
+      this.omittedNotices += 1
+    } else {
+      this.pendingNotice += `${notice}\n`
+    }
+    if (this.noticeTimer !== undefined) return delivered
+    this.noticeTimer = setTimeout(() => { this.flushNotice() }, this.notificationDelayMs)
+    return delivered
+  }
+
+  private flushNotice(): void {
+    this.noticeTimer = undefined
+    const text = this.pendingNotice + (this.omittedNotices === 0 ? '' : `${this.omittedNotices} additional operations omitted; query plugin_manager for current state.\n`)
+    this.pendingNotice = ''
+    this.omittedNotices = 0
+    const delivered = this.noticeDelivered
+    this.noticeDelivered = undefined
+    if (delivered === undefined) return
+    const agents = [...this.noticeAgents]
+    this.noticeAgents.clear()
+    for (const agent of agents) {
+      try {
+        agent.inject(createUserMessage({
+          content: [{ type: 'text', text }],
+          source: { kind: 'plugin', plugin: 'plugin-manager' },
+        }))
+      } catch (error) {
+        this.ownerContext.logger.warn('Plugin management notification could not reach an Agent', error)
+      }
+    }
+    delivered.resolve()
+  }
+}
+
+export default PluginManager

+ 171 - 0
packages/boot/plugin-manager/src/operations.ts

@@ -0,0 +1,171 @@
+/** Shared profile package operations used by dsh plugin and the running manager. */
+import { existsSync } from 'node:fs'
+import { mkdir, mkdtemp, open } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
+import { execa } from 'execa'
+import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
+import {
+  DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileManifest,
+  resolveBundleDir, resolveProfileDir, loadOverlayPatches, type ProfileManifest,
+} from '@deepseek-ai/dsh-app-boot'
+import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
+import type { PackageResult } from './types.ts'
+
+/** Profile and invocation locations supplied by the launcher. */
+export interface PackageOperationContext {
+  profile: string
+  installAnchor: string
+  cwd: string
+  home?: string
+}
+
+/** Output and cancellation policy for one pnpm operation. */
+export interface PackageOperationOptions {
+  signal?: AbortSignal
+  outputBytes: number
+  onOutput?: (text: string, stream: 'stdout' | 'stderr') => void
+  activateNewBundles?: boolean
+  lockWaitMs?: number
+}
+
+/** Resolve relative package specs against the caller's directory.
+ * @param argument One pnpm argument.
+ * @param cwd Invocation directory, never the profile directory.
+ * @returns Anchored argument.
+ */
+export function anchorPathSpec(argument: string, cwd: string): string {
+  const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument)
+  if (match?.groups?.path === undefined) return argument
+  return `${match.groups.prefix ?? ''}${resolve(cwd, match.groups.path)}`
+}
+
+/** Read bundle metadata without loading its JavaScript.
+ * @param name Installed dependency or installation-owned package name.
+ * @param dir Profile directory.
+ * @param anchor Installation manifest.
+ * @returns Resolved metadata, or undefined for packages without bundle metadata.
+ */
+export function bundleManifest(name: string, dir: string, anchor: string): ProfileManifest | undefined {
+  const packageDir = resolveBundleDir('dsh', name, anchor, dir)
+  const manifest = readProfileManifest('dsh', packageDir)
+  return manifest.dsh?.bundle?.patch === undefined ? undefined : manifest
+}
+
+/** Atomically save a profile manifest while retaining unrelated fields.
+ * @param dir Profile directory.
+ * @param manifest Updated document.
+ */
+export async function saveManifest(dir: string, manifest: ProfileManifest): Promise<void> {
+  await writeFileAtomic(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n', { mode: 0o600 })
+}
+
+/** Reconcile package removals and newly installed bundles without re-enabling retained dependencies. */
+async function reconcile(before: ProfileManifest, dir: string, anchor: string, options: PackageOperationOptions): Promise<void> {
+  const after = readProfileManifest('dsh', dir)
+  const dependencies = Object.keys(after.dependencies ?? {})
+  const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
+  const previous = after.dsh?.profile?.bundles ?? []
+  const bundles = previous.filter((name) => {
+    if (!beforeDeps.has(name) && !dependencies.includes(name)) return true
+    return dependencies.includes(name) && bundleManifest(name, dir, anchor) !== undefined
+  })
+  for (const name of dependencies) {
+    if (beforeDeps.has(name)) continue
+    const metadata = bundleManifest(name, dir, anchor)
+    if (metadata?.dsh?.bundle === undefined) {
+      options.onOutput?.(`dsh: warning: ${name} declares no dsh.bundle — installed as a plain dependency, not a profile layer\n`, 'stderr')
+      continue
+    }
+    loadOverlayPatches('dsh', join(resolveBundleDir('dsh', name, anchor, dir), metadata.dsh.bundle.patch))
+    if (!bundles.includes(name)) {
+      bundles.push(name)
+    }
+  }
+  if (JSON.stringify(previous) === JSON.stringify(bundles)) return
+  after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles } }
+  await saveManifest(dir, after)
+}
+
+/** Execute pnpm inside a profile whose caller already holds the profile write lock.
+ * @param context Launcher-owned profile and resolution locations.
+ * @param args Pnpm arguments, before relative path anchoring.
+ * @param options Output, activation and cancellation policy.
+ * @returns Exit status, bounded output, and the complete diagnostic file.
+ */
+export async function runProfilePnpm(
+  context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions,
+): Promise<PackageResult> {
+  const dir = resolveProfileDir(context.profile, context.home)
+  const before = readProfileManifest('dsh', dir)
+  const logRoot = join(dir, '.plugin-manager', 'logs')
+  await mkdir(logRoot, { recursive: true, mode: 0o700 })
+  const logDir = await mkdtemp(join(logRoot, 'operation-'))
+  const logPath = join(logDir, 'pnpm.log')
+  const log = await open(logPath, 'wx', 0o600)
+  let output = Buffer.alloc(0)
+  let truncated = false
+  const cancellation = new AbortController()
+  const child = execa('pnpm', args.map(arg => anchorPathSpec(arg, context.cwd)), {
+    cwd: dir, env: scrubbedParentEnv(), extendEnv: false, reject: false,
+    buffer: false, stdin: 'ignore', cancelSignal: options.signal === undefined
+      ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
+  })
+  let writes = Promise.resolve()
+  const collect = async (stream: AsyncIterable<Buffer | string>, kind: 'stdout' | 'stderr') => {
+    try {
+      for await (const chunk of stream) {
+        const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+        writes = writes.then(async () => { await log.write(bytes) })
+        await writes
+        options.onOutput?.(bytes.toString('utf8'), kind)
+        output = Buffer.concat([output, bytes])
+        if (output.length > options.outputBytes) {
+          truncated = true
+          output = output.subarray(output.length - options.outputBytes)
+        }
+      }
+    } catch (error) {
+      cancellation.abort()
+      throw error
+    }
+  }
+  let exitCode: number
+  try {
+    const [completion, ...streams] = await Promise.allSettled([child, collect(child.stdout, 'stdout'), collect(child.stderr, 'stderr')])
+    for (const stream of streams) if (stream.status === 'rejected') throw stream.reason
+    if (completion.status === 'rejected') throw completion.reason
+    const result = completion.value
+    exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1)
+    if (result.failed && output.length === 0) {
+      const diagnostic = result.shortMessage ?? 'pnpm failed'
+      await log.write(diagnostic)
+      truncated = Buffer.byteLength(diagnostic) > options.outputBytes
+      output = Buffer.from(diagnostic).subarray(0, options.outputBytes)
+    }
+    if (exitCode === 0 && options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options)
+  } finally {
+    await log.close()
+  }
+  return { exitCode, output: output.toString('utf8'), truncated, logPath }
+}
+
+/** Initialize and run the dsh plugin command with the same write lock as the service.
+ * @param context Launcher-owned locations.
+ * @param args Pnpm arguments.
+ * @param options Output and cancellation policy.
+ * @returns Completed package-manager result.
+ */
+export async function runPluginCommand(
+  context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions,
+): Promise<PackageResult> {
+  const dir = resolveProfileDir(context.profile, context.home)
+  await mkdir(dir, { recursive: true })
+  return withFileLock(join(dir, 'package.json'), async () => {
+    if (!existsSync(join(dir, 'package.json'))) {
+      const template = PROFILE_TEMPLATES[context.profile]
+      initProfile(dir, template?.bundles ?? DEFAULT_PROFILE_BUNDLES, template?.patchReload)
+      options.onOutput?.(`dsh: initialized profile ${context.profile} at ${dir}\n`, 'stderr')
+    }
+    return runProfilePnpm(context, args, options)
+  }, options.lockWaitMs === undefined ? undefined : { waitMs: options.lockWaitMs })
+}

+ 39 - 0
packages/boot/plugin-manager/src/patch.ts

@@ -0,0 +1,39 @@
+/** Comment-preserving profile plugin enablement edits. */
+import { readFile } from 'node:fs/promises'
+import { isMap, isSeq, parseDocument } from 'yaml'
+import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot'
+import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
+
+/** Replace the last matching override or append one after existing insertions.
+ * @param filename Current profile patch file.
+ * @param id Unique composition entry id.
+ * @param enabled Desired entry enablement.
+ * @returns Whether the file changed.
+ */
+export async function writePluginEnabled(filename: string, id: string, enabled: boolean): Promise<boolean> {
+  let text: string
+  try {
+    text = await readFile(filename, 'utf8')
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    text = '[]\n'
+  }
+  const document = parseDocument(text, {
+    customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value: string) => value }],
+  })
+  const error = document.errors[0]
+  if (error !== undefined) throw error
+  if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence')
+  loadOptionalPatches('dsh', filename)
+  const items = document.contents.items
+  const target = items.findLast((item, index) => isMap(item) && document.getIn([index, 'id']) === id
+    && !item.has('insert') && !item.has('name'))
+  if (isMap(target)) {
+    if (document.getIn([items.indexOf(target), 'disabled']) === !enabled) return false
+    document.setIn([items.indexOf(target), 'disabled'], !enabled)
+  } else {
+    document.add({ id, disabled: !enabled })
+  }
+  await writeFileAtomic(filename, String(document), { mode: 0o600 })
+  return true
+}

+ 63 - 0
packages/boot/plugin-manager/src/tools.ts

@@ -0,0 +1,63 @@
+/** Agent-facing current-profile management using the same service as Web controls. */
+import { assertNever } from '@deepseek-ai/dsh-util-values'
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from './index.ts'
+import type { PluginEntryId } from './types.ts'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+
+/** Required services for the management tool. */
+export const inject = ['tools', 'pluginManager']
+
+/** Register one management tool for discovery and the four persistent actions.
+ * @param ctx Agent-scoped tool registration context.
+ */
+export function apply(ctx: Context): void {
+  ctx.tools.register(defineTool({
+    name: 'plugin_manager',
+    description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.',
+    parameters: {
+      action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle'], description: 'Management operation.' },
+      target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action.' },
+      enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation.' },
+      offset: { type: 'number', description: 'Zero-based list offset; defaults to 0.' },
+      limit: { type: 'number', description: 'List page size, from 1 to 100; defaults to 25.' },
+    },
+    output: {
+      schema: { type: 'string' },
+      render: (_args, value) => [{ type: 'text', text: value }],
+    },
+    async execute(args) {
+      const manager = ctx.pluginManager
+      switch (args.action) {
+        case 'list_plugins':
+        case 'list_bundles': {
+          const offset = args.offset ?? 0
+          const limit = args.limit ?? 25
+          if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) {
+            throw new Error('offset must be a non-negative integer and limit must be an integer from 1 to 100')
+          }
+          const rows = args.action === 'list_plugins' ? await manager.listPlugins() : await manager.listBundles()
+          const entries = rows.slice(offset, offset + limit)
+          return JSON.stringify({ entries, total: rows.length,
+            nextOffset: offset + entries.length < rows.length ? offset + entries.length : null })
+        }
+        case 'set_plugin':
+        case 'set_bundle': {
+          if (args.target === undefined || args.enabled === undefined) throw new Error('target and enabled are required')
+          return JSON.stringify(await (args.action === 'set_plugin'
+            ? manager.setPluginEnabled(args.target as PluginEntryId, args.enabled)
+            : manager.setBundleEnabled(args.target, args.enabled)))
+        }
+        case 'install_bundle':
+          if (args.target === undefined) throw new Error('target package spec is required')
+          return JSON.stringify(await manager.installBundle(args.target, args.enabled === undefined ? {} : { enabled: args.enabled }))
+        case 'remove_bundle':
+          if (args.target === undefined) throw new Error('target bundle name is required')
+          return JSON.stringify(await manager.removeBundle(args.target))
+        /* v8 ignore next -- tool JSON validation rejects actions outside the declared enum */
+        default: return assertNever(args.action)
+      }
+    },
+    presentCall: args => ({ card: 'generic', title: 'Manage profile plugins', kind: args.action.startsWith('list_') ? 'read' : 'other', rawInput: args }),
+  }))
+}

+ 42 - 0
packages/boot/plugin-manager/src/types.ts

@@ -0,0 +1,42 @@
+/** Public plugin management records shared with clients. */
+import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types'
+export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types'
+
+/** One running-profile entry and its persistent control availability. */
+export interface PluginInfo extends PluginInventoryEntry {
+  /** Profile patch target; absent for dynamically mounted or ambiguous entries. */
+  patchId?: string
+  /** Why the manager cannot modify this entry. */
+  readOnlyReason?: string
+}
+
+/** One installed or installation-provided bundle. */
+export interface BundleInfo {
+  name: string
+  version?: string
+  enabled: boolean
+  removable: boolean
+  readOnlyReason?: string
+  error?: string
+}
+
+/** Pnpm completion, including a retrieval path for unabridged diagnostics. */
+export interface PackageResult {
+  exitCode: number
+  output: string
+  truncated: boolean
+  logPath: string
+}
+
+/** Persisted change and independently observed application outcome. */
+export interface ChangeResult {
+  changed: boolean
+  application: 'applied' | 'restart-required' | 'overridden' | 'failed'
+  message: string
+  packageResult?: PackageResult
+}
+
+/** Bundle installation defaults to activation. */
+export interface InstallBundleOptions {
+  enabled?: boolean
+}

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

@@ -0,0 +1,291 @@
+/** Persistent manager behavior through a real profile Include and Loader. */
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import type { Context } from '@deepseek-ai/cordis'
+import type AgentRegistry from '@deepseek-ai/dsh-agent'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { UserMessage } from '@deepseek-ai/dsh-llm'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
+import {
+  boot, composeEntries, initProfile, loadProfileDirectory, readProfileManifest, reconcileProfilePatches,
+  type ProfileRuntime,
+} from '@deepseek-ai/dsh-app-boot'
+import PluginManager, { type Config } from '../src/index.ts'
+import { Group } from '@deepseek-ai/cordis-plugin-loader'
+import * as operations from '../src/operations.ts'
+
+async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}) {
+  const home = mkdtempSync(join(tmpdir(), 'plugin-manager-'))
+  const dir = join(home, 'profiles', 'test')
+  const anchor = join(home, 'package.json')
+  writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n')
+  initProfile(dir, ['core', 'extra'], reload)
+  const bundle = (name: string, rows: unknown[]) => {
+    const path = join(dir, 'node_modules', name)
+    mkdirSync(path, { recursive: true })
+    writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
+    writeFileSync(join(path, 'cordis.patch.yml'), JSON.stringify([{ insert: rows }]))
+    writeFileSync(join(path, 'plugin.mjs'), 'export function apply(ctx, config) { if (config?.fail) throw new Error("test activation failed"); ctx.provide(config?.service ?? "managedProbe", true) }\n')
+  }
+  bundle('core', [{ id: 'manager', name: 'cordis:manager', config }])
+  bundle('extra', [{ id: 'managed', name: './plugin.mjs' }])
+  const manifest = readProfileManifest('test', dir)
+  manifest.dependencies = { extra: '1.0.0' }
+  writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+  const read = () => loadProfileDirectory('test', dir, anchor)
+  const patches = () => [...read().layers.flatMap(layer => layer.patches), ...read().patches,
+    ...overlay ? [{ id: 'managed', disabled: true }] : []]
+  writeFileSync(join(dir, 'cordis.yml'), '[]\n')
+  const runtime: ProfileRuntime = {
+    startedBundles: ['core', 'extra'],
+    name: 'test', dir, installAnchor: anchor, cwd: home, home, patchReload: reload, read,
+    entries: () => composeEntries([patches()]),
+    mutate: operation => withFileLock(join(dir, 'package.json'), operation),
+    reload: async () => { if (reload === 'live') await reconcileProfilePatches(ctx, patches(), 'test') },
+  }
+  const ctx = await boot('test', join(dir, 'cordis.yml'), patches(), (ctx) => {
+    prepare?.(ctx)
+    ctx.provide('profileRuntime', runtime)
+    ctx.loader.builtins.manager = PluginManager
+  })
+  onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
+  return { ctx, dir, manager: ctx.pluginManager, bundle, runtime }
+}
+
+it('lists bundle versions and current-profile plugin targets', async () => {
+  const { manager } = await fixture()
+  const plugins = await manager.listPlugins()
+  expect(plugins.find(row => row.entryId === 'include:managed')).toMatchObject({ patchId: 'managed', enabled: true })
+  expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBeDefined()
+  expect(await manager.listBundles()).toEqual([
+    { name: 'core', version: '1.0.0', enabled: true, removable: false, readOnlyReason: 'This bundle provides plugin management components' },
+    { name: 'extra', version: '1.0.0', enabled: true, removable: true },
+  ])
+})
+
+it('turns a plugin off and on without duplicating patch overrides', async () => {
+  const { manager, dir } = await fixture()
+  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
+  expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
+  expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false)
+  expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'applied' })
+  expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'applied' })
+  expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8').match(/id: managed/g)).toHaveLength(1)
+})
+
+it('retains installed dependencies when toggling a bundle and appends it when re-enabled', async () => {
+  const { manager, dir, bundle } = await fixture()
+  bundle('third', [])
+  await manager.setBundleEnabled('third', true)
+  expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
+  expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
+  expect((await manager.listPlugins()).some(row => row.patchId === 'managed')).toBe(false)
+  await manager.setBundleEnabled('extra', true)
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'third', 'extra'])
+})
+
+it('reports an overlay overriding a saved plugin toggle', async () => {
+  const { manager } = await fixture('live', true)
+  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
+  expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'overridden' })
+})
+
+it('saves startup-only toggles and refuses removal of currently used packages', async () => {
+  const { manager } = await fixture('startup')
+  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
+  expect(await manager.setPluginEnabled(id, false)).toMatchObject({ application: 'restart-required' })
+  expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(true)
+  await manager.setBundleEnabled('extra', false)
+  expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed' })
+})
+
+it('refuses self-disable, unknown entries and removal of installation-owned bundles', async () => {
+  const { manager } = await fixture()
+  const id = (await manager.listPlugins()).find(row => row.entryId === 'include:manager')!.entryId
+  expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed' })
+  expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed' })
+  expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed' })
+  expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed' })
+})
+
+it('installs only valid bundle declarations and honors installation without activation', async () => {
+  const { manager, dir, bundle } = await fixture()
+  const initial = readProfileManifest('test', dir)
+  delete initial.dependencies
+  writeFileSync(join(dir, 'package.json'), JSON.stringify(initial))
+  const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
+    const name = String(args[1])
+    bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
+    const manifest = readProfileManifest('test', dir)
+    manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+    return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  onTestFinished(() => { install.mockRestore() })
+  expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({ changed: true, application: 'applied', packageResult: { exitCode: 0 } })
+  expect((await manager.listBundles()).find(row => row.name === 'new-bundle')?.enabled).toBe(false)
+  expect(await manager.setBundleEnabled('new-bundle', true)).toMatchObject({ application: 'applied' })
+  expect((await manager.listPlugins()).find(row => row.patchId === 'new-bundle')?.fiberPhase).toBe('active')
+  expect(await manager.installBundle('another-bundle')).toMatchObject({ application: 'applied' })
+  expect((await manager.listBundles()).find(row => row.name === 'another-bundle')?.enabled).toBe(true)
+})
+
+it('unloads bundle contributions before pnpm removes files and retains failed removal state', async () => {
+  const { manager, dir, ctx } = await fixture()
+  const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
+    expect([...ctx.loader.entries()].some(row => row.id === 'include:managed')).toBe(false)
+    return { exitCode: 1, output: 'removal failed', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  onTestFinished(() => { remove.mockRestore() })
+  expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 1 } })
+  expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
+  expect((await manager.listBundles()).find(row => row.name === 'extra')?.enabled).toBe(false)
+  remove.mockImplementationOnce(async () => {
+    const manifest = readProfileManifest('test', dir)
+    delete manifest.dependencies?.extra
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+    rmSync(join(dir, 'node_modules', 'extra'), { recursive: true })
+    return { exitCode: 0, output: 'removed', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  expect(await manager.removeBundle('extra')).toMatchObject({ changed: true, application: 'applied' })
+  expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
+})
+
+it('reports package failure separately from partial disk changes', async () => {
+  const { manager, dir } = await fixture()
+  const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
+    const manifest = readProfileManifest('test', dir)
+    manifest.dependencies = { ...manifest.dependencies, partial: '1' }
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+    return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  onTestFinished(() => { install.mockRestore() })
+  expect(await manager.installBundle('partial')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 42 } })
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
+  expect((await manager.listBundles()).find(row => row.name === 'partial')?.error).toContain('cannot resolve profile bundle')
+})
+
+it('keeps saved changes after activation failure and allows a corrected configuration to retry', async () => {
+  const { manager, dir } = await fixture()
+  writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n  disabled: true\n  config: { fail: true }\n')
+  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
+  expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'failed' })
+  expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('disabled: false')
+  writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n  disabled: true\n  config: { fail: false }\n')
+  expect(await manager.setPluginEnabled(id, true)).toMatchObject({ application: 'applied' })
+})
+
+
+it('combines concurrent changes into durable notices without waking Agents', async () => {
+  const session = Session.create(SessionId('manager-notices'))
+  const wake = vi.fn()
+  const notices: UserMessage[] = []
+  const liveAgents = [{
+    inject(message: UserMessage) {
+      notices.push(message)
+      session.append('user/message', message, { surfaceOp: 'append' })
+    }, followup: wake, steer: wake,
+  }, { inject() { throw new Error('already disposed') } }]
+  const agents = { list: () => liveAgents }
+  const { manager } = await fixture('live', false, (ctx) => { ctx.provide('agents', agents as unknown as AgentRegistry) })
+  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
+  await Promise.all([manager.setPluginEnabled(id, false), manager.setPluginEnabled(id, true)])
+  expect(notices).toHaveLength(1)
+  expect(JSON.stringify(notices)).toContain('disabled')
+  expect(JSON.stringify(notices)).toContain('enabled')
+  expect(wake).not.toHaveBeenCalled()
+  expect(session.snapshotEvents().filter(row => row.type === 'user/message')).toHaveLength(1)
+})
+
+
+it('reports plain dependencies, missing versions and invalid selected bundles distinctly', async () => {
+  const { manager, dir, runtime } = await fixture()
+  writeFileSync(runtime.installAnchor, '{}')
+  writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}')
+  expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: true, error: 'Not a bundle: extra' })
+  expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ application: 'applied' })
+  expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
+  expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
+  writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{"name":"core","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}')
+  expect((await manager.listBundles())[0]?.version).toBeUndefined()
+  writeFileSync(join(dir, 'package.json'), '{}')
+  expect(await manager.listBundles()).toEqual([])
+  expect(await manager.setBundleEnabled('unknown', false)).toMatchObject({ application: 'failed' })
+  writeFileSync(runtime.installAnchor, '{"dependencies":{"missing-builtin":"1"}}')
+  expect(await manager.listBundles()).toEqual([])
+})
+
+it('refuses management bundle disablement and permits repeated bundle selections', async () => {
+  const { manager } = await fixture()
+  expect(await manager.setBundleEnabled('core', false)).toMatchObject({ application: 'failed', changed: false })
+  expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ application: 'applied', changed: false })
+})
+
+it('addresses children inside profile groups and marks ambiguous ids read-only', async () => {
+  const { manager, bundle, runtime } = await fixture('live', false, (ctx) => { ctx.loader.builtins.group = Group })
+  bundle('grouped', [{ id: 'group', name: 'cordis:group', group: true,
+    config: [{ id: 'child', name: './plugin.mjs', config: { service: 'child' } }] }])
+  expect(await manager.setBundleEnabled('grouped', true)).toMatchObject({ application: 'applied' })
+  expect((await manager.listPlugins()).find(row => row.patchId === 'child')).toBeDefined()
+  const entries = runtime.entries()
+  const duplicate = entries.find(row => row.id === 'managed')!
+  const read = vi.spyOn(runtime, 'entries').mockReturnValue([...entries, duplicate])
+  onTestFinished(() => { read.mockRestore() })
+  expect((await manager.listPlugins()).find(row => row.entryId === 'include:managed')?.readOnlyReason).toContain('not uniquely addressable')
+})
+
+it.each(['', '-g'])('rejects an invalid installation spec before calling pnpm: %j', async (spec) => {
+  const { manager } = await fixture()
+  expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed' })
+})
+
+it('retains a successful install that cannot be activated as a bundle', async () => {
+  const { manager, dir, bundle } = await fixture()
+  const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
+    bundle('plain', [])
+    writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
+    const manifest = readProfileManifest('test', dir)
+    manifest.dependencies = { ...manifest.dependencies, plain: '1' }
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+    return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  onTestFinished(() => { install.mockRestore() })
+  expect(await manager.installBundle('plain')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 0 } })
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
+})
+
+it('reports repeated installs as requiring restart and ambiguous package changes as failures', async () => {
+  const { manager, dir } = await fixture()
+  const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') })
+  onTestFinished(() => { install.mockRestore() })
+  expect(await manager.installBundle('extra')).toMatchObject({ changed: false, application: 'restart-required' })
+  expect(await manager.installBundle('extra@1')).toMatchObject({ changed: false, application: 'failed' })
+  install.mockImplementationOnce(async () => {
+    writeFileSync(join(dir, 'package.json'), '{}')
+    return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  expect(await manager.installBundle('unknown')).toMatchObject({ changed: true, application: 'failed' })
+})
+
+it('handles missing patch files and retains non-Error Loader diagnostics', async () => {
+  const { manager, dir, runtime } = await fixture()
+  rmSync(join(dir, 'cordis.patch.yml'))
+  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
+  const reload = vi.spyOn(runtime, 'reload').mockRejectedValueOnce('loader rejected generation')
+  onTestFinished(() => { reload.mockRestore() })
+  expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'failed', message: 'loader rejected generation' })
+  rmSync(join(dir, 'cordis.patch.yml'))
+  mkdirSync(join(dir, 'cordis.patch.yml'))
+  await expect(manager.setPluginEnabled(id, true)).rejects.toThrow()
+})
+
+it('bounds batched notices and discloses omitted operation results', async () => {
+  const messages: UserMessage[] = []
+  const { manager } = await fixture('live', false, (ctx) => {
+    ctx.provide('agents', { list: () => [{ inject: (message: UserMessage) => { messages.push(message) } }] } as unknown as AgentRegistry)
+  }, { outputBytes: 1, notificationDelayMs: 0 })
+  await manager.setBundleEnabled('extra', false)
+  expect(JSON.stringify(messages)).toContain('1 additional operations omitted')
+})

+ 171 - 0
packages/boot/plugin-manager/tests/operations.spec.ts

@@ -0,0 +1,171 @@
+/** The CLI and manager share package reconciliation, path anchoring and diagnostics. */
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { PassThrough } from 'node:stream'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import { initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot'
+import { anchorPathSpec, runPluginCommand, runProfilePnpm } from '../src/operations.ts'
+
+const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => ReturnType<typeof result>>() }))
+vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) }))
+
+function fixture() {
+  const home = mkdtempSync(join(tmpdir(), 'manager-pnpm-'))
+  onTestFinished(() => { command.run.mockReset(); rmSync(home, { recursive: true, force: true }) })
+  const dir = join(home, 'profiles', 'test')
+  const installAnchor = join(home, 'package.json')
+  writeFileSync(installAnchor, '{}\n')
+  initProfile(dir, [])
+  return { home, dir, context: { home, profile: 'test', installAnchor, cwd: home } }
+}
+
+function result(
+  exitCode: number | undefined, output: string, mutate: () => void = () => {},
+  details: { code?: string; shortMessage?: string } = {},
+) {
+  const stdout = new PassThrough()
+  const stderr = new PassThrough()
+  const done = Promise.resolve().then(() => {
+    mutate()
+    stdout.end(output)
+    stderr.end()
+    return { exitCode, failed: exitCode !== 0, ...details }
+  })
+  return Object.assign(done, { stdout, stderr })
+}
+
+function install(dir: string, name: string) {
+  const path = join(dir, 'node_modules', name)
+  mkdirSync(path, { recursive: true })
+  writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
+  writeFileSync(join(path, 'cordis.patch.yml'), '[]\n')
+  const manifest = readProfileManifest('test', dir)
+  manifest.dependencies = { ...manifest.dependencies, [name]: '1' }
+  writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+}
+
+it('anchors relative package specs without rewriting registry specs', () => {
+  expect(anchorPathSpec('.', '/workspace')).toBe('/workspace')
+  expect(anchorPathSpec('file:../plugin', '/workspace/project')).toBe('file:/workspace/plugin')
+  expect(anchorPathSpec('package@1', '/workspace')).toBe('package@1')
+})
+
+it('activates newly installed bundles and leaves retained disabled dependencies disabled', async () => {
+  const { dir, context } = fixture()
+  install(dir, 'disabled')
+  command.run.mockImplementationOnce(() => result(0, 'installed', () =>{  install(dir, 'new-bundle') }))
+  expect(await runPluginCommand(context, ['add', 'new-bundle'], { outputBytes: 100 })).toMatchObject({ exitCode: 0 })
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new-bundle'])
+  command.run.mockImplementationOnce(() => result(0, 'updated'))
+  await runPluginCommand(context, ['update'], { outputBytes: 100 })
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new-bundle'])
+})
+
+it('can install without activation and bounds output while retaining the complete log', async () => {
+  const { dir, context } = fixture()
+  command.run.mockImplementationOnce(() => result(0, '0123456789', () =>{  install(dir, 'extra') }))
+  const outcome = await runProfilePnpm(context, ['add', './extra'], { outputBytes: 4, activateNewBundles: false })
+  expect(outcome).toMatchObject({ exitCode: 0, output: '6789', truncated: true })
+  expect(readFileSync(outcome.logPath, 'utf8')).toBe('0123456789')
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([])
+  expect(command.run.mock.calls[0]?.[1]).toEqual(['add', join(context.cwd, 'extra')])
+})
+
+it('retains partial package-manager changes after failure without activating them', async () => {
+  const { dir, context } = fixture()
+  command.run.mockImplementationOnce(() => result(1, 'installation failed', () =>{  install(dir, 'partial') }))
+  expect(await runProfilePnpm(context, ['add', 'partial'], { outputBytes: 100 })).toMatchObject({ exitCode: 1 })
+  expect(readProfileManifest('test', dir).dependencies).toEqual({ partial: '1' })
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([])
+})
+
+
+it('initializes missing profiles under the same lock and reports initialization', async () => {
+  const { home, context } = fixture()
+  const messages: string[] = []
+  command.run.mockImplementation(() => result(0, ''))
+  for (const profile of ['custom', 'web']) {
+    await runPluginCommand({ ...context, profile }, ['root'], {
+      outputBytes: 100, lockWaitMs: 1000, onOutput: (text) => { messages.push(text) },
+    })
+    expect(readProfileManifest('test', join(home, 'profiles', profile)).dsh?.profile?.bundles).toContain('@deepseek-ai/dsh-base')
+  }
+  expect(messages.filter(text => text.includes('initialized profile'))).toHaveLength(2)
+})
+
+it('retains built-in layers, removes deleted dependencies and warns about plain packages', async () => {
+  const { context, dir } = fixture()
+  install(dir, 'removed')
+  const manifest = readProfileManifest('test', dir)
+  manifest.dsh = { profile: { bundles: ['builtin', 'removed'] } }
+  writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+  const messages: string[] = []
+  command.run.mockImplementationOnce(() => result(0, '', () => {
+    install(dir, 'plain')
+    writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
+    const after = readProfileManifest('test', dir)
+    delete after.dependencies?.removed
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(after))
+  }))
+  await runPluginCommand(context, ['remove', 'removed'], { outputBytes: 100, onOutput: (text) => { messages.push(text) } })
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['builtin'])
+  expect(messages.join('')).toContain('plain dependency')
+})
+
+it('preserves a package-manager selected new bundle without adding it twice', async () => {
+  const { context, dir } = fixture()
+  writeFileSync(join(dir, 'package.json'), '{}')
+  command.run.mockImplementationOnce(() => result(0, '', () => {
+    install(dir, 'new')
+    const manifest = readProfileManifest('test', dir)
+    manifest.dsh = { profile: { bundles: ['new'] } }
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+  }))
+  await runProfilePnpm(context, ['add', 'new'], { outputBytes: 100 })
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new'])
+})
+
+it.each([
+  { code: 'ENOENT', shortMessage: 'pnpm not found', expected: 127 },
+  { code: 'EACCES', shortMessage: undefined, expected: 1 },
+])('reports launch failures with a complete log: $code', async ({ code, shortMessage, expected }) => {
+  const { context } = fixture()
+  command.run.mockImplementationOnce(() => result(undefined, '', () => {}, { code, ...shortMessage === undefined ? {} : { shortMessage } }))
+  const outcome = await runProfilePnpm(context, ['root'], { outputBytes: 4, signal: new AbortController().signal })
+  expect(outcome.exitCode).toBe(expected)
+  expect(outcome.truncated).toBe(true)
+  expect(readFileSync(outcome.logPath, 'utf8')).toBe(shortMessage ?? 'pnpm failed')
+})
+
+it('cancels and settles package output when the output consumer fails', async () => {
+  const { context } = fixture()
+  let cancellation: AbortSignal | undefined
+  command.run.mockImplementationOnce((_name, _args, options) => {
+    cancellation = (options as { cancelSignal: AbortSignal }).cancelSignal
+    const child = result(0, 'text')
+    child.stdout.setEncoding('utf8')
+    return child
+  })
+  await expect(runProfilePnpm(context, ['root'], {
+    outputBytes: 100, onOutput() { throw new Error('output destination closed') },
+  })).rejects.toThrow('output destination closed')
+  expect(cancellation?.aborted).toBe(true)
+})
+
+it('preserves an unexpected subprocess rejection after both streams settle', async () => {
+  const { context } = fixture()
+  const stdout = new PassThrough()
+  const stderr = new PassThrough()
+  stdout.end()
+  stderr.end()
+  command.run.mockImplementationOnce(() => Object.assign(Promise.reject(new Error('subprocess failed')), { stdout, stderr }))
+  await expect(runProfilePnpm(context, ['root'], { outputBytes: 100 })).rejects.toThrow('subprocess failed')
+})
+
+
+it('handles manifests without dependency or bundle selections', async () => {
+  const { context, dir } = fixture()
+  command.run.mockImplementationOnce(() => result(0, '', () => { writeFileSync(join(dir, 'package.json'), '{}') }))
+  expect(await runProfilePnpm(context, ['root'], { outputBytes: 100 })).toMatchObject({ exitCode: 0 })
+})

+ 63 - 0
packages/boot/plugin-manager/tests/patch.spec.ts

@@ -0,0 +1,63 @@
+/** Profile patch edits preserve user-authored syntax and are idempotent. */
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { expect, it, onTestFinished } from 'vitest'
+import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot'
+import { writePluginEnabled } from '../src/patch.ts'
+
+async function fixture(text?: string): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), 'manager-patch-'))
+  onTestFinished(() => rm(dir, { recursive: true, force: true }))
+  const file = join(dir, 'cordis.patch.yml')
+  if (text !== undefined) await writeFile(file, text)
+  return file
+}
+
+it('preserves comments, expressions, unrelated configuration and the last override', async () => {
+  const file = await fixture('# personal configuration\n- id: tool\n  config:\n    value: !!js process.platform\n- id: tool\n  disabled: false # availability\n')
+  expect(await writePluginEnabled(file, 'tool', false)).toBe(true)
+  const text = await readFile(file, 'utf8')
+  expect(text).toContain('# personal configuration')
+  expect(text).toContain('!!js process.platform')
+  expect(text).toContain('# availability')
+  expect(loadOptionalPatches('test', file)).toEqual([
+    { id: 'tool', config: { value: { __jsExpr: 'process.platform' } } }, { id: 'tool', disabled: true },
+  ])
+  expect(await writePluginEnabled(file, 'tool', false)).toBe(false)
+  expect(await readFile(file, 'utf8')).toBe(text)
+  expect(await writePluginEnabled(file, 'tool', true)).toBe(true)
+})
+
+it('creates a missing patch file and appends after insertions', async () => {
+  const file = await fixture()
+  await writePluginEnabled(file, 'tool', false)
+  expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', disabled: true }])
+  await writeFile(file, '- insert:\n    - id: tool\n      name: package\n')
+  await writePluginEnabled(file, 'tool', true)
+  expect(loadOptionalPatches('test', file)).toEqual([
+    { insert: [{ id: 'tool', name: 'package' }] }, { id: 'tool', disabled: false },
+  ])
+})
+
+it.each(['- id: [broken', 'mapping: true\n'])('refuses malformed documents without overwriting %s', async (text) => {
+  const file = await fixture(text)
+  await expect(writePluginEnabled(file, 'tool', true)).rejects.toThrow()
+  expect(await readFile(file, 'utf8')).toBe(text)
+})
+
+
+it('retains name-asserting overrides and appends an unambiguous switch', async () => {
+  const file = await fixture('- id: tool\n  name: another-package\n  disabled: false\n')
+  await writePluginEnabled(file, 'tool', false)
+  expect(loadOptionalPatches('test', file)).toEqual([
+    { id: 'tool', name: 'another-package', disabled: false }, { id: 'tool', disabled: true },
+  ])
+  expect(await writePluginEnabled(file, 'tool', false)).toBe(false)
+})
+
+it('reports read failures without replacing a directory with configuration', async () => {
+  const file = await fixture()
+  await mkdir(file)
+  await expect(writePluginEnabled(file, 'tool', true)).rejects.toThrow()
+})

+ 82 - 0
packages/boot/plugin-manager/tests/tools.spec.ts

@@ -0,0 +1,82 @@
+/** Agent controls use the same manager methods as the Web and bound inventory reads. */
+import { Context } from '@deepseek-ai/cordis'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import type PluginManager from '../src/index.ts'
+import * as tool from '../src/tools.ts'
+
+function resultText(result: Awaited<ReturnType<ToolRuntime['execute']>>): string {
+  if (typeof result.value !== 'string') throw new Error('Expected a serialized manager result')
+  return result.value
+}
+
+async function fixture() {
+  const ctx = new Context()
+  onTestFinished(() => ctx.fiber.dispose())
+  const manager = {
+    listPlugins: vi.fn(async () => Array.from({ length: 30 }, (_, i) => ({ entryId: `include:${i}`, enabled: true }))),
+    listBundles: vi.fn(async () => [{ name: 'bundle', enabled: true }]),
+    setPluginEnabled: vi.fn(async () => ({ changed: true, application: 'applied' })),
+    setBundleEnabled: vi.fn(async () => ({ changed: true, application: 'applied' })),
+    installBundle: vi.fn(async () => ({ changed: true, application: 'restart-required' })),
+    removeBundle: vi.fn(async () => ({ changed: false, application: 'failed' })),
+  }
+  ctx.provide('pluginManager', manager as unknown as PluginManager)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRuntime)
+  const fiber = await ctx.plugin(tool)
+  const call = (args: unknown) => ctx.tools.execute({ name: 'plugin_manager', arguments: args,
+    callId: ToolCallId('manager-call'), signal: new AbortController().signal })
+  return { ctx, manager, call, fiber }
+}
+
+it('paginates inventories with an explicit continuation and total', async () => {
+  const { call } = await fixture()
+  const first = await call({ action: 'list_plugins' })
+  expect(first.isError).toBe(false)
+  expect(JSON.stringify(first.content)).toContain('nextOffset')
+  expect(JSON.parse(resultText(first))).toMatchObject({ nextOffset: 25, total: 30 })
+  const last = await call({ action: 'list_plugins', offset: 25, limit: 10 })
+  expect(JSON.parse(resultText(last))).toMatchObject({ nextOffset: null, total: 30 })
+  expect(resultText(await call({ action: 'list_bundles' }))).toContain('"name":"bundle"')
+})
+
+it('forwards all mutation actions and renders the returned outcome', async () => {
+  const { call, manager } = await fixture()
+  await call({ action: 'set_plugin', target: 'include:1', enabled: false })
+  expect(manager.setPluginEnabled).toHaveBeenCalledWith('include:1', false)
+  await call({ action: 'set_bundle', target: 'bundle', enabled: true })
+  expect(manager.setBundleEnabled).toHaveBeenCalledWith('bundle', true)
+  await call({ action: 'install_bundle', target: 'bundle' })
+  expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', {})
+  await call({ action: 'install_bundle', target: 'bundle', enabled: false })
+  expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', { enabled: false })
+  expect(resultText(await call({ action: 'remove_bundle', target: 'bundle' }))).toContain('"application":"failed"')
+  expect(manager.removeBundle).toHaveBeenCalledWith('bundle')
+})
+
+it.each([
+  { action: 'unknown_action' },
+  { action: 'list_plugins', offset: -1 },
+  { action: 'list_plugins', offset: 0.5 },
+  { action: 'list_bundles', limit: 101 },
+  { action: 'list_bundles', limit: 0 },
+  { action: 'list_bundles', limit: 1.5 },
+  { action: 'set_plugin', enabled: true },
+  { action: 'set_bundle', target: 'bundle' },
+  { action: 'install_bundle' }, { action: 'remove_bundle' },
+])('rejects incomplete or unbounded tool inputs: %j', async (args) => {
+  const { call } = await fixture()
+  expect((await call(args)).isError).toBe(true)
+})
+
+it('presents reads and changes distinctly and disposes its registration', async () => {
+  const { ctx, fiber } = await fixture()
+  const definition = ctx.tools.get('plugin_manager')!
+  expect(definition.presentCall?.({ action: 'list_plugins' })).toMatchObject({ kind: 'read' })
+  expect(definition.presentCall?.({ action: 'remove_bundle', target: 'bundle' })).toMatchObject({ kind: 'other' })
+  await fiber.dispose()
+  expect(ctx.tools.get('plugin_manager')).toBeUndefined()
+})

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

@@ -0,0 +1,48 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/loader"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../app-boot"
+    },
+    {
+      "path": "../../util/atomic-write"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../host/plugin-inventory"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../subprocess/subprocess"
+    },
+    {
+      "path": "../../typert/protocol"
+    },
+    {
+      "path": "../../core/tools"
+    },
+    {
+      "path": "../../util/values"
+    }
+  ]
+}

+ 8 - 0
packages/bundle/base/cordis.patch.yml

@@ -13,6 +13,14 @@
 # driven); the grouping is for readers.
 
 - insert:
+    - id: tool-plugin-manager
+      name: '@deepseek-ai/dsh-plugin-manager/tools'
+      disabled: !!js !ctx.get('profileRuntime')
+
+    - id: plugin-manager
+      name: '@deepseek-ai/dsh-plugin-manager'
+      disabled: !!js !ctx.get('profileRuntime')
+
     - id: timer
       name: '@deepseek-ai/cordis-plugin-timer'
 

+ 2 - 1
packages/bundle/base/package.json

@@ -120,7 +120,8 @@
     "@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
     "@deepseek-ai/dsh-workflow-ptc": "workspace:^",
     "@deepseek-ai/dsh-agent-instructions": "workspace:^",
-    "@deepseek-ai/dsh-ptc-runtime-node": "workspace:^"
+    "@deepseek-ai/dsh-ptc-runtime-node": "workspace:^",
+    "@deepseek-ai/dsh-plugin-manager": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"

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

@@ -369,6 +369,9 @@
 # exists, so there is no agent to key by. Behind a preset realm those variables
 # would never reach the model's shell at all.
 
+- id: tool-plugin-manager
+  disabled: true
+
 - id: tool-bash
   disabled: true
 

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

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

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

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

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

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

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

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

+ 15 - 1
packages/client/ui-settings-plugin-inventory/src/client/index.ts

@@ -10,6 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
 // Inline-safe shared fold: shipped ids map to dictionary keys in one home.
 import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display'
 import { PluginInventorySettingsTab, type PluginInventorySettingsTabInjected } from './PluginInventorySettingsTab.tsx'
+import type { PluginManagement } from './management.tsx'
 import { en, zh, type PluginInventoryLocaleKey } from './locales.ts'
 
 export type { PluginInventorySettingsTabInjected, PluginInventorySettingsTabProps } from './PluginInventorySettingsTab.tsx'
@@ -45,7 +46,20 @@ export function apply(ctx: ClientContext): void {
   const agentPresetCopy = ctx.locale.bind('settings.agentPreset')
   const presetName: PluginInventorySettingsTabInjected['presetName'] = preset =>
     presetDisplayText(preset, agentPresetCopy).name
-  const injected = (): PluginInventorySettingsTabInjected => ({ list, presetName })
+  const unwrap = async <T>(response: Promise<{ ok: true; value: T } | { ok: false; error: Error }>): Promise<T> => {
+    const result = await response
+    if (!result.ok) throw result.error
+    return result.value
+  }
+  const management: PluginManagement = {
+    listPlugins: () => unwrap(ctx.remote.pluginManager.listPlugins()),
+    listBundles: () => unwrap(ctx.remote.pluginManager.listBundles()),
+    setPluginEnabled: (id, enabled) => unwrap(ctx.remote.pluginManager.setPluginEnabled(id, enabled)),
+    setBundleEnabled: (name, enabled) => unwrap(ctx.remote.pluginManager.setBundleEnabled(name, enabled)),
+    installBundle: (spec, options) => unwrap(ctx.remote.pluginManager.installBundle(spec, options)),
+    removeBundle: name => unwrap(ctx.remote.pluginManager.removeBundle(name)),
+  }
+  const injected = (): PluginInventorySettingsTabInjected => ({ list, presetName, management })
 
   ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
     name: 'settings.plugins.tab',

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

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

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

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

+ 44 - 0
packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx

@@ -378,3 +378,47 @@ describe('PluginInventorySettingsTab', () => {
     await act(async () => { deferredFailure.reject(new Error('late failure')) })
   })
 })
+
+describe('persistent profile management', () => {
+  it('runs bundle and plugin controls through the same manager and shows failed results', async () => {
+    let enabled = true
+    let selected = true
+    let installed = true
+    const list: PluginInventorySettingsTabInjected['list'] = async () => ({
+      managementAvailable: true,
+      entries: [{ entryId: 'managed' as Snapshot['entries'][number]['entryId'], moduleName: '@fixture/managed', enabled, fiberPhase: enabled ? 'active' : null }],
+    })
+    const management = {
+      listPlugins: async () => [{ ...(await list()).entries[0]!, patchId: 'managed' }],
+      listBundles: async () => installed ? [{ name: 'extra', version: '1', enabled: selected, removable: true }] : [],
+      setPluginEnabled: vi.fn(async (_id: Snapshot['entries'][number]['entryId'], next: boolean) => {
+        enabled = next
+        return { changed: true, application: 'applied' as const, message: 'Plugin changed.' }
+      }),
+      setBundleEnabled: vi.fn(async (_name: string, next: boolean) => {
+        selected = next
+        return { changed: true, application: 'applied' as const, message: 'Bundle changed.' }
+      }),
+      installBundle: vi.fn(async () => ({ changed: false, application: 'failed' as const, message: 'Registry unavailable.' })),
+      removeBundle: vi.fn(async () => {
+        installed = false
+        return { changed: true, application: 'applied' as const, message: 'Bundle removed.' }
+      }),
+    } satisfies NonNullable<PluginInventorySettingsTabInjected['management']>
+    render(<PluginInventorySettingsTab {...props(list)} management={management} />)
+    fireEvent.click(await screen.findByRole('switch', { name: 'Toggle bundle extra' }))
+    await waitFor(() => { expect(management.setBundleEnabled).toHaveBeenCalledWith('extra', false) })
+    await waitFor(() => { expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle bundle extra' }).checked).toBe(false) })
+    fireEvent.click(screen.getByRole('button', { name: /managed, managed/ }))
+    fireEvent.click(await screen.findByRole('switch', { name: 'Toggle plugin managed' }))
+    await waitFor(() => { expect(management.setPluginEnabled).toHaveBeenCalledWith('managed', false) })
+    await waitFor(() => { expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle plugin managed' }).checked).toBe(false) })
+    fireEvent.change(screen.getByRole('textbox', { name: 'npm package name or local path' }), { target: { value: 'new-bundle' } })
+    fireEvent.click(screen.getByRole('button', { name: 'Install' }))
+    await waitFor(() => { expect(management.installBundle).toHaveBeenCalledWith('new-bundle', { enabled: true }) })
+    expect((await screen.findByRole('alert')).textContent).toContain('Registry unavailable.')
+    fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
+    await waitFor(() => { expect(management.removeBundle).toHaveBeenCalledWith('extra') })
+    await waitFor(() => { expect(screen.queryByRole('switch', { name: 'Toggle bundle extra' })).toBeNull() })
+  })
+})

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

@@ -1397,6 +1397,84 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'pluginManager',
+    summary: 'Manage profile files and apply their declared reload lifecycle.',
+    description: 'Manage profile files and apply their declared reload lifecycle.',
+    methods: [
+      {
+        signature: '@Remote async listPlugins(): Promise<PluginInfo[]>',
+        description: 'Read current plugins, including why a row cannot be changed through the profile patch.',
+        parameters: [],
+        returns: 'Current runtime entries with persistent patch targets.',
+      },
+      {
+        signature: '@Remote listBundles(): Promise<BundleInfo[]>',
+        description: 'Read installed bundles and bundles supplied by this dsh installation.',
+        parameters: [],
+        returns: 'Package versions, activation selections and removal availability.',
+      },
+      {
+        signature: '@Remote setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>',
+        description: 'Persist a plugin entry\'s desired enablement and apply it on live profiles.',
+        parameters: [{ name: 'id', description: 'Loader entry identity returned by listPlugins.' }, { name: 'enabled', description: 'Whether the plugin should run.' }],
+        returns: 'Saved and runtime outcomes, including higher-priority overrides.',
+      },
+      {
+        signature: '@Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>',
+        description: 'Select or remove a bundle layer while retaining installed dependencies.',
+        parameters: [{ name: 'name', description: 'Bundle package name.' }, { name: 'enabled', description: 'Whether the bundle contributes its patch layer.' }],
+        returns: 'Persisted and runtime outcomes.',
+      },
+      {
+        signature: '@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>',
+        description: 'Install a package using the same pnpm implementation as dsh plugin.',
+        parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle; defaults to true.' }],
+        returns: 'Package-manager diagnostics and observed activation outcome.',
+      },
+      {
+        signature: '@Remote removeBundle(name: string): Promise<ChangeResult>',
+        description: 'Unload and remove a profile-owned bundle dependency through dsh plugin\'s pnpm path.',
+        parameters: [{ name: 'name', description: 'Installed dependency name.' }],
+        returns: 'Removal diagnostics and the remaining profile state.',
+      },
+    ],
+  },
+  {
+    key: 'profileRuntime',
+    summary: 'Current-process profile operations; callbacks run under the shared profile write lock.',
+    description: 'Current-process profile operations; callbacks run under the shared profile write lock.',
+    methods: [
+      {
+        signature: 'readonly startedBundles: readonly string[]',
+        description: 'Bundle packages used to start this process, before any persisted edits.',
+        parameters: [],
+      },
+      {
+        signature: 'read(): Profile',
+        description: 'Read the current manifest and bundle patch layers without initializing a profile.',
+        parameters: [],
+        returns: 'Resolved disk configuration.',
+      },
+      {
+        signature: 'entries(): EntryOptions[]',
+        description: 'Compose disk configuration with the invocation\'s higher-priority layers.',
+        parameters: [],
+        returns: 'Effective entry options in composition order.',
+      },
+      {
+        signature: 'mutate<T>(operation: () => Promise<T>, waitMs?: number): Promise<T>',
+        description: 'Serialize a mutation with file watching and other profile writers.',
+        parameters: [{ name: 'operation', description: 'Work performed while holding the profile manifest lock.' }, { name: 'waitMs', description: 'Maximum lock acquisition time; omission uses the file writer default.' }],
+        returns: 'The operation\'s result.',
+      },
+      {
+        signature: 'reload(): Promise<void>',
+        description: 'Apply the current disk configuration; call only inside mutate.',
+        parameters: [],
+      },
+    ],
+  },
   {
     key: 'ptcRuntime',
     summary: 'Registers one `ctx.ptcRuntime` implementation.',
@@ -3966,6 +4044,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'BrowserUseProviderName',
     declaration: 'export type BrowserUseProviderName = Branded<\'BrowserUseProviderName\'>;',
   },
+  {
+    name: 'BundleInfo',
+    declaration: 'export interface BundleInfo {\n    name: string;\n    version?: string;\n    enabled: boolean;\n    removable: boolean;\n    error?: string;\n}',
+  },
+  {
+    name: 'ChangeResult',
+    declaration: 'export interface ChangeResult {\n    changed: boolean;\n    application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\';\n    message: string;\n    packageResult?: PackageResult;\n}',
+  },
   {
     name: 'ClientArtifactBaseline',
     declaration: 'export interface ClientArtifactBaseline {\n    readonly path: string;\n    readonly mtimeMs: number;\n    readonly size: number;\n}',
@@ -4526,6 +4612,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'InspectorJsonValue',
     declaration: 'export type InspectorJsonValue = InspectorJsonPrimitive | readonly InspectorJsonValue[] | InspectorJsonObject;',
   },
+  {
+    name: 'InstallBundleOptions',
+    declaration: 'export interface InstallBundleOptions {\n    enabled?: boolean;\n}',
+  },
   {
     name: 'InvariantFailure',
     declaration: 'export type InvariantFailure = (message: string) => never;',
@@ -4886,10 +4976,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'OptionalSessionSeq',
     declaration: 'export type OptionalSessionSeq = SessionSeq | null;',
   },
+  {
+    name: 'PackageResult',
+    declaration: 'export interface PackageResult {\n    exitCode: number;\n    output: string;\n    truncated: boolean;\n    logPath: string;\n}',
+  },
   {
     name: 'PermissionCatalog',
     declaration: 'export interface PermissionCatalog {\n    options: PresetOption[];\n}',
   },
+  {
+    name: 'PluginEntryId',
+    declaration: 'export type PluginEntryId = Branded<\'PluginEntryId\'>;',
+  },
+  {
+    name: 'PluginFiberPhase',
+    declaration: 'export type PluginFiberPhase = \'pending\' | \'loading\' | \'active\' | \'failed\' | \'unloading\' | null;',
+  },
+  {
+    name: 'PluginInfo',
+    declaration: 'export interface PluginInfo extends PluginInventoryEntry {\n    patchId?: string;\n    readOnlyReason?: string;\n}',
+  },
+  {
+    name: 'PluginInventoryEntry',
+    declaration: 'export interface PluginInventoryEntry {\n    readonly entryId: PluginEntryId;\n    readonly moduleName: string;\n    readonly enabled: boolean;\n    readonly fiberPhase: PluginFiberPhase;\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};',
@@ -4938,6 +5048,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PreToolDecision',
     declaration: 'export type PreToolDecision = {\n    kind: \'allow\';\n} | {\n    kind: \'deny\';\n    reason: string;\n    info?: ToolErrorInfo;\n} | {\n    kind: \'cancel\';\n} | {\n    kind: \'ask\';\n    reason?: string;\n};',
   },
+  {
+    name: 'Profile',
+    declaration: 'export interface Profile {\n    name: string;\n    dir: string;\n    layers: ProfileLayer[];\n    patchPath: string;\n    patches: PatchOptions[];\n    patchReload: ProfilePatchReload;\n}',
+  },
+  {
+    name: 'ProfileLayer',
+    declaration: 'export interface ProfileLayer {\n    packageName: string;\n    packageDir: string;\n    patchPath: string;\n    patches: PatchOptions[];\n}',
+  },
+  {
+    name: 'ProfilePatchReload',
+    declaration: 'export type ProfilePatchReload = \'live\' | \'startup\';',
+  },
   {
     name: 'ProjectionChangeListener',
     declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract<keyof SessionProjectionMap, string>, value: unknown, seq: SessionSeq) => void;',

+ 31 - 22
packages/host/plugin-inventory/src/index.ts

@@ -64,29 +64,38 @@ export class PluginInventoryGateway extends TypertRemoteService {
    */
   @Remote('list')
   async list(): Promise<PluginInventorySnapshot> {
-    const entries: PluginInventoryEntry[] = []
-    for (const entry of this.ctx.loader.entries()) {
-      if (entry.options.group) continue
-      entries.push({
-        entryId: pluginEntryId(entry.id),
-        moduleName: entry.options.name,
-        enabled: !entry.disabled,
-        fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
-      })
-    }
-    const presets = this.ctx.get('agentPresets')
-    if (presets === undefined) return { entries }
-    const agentPresets: AgentPresetPluginGroup[] = (await presets.compositionInventory()).map(
-      composition => ({
-        ...composition,
-        rows: composition.rows.map(({ fiberState, ...row }) => ({
-          ...row,
-          fiberPhase: fiberState === undefined ? null : FIBER_PHASE[fiberState],
-        })),
-      }),
-    )
-    return { entries, agentPresets }
+    return readPluginInventory(this.ctx)
   }
 }
 
 export default PluginInventoryGateway
+
+/** Read current Loader entries and optional preset compositions.
+ * @param ctx Context with the Loader service.
+ * @returns Current inventory without a separate runtime cache.
+ */
+export async function readPluginInventory(ctx: Context): Promise<PluginInventorySnapshot> {
+  const entries: PluginInventoryEntry[] = []
+  for (const entry of ctx.loader.entries()) {
+    if (entry.options.group) continue
+    entries.push({
+      entryId: pluginEntryId(entry.id),
+      moduleName: entry.options.name,
+      enabled: !entry.disabled,
+      fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
+    })
+  }
+  const presets = ctx.get('agentPresets')
+  const management = ctx.get('pluginManager') === undefined ? {} : { managementAvailable: true }
+  if (presets === undefined) return { entries, ...management }
+  const agentPresets: AgentPresetPluginGroup[] = (await presets.compositionInventory()).map(
+    composition => ({
+      ...composition,
+      rows: composition.rows.map(({ fiberState, ...row }) => ({
+        ...row,
+        fiberPhase: fiberState === undefined ? null : FIBER_PHASE[fiberState],
+      })),
+    }),
+  )
+  return { entries, agentPresets, ...management }
+}

+ 2 - 0
packages/host/plugin-inventory/src/types.ts

@@ -61,6 +61,8 @@ export interface AgentPresetPluginGroup {
 
 /** Point-in-time inventory returned by the plugin inventory Remote. */
 export interface PluginInventorySnapshot {
+  /** Whether this Host exposes persistent current-profile management. */
+  readonly managementAvailable?: boolean
   readonly entries: readonly PluginInventoryEntry[]
   /**
    * Per-preset compositions, present only when an agent-preset roster is

+ 4 - 0
packages/preset/agent-presets/presets/cordis/agent.cordis.yml

@@ -271,3 +271,7 @@
 
 - id: present
   name: '@deepseek-ai/dsh-tool-present'
+
+- id: tool-plugin-manager
+  name: '@deepseek-ai/dsh-plugin-manager/tools'
+  disabled: !!js !ctx.get('profileRuntime')

+ 4 - 0
packages/preset/agent-presets/presets/ptc/agent.cordis.yml

@@ -281,3 +281,7 @@
 
 - id: present
   name: '@deepseek-ai/dsh-tool-present'
+
+- id: tool-plugin-manager
+  name: '@deepseek-ai/dsh-plugin-manager/tools'
+  disabled: !!js !ctx.get('profileRuntime')

+ 4 - 0
packages/preset/agent-presets/presets/standard/agent.cordis.yml

@@ -260,3 +260,7 @@
 
 - id: present
   name: '@deepseek-ai/dsh-tool-present'
+
+- id: tool-plugin-manager
+  name: '@deepseek-ai/dsh-plugin-manager/tools'
+  disabled: !!js !ctx.get('profileRuntime')

+ 67 - 0
pnpm-lock.yaml

@@ -185,6 +185,9 @@ importers:
       '@deepseek-ai/dsh-app-boot':
         specifier: workspace:^
         version: link:../../packages/boot/app-boot
+      '@deepseek-ai/dsh-atomic-write':
+        specifier: workspace:^
+        version: link:../../packages/util/atomic-write
       '@deepseek-ai/dsh-base':
         specifier: workspace:^
         version: link:../../packages/bundle/base
@@ -254,6 +257,9 @@ importers:
       '@deepseek-ai/dsh-plan-mode':
         specifier: workspace:^
         version: link:../../packages/plan/plan-mode
+      '@deepseek-ai/dsh-plugin-manager':
+        specifier: workspace:^
+        version: link:../../packages/boot/plugin-manager
       '@deepseek-ai/dsh-pwsh-local':
         specifier: workspace:^
         version: link:../../packages/shell/pwsh-local
@@ -1002,6 +1008,9 @@ importers:
       '@deepseek-ai/dsh-permission-presets':
         specifier: workspace:^
         version: link:../../interaction/permission-presets
+      '@deepseek-ai/dsh-plugin-manager':
+        specifier: workspace:^
+        version: link:../../boot/plugin-manager
       '@deepseek-ai/dsh-scope':
         specifier: workspace:^
         version: link:../../core/scope
@@ -1439,6 +1448,61 @@ importers:
         specifier: ^15.0.0
         version: 15.0.0
 
+  packages/boot/plugin-manager:
+    dependencies:
+      '@deepseek-ai/dsh-app-boot':
+        specifier: workspace:^
+        version: link:../app-boot
+      '@deepseek-ai/dsh-atomic-write':
+        specifier: workspace:^
+        version: link:../../util/atomic-write
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+      execa:
+        specifier: ^10.0.0
+        version: 10.0.0
+      yaml:
+        specifier: ^2.9.0
+        version: 2.9.0
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-host-plugin-inventory':
+        specifier: workspace:^
+        version: link:../../host/plugin-inventory
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-subprocess':
+        specifier: workspace:^
+        version: link:../../subprocess/subprocess
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
+
   packages/browser-use/browser-use:
     devDependencies:
       '@deepseek-ai/cordis':
@@ -1562,6 +1626,9 @@ importers:
       '@deepseek-ai/dsh-plan-mode':
         specifier: workspace:^
         version: link:../../plan/plan-mode
+      '@deepseek-ai/dsh-plugin-manager':
+        specifier: workspace:^
+        version: link:../../boot/plugin-manager
       '@deepseek-ai/dsh-plugin-package-inventory-deepseek':
         specifier: workspace:^
         version: link:../../llm/plugin-package-inventory-deepseek

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

@@ -54,6 +54,8 @@ export { REGION_BEGIN, REGION_END }
  * errors, so the partition can never silently drift from the service API.
  */
 export const SERVICE_PAGE: Record<string, string> = {
+  pluginManager: 'boot.md',
+  profileRuntime: 'boot.md',
   mcpResources: 'mcp.md',
   agentLoop: 'core.md',
   agentDefaultModel: 'core.md',
@@ -252,6 +254,11 @@ export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
  * appear on more than one page.
  */
 export const LINK_MAP: Readonly<Record<string, string>> = {
+  PluginInfo: 'boot.md',
+  BundleInfo: 'boot.md',
+  ChangeResult: 'boot.md',
+  InstallBundleOptions: 'boot.md',
+  PluginEntryId: 'boot.md',
   BrowserUseProviderName: 'browser-use.md',
   ComputerUseProviderName: 'computer-use.md',
   Agent: 'core.md',
@@ -687,6 +694,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
   'Context',
   'Error',
   'EntryTree',
+  'EntryOptions',
   'Exclude',
   'Extract',
   'Map',
@@ -706,6 +714,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
 
 /** Project types deliberately documented outside the subsystems catalog. */
 export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
+  Profile: 'resolved profile layers are owned by packages/boot/app-boot/README.md',
   McpResourceProvider: 'scoped resource provider is owned by packages/mcp/mcp-resources/README.md',
   'z.ZodType': 'Zod response validation API is owned by https://zod.dev/packages/zod',
   Socket: 'Node.js byte stream API is owned by https://nodejs.org/api/net.html#class-netsocket',

+ 14 - 0
scripts/gen-tool-catalog.ts

@@ -64,6 +64,8 @@ import * as StagehandBrowserTools from '@deepseek-ai/dsh-experimental-browser-us
 import type TeamService from '@deepseek-ai/dsh-experimental-agent-team'
 import * as ToolTeam from '@deepseek-ai/dsh-experimental-tool-agent-team'
 import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
+import type PluginManager from '@deepseek-ai/dsh-plugin-manager'
+import * as PluginManagerTools from '@deepseek-ai/dsh-plugin-manager/tools'
 import McpResources from '@deepseek-ai/dsh-mcp-resources'
 import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
 import { registerListSubagentModels } from '../packages/subagent/tool-subagent/src/list-models.ts'
@@ -199,6 +201,18 @@ export interface ToolPackage {
  * guard proves it is exhaustive against the on-disk glob.
  */
 const TOOL_PACKAGES: ToolPackage[] = [
+  {
+    pkg: '@deepseek-ai/dsh-plugin-manager',
+    dir: 'plugin-manager',
+    source: 'packages/boot/plugin-manager/src/tools.ts',
+    requires: ['ctx.tools', 'ctx.pluginManager'],
+    writes: ['tool/call', 'tool/result', 'user/message'],
+    async mount(ctx) {
+      // Schema harvest never executes a management method or opens a profile.
+      ctx.provide('pluginManager', {} as PluginManager)
+      await ctx.plugin(PluginManagerTools)
+    },
+  },
   {
     pkg: '@deepseek-ai/dsh-mcp-resources',
     dir: 'mcp-resources',

+ 0 - 1
scripts/verify-subsystem-pages.ts

@@ -16,7 +16,6 @@ const root = resolve(import.meta.dirname, '..')
  */
 export const GROUPS_WITHOUT_SUBSYSTEM_PAGE: Readonly<Record<string, string>> = {
   acp: 'Protocol transport entry point; the server package README owns its interoperability contract.',
-  boot: 'Shared application-bin boot library rather than a runtime subsystem.',
   bundle: 'Composition patch carriers whose mounted packages own all runtime contracts.',
   hooks: 'External hook-protocol bridges over existing interception points, not a new Harness service.',
   sdk: 'Out-of-process protocol and client packages whose package READMEs own the SDK contracts.',

+ 4 - 0
tsconfig.base.json

@@ -172,6 +172,10 @@
       "@deepseek-ai/dsh-host-open-in-app/shared": ["./packages/host/open-in-app/src/shared.ts"],
       "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
       "@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"],
+      "@deepseek-ai/dsh-plugin-manager": ["./packages/boot/plugin-manager/src"],
+      "@deepseek-ai/dsh-plugin-manager/types": ["./packages/boot/plugin-manager/src/types.ts"],
+      "@deepseek-ai/dsh-plugin-manager/operations": ["./packages/boot/plugin-manager/src/operations.ts"],
+      "@deepseek-ai/dsh-plugin-manager/tools": ["./packages/boot/plugin-manager/src/tools.ts"],
       "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"],
       "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"],
       "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],

+ 1 - 0
tsconfig.host.json

@@ -373,6 +373,7 @@
     { "path": "./packages/host/frontend-static" },
     { "path": "./packages/host/open-in-app" },
     { "path": "./packages/host/plugin-inventory" },
+    { "path": "./packages/boot/plugin-manager" },
     { "path": "./packages/llm/plugin-package-inventory-deepseek" },
     { "path": "./packages/host/webserver" },
     { "path": "./packages/sdk/client" },