Kaynağa Gözat

refactor(plugin-manager): split the manager module by responsibility

`src/index.ts` held the installer, the manager, the view fold, and every
helper in one 1100-line file. It is now the package API alone, re-exporting
from `installer.ts` (pnpm runs, the probe cache, post-install checks),
`manager.ts` (every operation over the booted profile and the mutex),
`view.ts` (the package view fold and the row-ownership walk the dependents
query shares), `modules.ts` (declared-module row naming and views), and
`helpers.ts` (the diagnostic prefix, tooling bounds, spawn seam, and manifest
readers). The public surface, the tests, and the failure codes are unchanged.
Yichen Jiang 3 hafta önce
ebeveyn
işleme
e0841bec01

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
-README.md: ff686c3c491978f846f742ab0cb699cff4a8a57a
-README.zh.md: 086593fb1b6d7aaf41adc8b0e1cf4934d904fd4c
+README.md: 07eee8b2b95d5d3a1860d12b1bc804a5f0a3fde2
+README.zh.md: e20bd584257ee75b74b187a0c61bb4c358377a94

+ 6 - 1
packages/boot/plugin-manager/README.md

@@ -94,7 +94,12 @@ The manager reads the Loader tree, the reflect store, and the `pluginFailures` r
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | `PluginInstaller` (pnpm runs, probing, post-install checks) and `PluginManager` (every operation over the booted profile, the mutex, and the view fold) |
+| [`src/index.ts`](src/index.ts) | The package API: re-exports of the classes, options, types, and failure codes |
+| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`: pnpm runs with streamed output, the probe record cache, and the post-install checks |
+| [`src/manager.ts`](src/manager.ts) | `PluginManager`: every operation over the booted profile, the one-at-a-time mutex, user-layer edits, and dependents |
+| [`src/view.ts`](src/view.ts) | The view fold: manifest, probe, and live-tree rows into one `PluginPackageView`, and the row-ownership walk |
+| [`src/modules.ts`](src/modules.ts) | Declared `dsh.plugins` modules: row naming, derived row ids, and their wire view |
+| [`src/helpers.ts`](src/helpers.ts) | Shared vocabulary: the diagnostic prefix, tooling bounds, the spawn seam, and the manifest readers |
 | [`src/types.ts`](src/types.ts) | Payloads, the `plugins/changed` and `plugins/install-log` events, and the `plugins/*` failure codes with their details |
 | [`src/errors.ts`](src/errors.ts) | `PluginOperationError` and the code-discriminated failure union |
 | — | No runtime invariant companion is published; every view is folded from the manifest, the probe cache, and Loader-owned state on each call. |

+ 6 - 1
packages/boot/plugin-manager/README.zh.md

@@ -94,7 +94,12 @@ Loader 的事务性更新不会碰未改变的行,因此一条失败的隔离
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | `PluginInstaller`(pnpm 运行、探测、装后检查)与 `PluginManager`(已启动 profile 上的每项操作、互斥与视图折叠) |
+| [`src/index.ts`](src/index.ts) | 包 API:类、选项、类型与失败码的 re-export |
+| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`:流式输出的 pnpm 运行、探针记录缓存与装后检查 |
+| [`src/manager.ts`](src/manager.ts) | `PluginManager`:已启动 profile 上的每项操作、一次一个的互斥、用户层编辑与依赖查询 |
+| [`src/view.ts`](src/view.ts) | 视图折叠:把 manifest、探针与在线树的行折成一份 `PluginPackageView`,以及行归属遍历 |
+| [`src/modules.ts`](src/modules.ts) | 声明的 `dsh.plugins` 模块:行命名、派生行 id 及其 wire 视图 |
+| [`src/helpers.ts`](src/helpers.ts) | 共享词汇:诊断前缀、工具边界、spawn 测试缝与 manifest 读取器 |
 | [`src/types.ts`](src/types.ts) | 载荷、`plugins/changed` 与 `plugins/install-log` 事件,以及 `plugins/*` 失败码及其 details |
 | [`src/errors.ts`](src/errors.ts) | `PluginOperationError` 与按码区分的失败联合 |
 | — | 不发布运行时不变量伴随件;每份视图都在每次调用时从 manifest、探针缓存与 Loader 持有的状态折叠而来。 |

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

@@ -0,0 +1,64 @@
+/**
+ * Vocabulary the installer, the manager, and the view fold share: the
+ * diagnostic prefix, the tooling bounds, the spawn seam, and the two profile
+ * manifest readers a hand-written manifest may leave empty.
+ * @module @deepseek-ai/dsh-plugin-manager/helpers
+ */
+
+import type { ChildProcess, SpawnOptions } from 'node:child_process'
+import type { ProfileManifest } from '@deepseek-ai/dsh-app-boot'
+
+/** Diagnostic prefix on errors this package raises and on the app-boot helpers it calls. */
+export const NAME = 'plugin-manager'
+
+/**
+ * The message of a thrown value; a non-Error keeps its text.
+ * @param error - the thrown value.
+ * @returns its message.
+ */
+export function messageOf(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}
+
+/**
+ * The profile's installed dependencies by name; a hand-written manifest may omit the field.
+ * @param manifest - the profile manifest.
+ * @returns the dependencies, empty when absent.
+ */
+export function dependenciesOf(manifest: ProfileManifest): Record<string, string> {
+  return manifest.dependencies ?? {}
+}
+
+/**
+ * The profile's enabled layer list; a hand-written manifest may omit the section.
+ * @param manifest - the profile manifest.
+ * @returns the bundle names, empty when absent.
+ */
+export function bundlesOf(manifest: ProfileManifest): readonly string[] {
+  return manifest.dsh?.profile?.bundles ?? []
+}
+
+/** The spawn function, replaceable in tests so no pnpm runs. */
+export type SpawnLike = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess
+
+/** The pnpm command and the bounds on the child processes plugin management runs. */
+export interface PluginToolingConfig {
+  /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
+  readonly pnpmCommand: string
+  /** Bound on one install or remove run, in milliseconds. */
+  readonly installTimeoutMs: number
+  /** Bound on one package probe, in milliseconds. */
+  readonly probeTimeoutMs: number
+  /** How many trailing bytes of an install run's output an install failure reports. */
+  readonly installLogTailBytes: number
+}
+
+/**
+ * One optional field, present only when its value is.
+ * @param key - the field name.
+ * @param value - the value, or undefined for no field.
+ * @returns an object with the field, or an empty object.
+ */
+export function optional<K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } {
+  return value === undefined ? {} : { [key]: value } as { [P in K]?: V }
+}

+ 4 - 1085
packages/boot/plugin-manager/src/index.ts

@@ -16,1092 +16,11 @@
  * @module @deepseek-ai/dsh-plugin-manager
  */
 
-import { spawn as spawnChild, type ChildProcess, type SpawnOptions } from 'node:child_process'
-import { randomUUID } from 'node:crypto'
-import { readFileSync, rmSync, writeFileSync } from 'node:fs'
-import { join } from 'node:path'
-import type { Context, Fiber, FiberState } from '@deepseek-ai/cordis'
-import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
-import {
-  awaitChildClose,
-  bundleGroupId,
-  claimLayerIds,
-  disableBundle,
-  enableBundle,
-  healProfilesModuleFallback,
-  layerTrust,
-  loadProfile,
-  PLUGIN_PROBE_DIR,
-  probePackage,
-  readProbeCache,
-  readProfileManifest,
-  reconcileInstalledBundles,
-  recordContainedStates,
-  resolveBundleDir,
-  resolveProfileLayer,
-  writeProbeCache,
-  type PluginProbe,
-  type Profile,
-  type ProfileManifest,
-  type ProfileRuntime,
-} from '@deepseek-ai/dsh-app-boot'
-import type { BundleStage } from '@deepseek-ai/dsh-package-manifest'
-import { mutatePatchFile, readPatchListFile, type PatchRow } from '@deepseek-ai/dsh-app-boot/patch-file'
-import type { JsonValue } from '@deepseek-ai/dsh-util-values'
-import { PluginOperationError } from './errors.ts'
-import type {
-  PluginChangeReason,
-  PluginDependents,
-  PluginEnableResult,
-  PluginInstallLogChunk,
-  PluginInstallRejection,
-  PluginInstallResult,
-  PluginPackageAddableView,
-  PluginPackageRowView,
-  PluginPackageStatus,
-  PluginPackageView,
-  PluginRowAddition,
-  PluginRowPhase,
-  PluginRowReference,
-  PluginRowTarget,
-  PluginServiceDependent,
-} from './types.ts'
-
 export * from './errors.ts'
 export type * from './types.ts'
-
-/** Diagnostic prefix on errors this package raises and on the app-boot helpers it calls. */
-const NAME = 'plugin-manager'
-
-/** The message of a thrown value; a non-Error keeps its text. */
-function messageOf(error: unknown): string {
-  return error instanceof Error ? error.message : String(error)
-}
-
-/** The profile's installed dependencies by name; a hand-written manifest may omit the field. */
-function dependenciesOf(manifest: ProfileManifest): Record<string, string> {
-  return manifest.dependencies ?? {}
-}
-
-/** The profile's enabled layer list; a hand-written manifest may omit the section. */
-function bundlesOf(manifest: ProfileManifest): readonly string[] {
-  return manifest.dsh?.profile?.bundles ?? []
-}
-
-/** Runtime mirror: FiberState is a cross-package const enum. */
-const FIBER_STATE = {
-  PENDING: 0 as FiberState.PENDING,
-  LOADING: 1 as FiberState.LOADING,
-  ACTIVE: 2 as FiberState.ACTIVE,
-  FAILED: 3 as FiberState.FAILED,
-  DISPOSED: 4 as FiberState.DISPOSED,
-  UNLOADING: 5 as FiberState.UNLOADING,
-} as const
-
-/** Complete projection of Cordis Fiber states onto the row phase vocabulary. */
-const ROW_PHASE = {
-  [FIBER_STATE.PENDING]: 'pending',
-  [FIBER_STATE.LOADING]: 'loading',
-  [FIBER_STATE.ACTIVE]: 'active',
-  [FIBER_STATE.FAILED]: 'failed',
-  [FIBER_STATE.DISPOSED]: null,
-  [FIBER_STATE.UNLOADING]: 'unloading',
-} as const satisfies Record<FiberState, PluginRowPhase>
-
-/** The spawn function, replaceable in tests so no pnpm runs. */
-export type SpawnLike = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess
-
-/** The pnpm command and the bounds on the child processes plugin management runs. */
-export interface PluginToolingConfig {
-  /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
-  readonly pnpmCommand: string
-  /** Bound on one install or remove run, in milliseconds. */
-  readonly installTimeoutMs: number
-  /** Bound on one package probe, in milliseconds. */
-  readonly probeTimeoutMs: number
-  /** How many trailing bytes of an install run's output an install failure reports. */
-  readonly installLogTailBytes: number
-}
-
-/** What one install run changed before any newly installed bundle was enabled. */
-export type PluginInstallOutcome = Omit<PluginInstallResult, 'enabled'>
-
-/** What {@link PluginInstaller} needs: the profile on disk, the tooling bounds, and where pnpm's output goes. */
-export interface PluginInstallerOptions {
-  /** The profile directory pnpm runs in and whose manifest records installs. */
-  readonly profileDir: string
-  /** The profile name, for diagnostics. */
-  readonly profileName: string
-  /** Absolute path of the dsh app's package.json: the first resolution anchor for installed packages. */
-  readonly installAnchor: string
-  /** The profile's composed layers, read when a new bundle's row ids are checked against them. */
-  readonly loadProfile: () => Profile
-  readonly config: PluginToolingConfig
-  /** Receives every chunk of a pnpm run's output, in order; the last chunk carries the exit code. */
-  readonly installLog: (chunk: PluginInstallLogChunk) => void
-  /** Test seam: the child spawner; defaults to `node:child_process`. */
-  readonly spawn?: SpawnLike
-  /** Test seam: the package probe; defaults to app-boot's. */
-  readonly probe?: typeof probePackage
-}
-
-/**
- * The agent-preset roster as row operations see it: where each preset's user
- * patch layer lives and which row ids its composition already carries. The
- * roster service satisfies this structurally; a host passes it in so this
- * package never imports the roster.
- */
-export interface PresetLayers {
-  /**
-   * The user patch layer file of one preset, created on the first write.
-   * @param presetId - the preset id.
-   * @returns the absolute path.
-   */
-  overlayPathFor(presetId: string): Promise<string>
-  /**
-   * Every preset with the path of its user patch layer when it has one.
-   * @returns the presets.
-   */
-  list(): Promise<readonly { readonly id: string; readonly overlayPath?: string }[]>
-  /**
-   * Every preset's composition as rows, under the ids the composition declares; an anonymous row has none.
-   * @returns one composition per preset.
-   */
-  compositionInventory(): Promise<readonly { readonly id: string; readonly rows: readonly { readonly entryId: string | null }[] }[]>
-}
-
-/** What {@link PluginManager} needs beyond the Cordis context it reads the tree through. */
-export interface PluginManagerOptions {
-  readonly config: PluginToolingConfig
-  /** The booted profile, read per call; `undefined` means no profile is composed in this process. */
-  readonly runtime: () => ProfileRuntime | undefined
-  /** The preset roster's layers, read per call; `undefined` when no roster is composed. */
-  readonly presets: () => PresetLayers | undefined
-  /** How many agents are running; `add` and `uninstall` refuse while any is. */
-  readonly runningAgents: () => number
-  /** Test seam: the child spawner; defaults to `node:child_process`. */
-  readonly spawn?: SpawnLike
-  /** Test seam: the package probe; defaults to app-boot's. */
-  readonly probe?: typeof probePackage
-}
-
-/** The installed package's manifest slice the view reads. */
-type InstalledManifest = ProfileManifest & { description?: string; dsh?: ProfileManifest['dsh'] & { title?: string } }
-
-/** Fields of one row's provenance a package view needs. */
-interface RowFacts {
-  readonly entry: Entry
-  readonly rowId: string
-}
-
-/**
- * Installs and removes packages in one profile with pnpm, and probes them.
- *
- * Every run reads the profile manifest afresh and writes it through the
- * app-boot helpers the `dsh plugin` command uses, so the CLI and the Web
- * host never disagree on the file: `dependencies` records what is installed,
- * `dsh.profile.bundles` what is enabled. Nothing here touches a running
- * tree, which is what lets the CLI install before any plugin starts.
- */
-export class PluginInstaller {
-  private readonly spawn: SpawnLike
-  private readonly probeRunner: typeof probePackage
-
-  /**
-   * @param options - the profile, the tooling bounds, and the output sink.
-   */
-  constructor(private readonly options: PluginInstallerOptions) {
-    this.spawn = options.spawn ?? spawnChild
-    this.probeRunner = options.probe ?? probePackage
-  }
-
-  /**
-   * Whether the package is a profile dependency.
-   * @param packageName - the package.
-   * @returns true when the manifest lists it.
-   */
-  isInstalled(packageName: string): boolean {
-    return packageName in dependenciesOf(readProfileManifest(NAME, this.options.profileDir))
-  }
-
-  /**
-   * Refuse a package the profile does not have.
-   * @param packageName - the package.
-   * @throws {PluginOperationError} `plugins/not-installed`.
-   */
-  assertInstalled(packageName: string): void {
-    if (this.isInstalled(packageName)) return
-    throw new PluginOperationError(
-      'plugins/not-installed',
-      `${NAME}: ${packageName} is not installed in profile ${this.options.profileName}`,
-      { packageName },
-    )
-  }
-
-  /**
-   * The installed package's manifest.
-   * @param packageName - the package.
-   * @returns the manifest, or undefined when the package cannot be resolved from the profile.
-   */
-  readInstalledManifest(packageName: string): InstalledManifest | undefined {
-    let dir: string
-    try {
-      dir = resolveBundleDir(NAME, packageName, this.options.installAnchor, this.options.profileDir)
-    } catch {
-      // A dependency pnpm removed underneath the manifest, or one that never
-      // materialized; the caller reports what the manifest still says.
-      return undefined
-    }
-    // resolveBundleDir answers only a directory holding a package.json.
-    return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as InstalledManifest
-  }
-
-  /**
-   * The package's probe record, from the profile cache or a fresh probe
-   * written to it. A record for another installed version is not reused.
-   * @param packageName - the installed package.
-   * @returns the record.
-   */
-  async probe(packageName: string): Promise<PluginProbe> {
-    const version = this.readInstalledManifest(packageName)?.version
-    const cached = readProbeCache(this.options.profileDir, packageName, version)
-    if (cached !== undefined) return cached
-    const probe = await this.probeRunner({
-      binName: NAME,
-      profileDir: this.options.profileDir,
-      installAnchor: this.options.installAnchor,
-      packageName,
-      timeoutMs: this.options.config.probeTimeoutMs,
-    })
-    writeProbeCache(this.options.profileDir, probe)
-    return probe
-  }
-
-  /**
-   * Install a package with pnpm, probe every package the run added, and
-   * remove what has no place in a profile. New bundles are left disabled.
-   * @param spec - what to install, in pnpm's own vocabulary: a registry
-   * name, a `github:` or git URL, a tarball, or an absolute path.
-   * @returns what the run installed and what it removed again.
-   * @throws {PluginOperationError} `plugins/bad-request` for an empty spec,
-   * `plugins/install-failed` when pnpm exits non-zero, cannot be spawned, or times out.
-   */
-  async add(spec: string): Promise<PluginInstallOutcome> {
-    if (spec.trim().length === 0) {
-      throw new PluginOperationError('plugins/bad-request', `${NAME}: the package spec must not be empty`, {})
-    }
-    const { profileDir, installAnchor } = this.options
-    const manifestPath = join(profileDir, 'package.json')
-    const snapshot = readFileSync(manifestPath, 'utf8')
-    const before = readProfileManifest(NAME, profileDir)
-    let jobId: string
-    try {
-      jobId = await this.runPnpm(['add', spec], spec)
-    } catch (error) {
-      // pnpm may have written the manifest before failing; the profile keeps
-      // the manifest it had, and what pnpm left under node_modules is not a
-      // dependency until a manifest names it.
-      if (readFileSync(manifestPath, 'utf8') !== snapshot) writeFileSync(manifestPath, snapshot)
-      throw error
-    }
-    const outcome = reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
-    const after = readProfileManifest(NAME, profileDir)
-    const added = Object.keys(dependenciesOf(after)).filter(name => !(name in dependenciesOf(before)))
-    await healProfilesModuleFallback({ installAnchor, profile: this.options.loadProfile() })
-    const installed: string[] = []
-    const removed: PluginInstallRejection[] = []
-    for (const name of added) {
-      const reason = await this.rejection(name)
-      if (reason === undefined) {
-        installed.push(name)
-        continue
-      }
-      await this.remove(name)
-      removed.push({ name, reason })
-    }
-    const kept = new Set(installed)
-    return {
-      installed,
-      removed,
-      installedOnly: outcome.installedOnly.filter(name => kept.has(name)),
-      plain: outcome.plain.filter(name => kept.has(name)),
-      jobId,
-    }
-  }
-
-  /**
-   * Run `pnpm remove`, reconcile the layer list, and forget the probe record.
-   * @param packageName - the dependency to remove.
-   * @throws {PluginOperationError} `plugins/install-failed` when pnpm fails.
-   */
-  async remove(packageName: string): Promise<void> {
-    const { profileDir, installAnchor } = this.options
-    const before = readProfileManifest(NAME, profileDir)
-    await this.runPnpm(['remove', packageName], packageName)
-    reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
-    rmSync(join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`), { force: true })
-  }
-
-  /**
-   * The post-install check of one package pnpm added: a package that is
-   * neither a bundle nor a plugin module has no place in a profile, and a
-   * bundle whose row id another layer already owns could only mount as a
-   * conflict record. A package the probe cannot run stays installed: the
-   * view reports it as not enableable with the probe's own reason.
-   * @returns why the package is removed again, or undefined to keep it.
-   */
-  private async rejection(packageName: string): Promise<string | undefined> {
-    let probe: PluginProbe
-    try {
-      probe = await this.probe(packageName)
-    } catch {
-      return undefined // the failure is re-derived on every list and shown there
-    }
-    // A refused probe (an import that throws, another cordis copy) keeps the
-    // package: the view shows it as not enableable with the probe's reason.
-    if (!probe.ok) return undefined
-    if (probe.kind === 'library') return 'declares neither a dsh bundle nor a plugin module'
-    if (probe.kind !== 'bundle') return undefined
-    const { profileDir, installAnchor } = this.options
-    try {
-      const layer = resolveProfileLayer(NAME, readProfileManifest(NAME, profileDir), packageName, installAnchor, profileDir)
-      const lost = claimLayerIds([...this.options.loadProfile().layers, layer]).skipped.get(packageName)
-      if (lost === undefined) return undefined
-      return lost.map(conflict => `row ${JSON.stringify(conflict.rowId)} is already declared by ${conflict.declaredBy}`).join('; ')
-    } catch (error) {
-      return messageOf(error)
-    }
-  }
-
-  /**
-   * Run one pnpm command in the profile directory, streaming its output as
-   * install-log chunks.
-   * @returns the run's job id.
-   * @throws {PluginOperationError} `plugins/install-failed` on a non-zero exit, a signal, or the timeout.
-   */
-  private async runPnpm(args: readonly string[], spec: string): Promise<string> {
-    const { config, profileDir } = this.options
-    const jobId = randomUUID()
-    const tail: string[] = []
-    let tailBytes = 0
-    const record = (stream: 'stdout' | 'stderr', text: string): void => {
-      tail.push(text)
-      tailBytes += Buffer.byteLength(text)
-      while (tailBytes > config.installLogTailBytes && tail.length > 1) {
-        tailBytes -= Buffer.byteLength(tail.shift() as string)
-      }
-      this.options.installLog({ jobId, spec, stream, text })
-    }
-    // Windows resolves pnpm through its .cmd shim, which spawn() refuses
-    // without a shell since the CVE-2024-27980 hardening. The parent
-    // environment is passed whole, as the `dsh plugin` command does: pnpm
-    // needs the user's registry, proxy, and auth settings.
-    const child = this.spawn(config.pnpmCommand, args, {
-      cwd: profileDir,
-      stdio: ['ignore', 'pipe', 'pipe'],
-      shell: process.platform === 'win32',
-      env: process.env,
-    })
-    child.stdout?.setEncoding('utf8')
-    child.stderr?.setEncoding('utf8')
-    child.stdout?.on('data', (text: string) => { record('stdout', text) })
-    child.stderr?.on('data', (text: string) => { record('stderr', text) })
-    const exitCode = await awaitChildClose(
-      child, config.installTimeoutMs,
-      () => new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(config.installTimeoutMs)}ms`),
-    ).catch((error: unknown) => {
-      const message = messageOf(error)
-      record('stderr', `${message}\n`)
-      this.options.installLog({ jobId, spec, stream: 'stderr', text: '', exitCode: null })
-      throw new PluginOperationError('plugins/install-failed', `${NAME}: ${message}`, { spec, exitCode: null, log: tail.join('') }, { cause: error })
-    })
-    this.options.installLog({ jobId, spec, stream: 'stdout', text: '', exitCode })
-    if (exitCode !== 0) {
-      throw new PluginOperationError(
-        'plugins/install-failed',
-        `${NAME}: pnpm ${args.join(' ')} exited with ${String(exitCode)} in ${profileDir}`,
-        { spec, exitCode, log: tail.join('') },
-      )
-    }
-    return jobId
-  }
-}
-
-/**
- * Plugin management over the booted profile: every operation the `plugins`
- * Remote exposes, with the tree read through the Cordis context and the
- * profile runtime, the preset roster, and the running-agent count read
- * through {@link PluginManagerOptions} per call.
- *
- * Every write touches the profile manifest, the user layers, or
- * `node_modules`, and two at once would race on those files, so the manager
- * runs one mutation at a time and refuses a second rather than queueing it.
- * Every change is followed by a `plugins/changed` event on the context.
- */
-export class PluginManager {
-  /** The mutation in flight, while one is; a second caller is refused rather than queued. */
-  private active: { operation: string; subject: string } | undefined
-
-  /**
-   * @param ctx - the context whose Loader tree and services the operations read.
-   * @param options - the profile runtime, the roster, the agent count, and the tooling bounds.
-   */
-  constructor(private readonly ctx: Context, private readonly options: PluginManagerOptions) {}
-
-  /**
-   * Run one mutation with the manager to itself.
-   * @throws {PluginOperationError} `plugins/busy` naming the operation in flight.
-   */
-  private async exclusive<T>(operation: string, subject: string, run: () => Promise<T>): Promise<T> {
-    if (this.active !== undefined) {
-      throw new PluginOperationError(
-        'plugins/busy',
-        `${NAME}: ${operation} ${subject} refused while ${this.active.operation} ${this.active.subject} is still running`,
-        { operation, subject, active: this.active },
-      )
-    }
-    this.active = { operation, subject }
-    try {
-      return await run()
-    } finally {
-      this.active = undefined
-    }
-  }
-
-  /**
-   * Refuse a change to `node_modules` while a session is running: pnpm
-   * rewrites the directory the running agents import from.
-   * @throws {PluginOperationError} `plugins/agents-running` with the count.
-   */
-  private assertNoRunningAgents(operation: string): void {
-    const running = this.options.runningAgents()
-    if (running === 0) return
-    throw new PluginOperationError(
-      'plugins/agents-running',
-      `${NAME}: ${operation} waits for ${String(running)} running session(s) to go idle`,
-      { operation, running },
-    )
-  }
-
-  /** The profile runtime, or the failure a caller without one receives. */
-  private runtime(): ProfileRuntime {
-    const runtime = this.options.runtime()
-    if (runtime === undefined) {
-      throw new PluginOperationError(
-        'plugins/unavailable',
-        `${NAME}: no profile is composed in this process, so there are no plugins to manage`,
-        { reason: 'no profile runtime' },
-      )
-    }
-    return runtime
-  }
-
-  /** The installer over the booted profile, streaming pnpm's output as `plugins/install-log`. */
-  private installer(runtime: ProfileRuntime): PluginInstaller {
-    return new PluginInstaller({
-      profileDir: runtime.dir,
-      profileName: runtime.profileName,
-      installAnchor: runtime.installAnchor,
-      loadProfile: () => runtime.current,
-      config: this.options.config,
-      installLog: (chunk) => { this.ctx.emit('plugins/install-log', chunk) },
-      ...this.options.spawn === undefined ? {} : { spawn: this.options.spawn },
-      ...this.options.probe === undefined ? {} : { probe: this.options.probe },
-    })
-  }
-
-  /**
-   * Every package the profile knows: its template and installed bundles,
-   * and every other installed dependency.
-   * @returns one view per package, bundles first in layer order.
-   */
-  async list(): Promise<PluginPackageView[]> {
-    const runtime = this.runtime()
-    const installer = this.installer(runtime)
-    const manifest = readProfileManifest(NAME, runtime.dir)
-    const names = [...new Set([...bundlesOf(manifest), ...Object.keys(dependenciesOf(manifest))])]
-    const views: PluginPackageView[] = []
-    for (const name of names) views.push(await this.view(runtime, installer, manifest, name))
-    return views
-  }
-
-  /** Fold one package's manifest, probe, and tree facts into its view. */
-  private async view(
-    runtime: ProfileRuntime,
-    installer: PluginInstaller,
-    manifest: ProfileManifest,
-    name: string,
-  ): Promise<PluginPackageView> {
-    const installed = name in dependenciesOf(manifest)
-    const enabled = bundlesOf(manifest).includes(name)
-    const layer = runtime.layers.find(candidate => candidate.packageName === name)
-    const trust = layer?.trust ?? layerTrust(manifest, name)
-    const liveReload = runtime.patchReload === 'live'
-    let probe: PluginProbe | undefined
-    let probeFailure: string | undefined
-    if (installed) {
-      try {
-        probe = await installer.probe(name)
-      } catch (error) {
-        probeFailure = messageOf(error)
-      }
-    }
-    const packageManifest = installer.readInstalledManifest(name)
-    const stage: BundleStage = layer?.stage
-      ?? (manifest.dsh?.profile?.stages?.[name] ?? packageManifest?.dsh?.bundle?.stage ?? 'runtime')
-    const kind = probe?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'library')
-    const composed = layer !== undefined
-    const rows = composed ? this.composedRows(runtime, name) : this.probedRows(probe)
-    const status = this.status({ kind, installed, enabled, composed, liveReload, probe, probeFailure, rows })
-    const reason = probeFailure ?? probe?.reason ?? (status === 'restart-required'
-      ? 'the profile applies layer changes at its next start'
-      : status === 'failed' || status === 'partial'
-        ? rows.find(row => row.failure !== undefined)?.failure?.message
-        : undefined)
-    return {
-      name,
-      ...optional('version', packageManifest?.version),
-      ...optional('title', packageManifest?.dsh?.title),
-      ...optional('description', packageManifest?.description),
-      kind,
-      trust,
-      stage,
-      installed,
-      enabled,
-      status,
-      ...optional('reason', reason),
-      ...optional('enginesDsh', probe?.enginesDsh),
-      cordisSameCopy: probe?.cordisSameCopy ?? null,
-      rows,
-      overrides: probe?.overrides ?? [],
-      addable: (probe?.addable ?? []).map(entry => addableView(name, entry)),
-      ...optional('probedAt', probe?.checkedAt),
-      liveReload,
-    }
-  }
-
-  /** The package's status, folded from what the view already knows. */
-  private status(facts: {
-    kind: PluginPackageView['kind']
-    installed: boolean
-    enabled: boolean
-    composed: boolean
-    liveReload: boolean
-    probe: PluginProbe | undefined
-    probeFailure: string | undefined
-    rows: readonly PluginPackageRowView[]
-  }): PluginPackageStatus {
-    if (facts.kind !== 'bundle') return 'plain'
-    if (facts.installed && (facts.probeFailure !== undefined || facts.probe?.ok === false)) return 'not-enableable'
-    if (facts.enabled !== facts.composed) return 'restart-required'
-    if (!facts.enabled) return 'disabled'
-    const live = facts.rows.filter(row => row.enabled)
-    if (live.length === 0) return 'running'
-    const active = live.filter(row => row.phase === 'active').length
-    if (active === live.length) return 'running'
-    return active === 0 ? 'failed' : 'partial'
-  }
-
-  /** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */
-  private composedRows(runtime: ProfileRuntime, name: string): PluginPackageRowView[] {
-    const failures = this.ctx.get('pluginFailures')
-    const userDisabled = runtime.userDisabledRowIds()
-    const rows: PluginPackageRowView[] = []
-    const listed = new Set<string>()
-    for (const { entry, rowId } of this.ownedEntries(runtime, name)) {
-      listed.add(entry.id)
-      const failure = failures?.get(entry.id)
-      rows.push({
-        entryId: entry.id,
-        rowId,
-        moduleName: entry.options.name,
-        enabled: !entry.disabled,
-        ...entry.disabled ? { disabledBy: userDisabled.has(rowId) ? 'user' as const : 'composition' as const } : {},
-        phase: entry.fiber === undefined ? null : ROW_PHASE[entry.fiber.state],
-        ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
-      })
-    }
-    /* v8 ignore next -- the root include provides the registry on every boot; the guard answers its optional type */
-    for (const failure of failures?.list() ?? []) {
-      if (listed.has(failure.entryId)) continue
-      if (runtime.originOf(failure.rowId)?.packageName !== name) continue
-      rows.push({
-        entryId: failure.entryId,
-        rowId: failure.rowId,
-        moduleName: failure.moduleName,
-        enabled: true,
-        phase: 'failed',
-        failure: { stage: failure.stage, message: failure.message },
-      })
-    }
-    // A row the composition left out never reached the tree; the runtime's
-    // conflicts name the bundle that lost, so nothing is looked up by id.
-    for (const conflict of runtime.conflicts) {
-      if (conflict.packageName !== name) continue
-      rows.push({
-        entryId: `conflict:${conflict.layer}:${conflict.rowId}`,
-        rowId: conflict.rowId,
-        moduleName: conflict.moduleName,
-        enabled: true,
-        phase: 'failed',
-        failure: { stage: 'conflict', message: conflict.message },
-      })
-    }
-    return rows
-  }
-
-  /** The non-group tree entries a package's layer inserted. */
-  private ownedEntries(runtime: ProfileRuntime, name: string): RowFacts[] {
-    const found: RowFacts[] = []
-    for (const entry of this.ctx.loader.entries()) {
-      if (entry.options.group) continue
-      const rowId = entry.options.id
-      if (runtime.originOf(rowId)?.packageName !== name) continue
-      found.push({ entry, rowId })
-    }
-    return found
-  }
-
-  /** The rows a bundle would contribute, from its probe record, when it is not composed. */
-  private probedRows(probe: PluginProbe | undefined): PluginPackageRowView[] {
-    return (probe?.rows ?? []).map(row => ({
-      entryId: row.id ?? row.name,
-      rowId: row.id ?? row.name,
-      moduleName: row.name,
-      enabled: !row.gated,
-      ...row.gated ? { disabledBy: 'composition' as const } : {},
-      phase: null,
-    }))
-  }
-
-  /**
-   * Install a package into the profile with pnpm, probe it, and leave it
-   * disabled unless asked otherwise. The run's output is emitted as
-   * `plugins/install-log` chunks carrying the returned `jobId`.
-   * @param spec - what to install, in pnpm's own vocabulary: a registry
-   * name, a `github:` or git URL, a tarball, or an absolute path.
-   * @param options - `enable` puts every newly installed bundle into the layer list at once.
-   * @returns what the run installed and enabled.
-   * @throws {PluginOperationError} `plugins/agents-running` while a session
-   * runs, `plugins/install-failed` when pnpm exits non-zero or the run times
-   * out, `plugins/enable-failed` when enabling was asked for and the tree
-   * rejected the bundle.
-   */
-  async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult> {
-    return this.exclusive('add', spec, () => this.addNow(spec, options))
-  }
-
-  private async addNow(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult> {
-    const runtime = this.runtime()
-    this.assertNoRunningAgents('add')
-    const outcome = await this.installer(runtime).add(spec)
-    const enabled: string[] = []
-    if (options?.enable === true) {
-      for (const name of outcome.installedOnly) {
-        await this.enableNow(name)
-        enabled.push(name)
-      }
-    }
-    this.changed('install')
-    return {
-      ...outcome,
-      enabled,
-      installedOnly: outcome.installedOnly.filter(name => !enabled.includes(name)),
-    }
-  }
-
-  /**
-   * Remove a package from the profile: disable it when enabled, drop every
-   * user-layer row that names it, run `pnpm remove`, and forget its probe.
-   * @param packageName - the installed dependency to remove.
-   * @throws {PluginOperationError} `plugins/not-installed`, `plugins/agents-running`,
-   * or `plugins/install-failed` when pnpm exits non-zero.
-   */
-  async uninstall(packageName: string): Promise<void> {
-    return this.exclusive('uninstall', packageName, () => this.uninstallNow(packageName))
-  }
-
-  private async uninstallNow(packageName: string): Promise<void> {
-    const runtime = this.runtime()
-    const installer = this.installer(runtime)
-    installer.assertInstalled(packageName)
-    this.assertNoRunningAgents('uninstall')
-    const references = await this.rowReferences(runtime, packageName)
-    if (bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) {
-      await this.disableNow(packageName)
-    }
-    for (const reference of references) {
-      await this.editLayer(runtime, reference.target, (document) => { document.removeInsert(reference.rowId) })
-    }
-    await installer.remove(packageName)
-    if (references.some(reference => reference.target.kind === 'global') && runtime.patchReload === 'live') {
-      await runtime.recompose()
-    }
-    this.changed('uninstall', packageName)
-  }
-
-  /**
-   * Put an installed bundle into the layer list and, on a live profile,
-   * recompose the tree with it. A rejected recomposition restores the list
-   * and reports the tree's reason; the tree that was running keeps running.
-   * @param packageName - the installed bundle.
-   * @returns whether the list changed and whether the change is live.
-   * @throws {PluginOperationError} `plugins/not-installed`, `plugins/not-enableable`
-   * for a package that declares no bundle or whose probe refused it, or
-   * `plugins/enable-failed`.
-   */
-  async enable(packageName: string): Promise<PluginEnableResult> {
-    return this.exclusive('enable', packageName, () => this.enableNow(packageName))
-  }
-
-  private async enableNow(packageName: string): Promise<PluginEnableResult> {
-    const runtime = this.runtime()
-    const installer = this.installer(runtime)
-    installer.assertInstalled(packageName)
-    const probe = await installer.probe(packageName).catch((error: unknown) => {
-      const reason = `cannot be probed: ${messageOf(error)}`
-      throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${packageName} ${reason}`, { packageName, reason })
-    })
-    if (!probe.ok) {
-      // The probe states a reason with every refusal; the fallback keeps the type total.
-      /* v8 ignore next */
-      const reason = probe.reason ?? 'the probe refused it'
-      throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${packageName} cannot be enabled: ${reason}`, { packageName, reason })
-    }
-    let changed: boolean
-    try {
-      changed = enableBundle(NAME, runtime.dir, runtime.installAnchor, packageName)
-    } catch (error) {
-      const reason = messageOf(error)
-      throw new PluginOperationError('plugins/not-enableable', reason, { packageName, reason })
-    }
-    if (runtime.patchReload !== 'live') {
-      this.changed('enable', packageName)
-      return { changed, effect: 'restart' }
-    }
-    if (changed) {
-      try {
-        // Links for the packages the bundle carries must exist before the
-        // recomposition imports its rows.
-        await healProfilesModuleFallback({
-          installAnchor: runtime.installAnchor,
-          profile: loadProfile(NAME, runtime.profileName, runtime.installAnchor, undefined, { userLayer: false }),
-        })
-        await runtime.recompose({ reloadBundles: true })
-        // The boot audit does not run again; record what the bundle's rows
-        // came to, so the list shows a waiting or failed row with its reason.
-        await recordContainedStates(this.ctx)
-      } catch (error) {
-        const reason = messageOf(error)
-        disableBundle(NAME, runtime.dir, packageName)
-        // The tree without the bundle is the tree that was running a moment
-        // ago; recomposing back to it is the update that already succeeded.
-        /* v8 ignore next */
-        await runtime.recompose({ reloadBundles: true }).catch(() => undefined)
-        throw new PluginOperationError(
-          'plugins/enable-failed',
-          `${NAME}: enabling ${packageName} failed and the layer list was restored: ${reason}`,
-          { packageName, reason },
-          { cause: error },
-        )
-      }
-    }
-    this.changed('enable', packageName)
-    return { changed, effect: 'live' }
-  }
-
-  /**
-   * Take a bundle out of the layer list and, on a live profile, recompose
-   * the tree without it.
-   * @param packageName - the enabled bundle.
-   * @returns whether the list changed and whether the change is live.
-   * @throws {PluginOperationError} `plugins/bad-request` for a template bundle, which is not a dependency.
-   */
-  async disable(packageName: string): Promise<PluginEnableResult> {
-    return this.exclusive('disable', packageName, () => this.disableNow(packageName))
-  }
-
-  private async disableNow(packageName: string): Promise<PluginEnableResult> {
-    const runtime = this.runtime()
-    let changed: boolean
-    try {
-      changed = disableBundle(NAME, runtime.dir, packageName)
-    } catch (error) {
-      throw new PluginOperationError('plugins/bad-request', messageOf(error), {}, { cause: error })
-    }
-    if (runtime.patchReload !== 'live') {
-      this.changed('disable', packageName)
-      return { changed, effect: 'restart' }
-    }
-    if (changed) await runtime.recompose({ reloadBundles: true })
-    this.changed('disable', packageName)
-    return { changed, effect: 'live' }
-  }
-
-  /**
-   * Compose an enabled bundle again from scratch: its group leaves the tree
-   * and returns, so rows that failed at boot get another start.
-   * @param packageName - the enabled bundle.
-   * @returns the enable outcome of the second step.
-   * @throws {PluginOperationError} `plugins/bad-request` when the bundle is not enabled, or the enable failures.
-   */
-  async retry(packageName: string): Promise<PluginEnableResult> {
-    return this.exclusive('retry', packageName, () => this.retryNow(packageName))
-  }
-
-  private async retryNow(packageName: string): Promise<PluginEnableResult> {
-    const runtime = this.runtime()
-    if (!bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) {
-      throw new PluginOperationError('plugins/bad-request', `${NAME}: ${packageName} is not enabled`, {})
-    }
-    await this.disableNow(packageName)
-    const result = await this.enableNow(packageName)
-    this.changed('retry', packageName)
-    return result
-  }
-
-  /**
-   * Add a row naming one of the package's modules to a user layer: the
-   * profile's global `cordis.patch.yml`, or an agent preset's.
-   * @param packageName - the installed package.
-   * @param target - which layer.
-   * @param options - `module` selects a declared `dsh.plugins[]` name (default `.`),
-   * `id` overrides the derived row id, `config` overrides the declared default.
-   * @returns where the row landed.
-   * @throws {PluginOperationError} `plugins/not-installed`, `plugins/not-enableable`
-   * when the module is not one the probe found addable, `plugins/row-conflict`,
-   * or `plugins/unavailable` for a preset target without a roster.
-   */
-  async addRow(
-    packageName: string,
-    target: PluginRowTarget,
-    options?: { module?: string; id?: string; config?: JsonValue },
-  ): Promise<PluginRowAddition> {
-    return this.exclusive('addRow', packageName, () => this.addRowNow(packageName, target, options))
-  }
-
-  private async addRowNow(
-    packageName: string,
-    target: PluginRowTarget,
-    options?: { module?: string; id?: string; config?: JsonValue },
-  ): Promise<PluginRowAddition> {
-    const runtime = this.runtime()
-    const installer = this.installer(runtime)
-    installer.assertInstalled(packageName)
-    const probe = await installer.probe(packageName)
-    const declared = options?.module ?? '.'
-    const addable = declared === '.' && probe.kind === 'plugin'
-      ? { ok: true, config: undefined, error: undefined }
-      : probe.addable.find(entry => entry.name === declared)
-    if (addable === undefined || !addable.ok) {
-      const reason = addable?.error ?? `${declared} is not a module ${packageName} declares addable`
-      throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${reason}`, { packageName, reason })
-    }
-    const moduleName = moduleSpecifier(packageName, declared)
-    const rowId = options?.id ?? derivedRowId(packageName, declared)
-    const row: PatchRow = { id: rowId, name: moduleName, config: options?.config ?? addable.config ?? {} }
-    if (await this.rowExists(runtime, target, rowId)) {
-      throw new PluginOperationError('plugins/row-conflict', `${NAME}: a row ${JSON.stringify(rowId)} already exists`, { rowId, target })
-    }
-    const file = await this.editLayer(runtime, target, (document) => { document.appendInsert(row) })
-    this.changed('row', packageName)
-    return { target, rowId, file }
-  }
-
-  /**
-   * Remove a row a user layer inserted.
-   * @param target - which layer.
-   * @param rowId - the inserted row's id.
-   * @throws {PluginOperationError} `plugins/bad-request` when the layer inserts no such row.
-   */
-  async removeRow(target: PluginRowTarget, rowId: string): Promise<void> {
-    return this.exclusive('removeRow', rowId, () => this.removeRowNow(target, rowId))
-  }
-
-  private async removeRowNow(target: PluginRowTarget, rowId: string): Promise<void> {
-    const runtime = this.runtime()
-    // A holder rather than a `let`: the assignment happens inside the edit
-    // callback, which control-flow narrowing does not see.
-    const outcome = { removed: false }
-    await this.editLayer(runtime, target, (document) => { outcome.removed = document.removeInsert(rowId) })
-    if (!outcome.removed) {
-      throw new PluginOperationError('plugins/bad-request', `${NAME}: the layer inserts no row ${JSON.stringify(rowId)}`, {})
-    }
-    this.changed('row')
-  }
-
-  /**
-   * Switch one row off or on in a user layer. Deny-only: `true` writes
-   * `disabled: true` for the row, `false` removes that key, so a bundle's
-   * own `!!js` gate is restored rather than overridden.
-   * @param target - which layer.
-   * @param rowId - the row's id as the composition declares it.
-   * @param disabled - whether the layer should switch the row off.
-   */
-  async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void> {
-    return this.exclusive('setRowDisabled', rowId, () => this.setRowDisabledNow(target, rowId, disabled))
-  }
-
-  private async setRowDisabledNow(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void> {
-    const runtime = this.runtime()
-    await this.editLayer(runtime, target, (document) => {
-      if (disabled) document.setRowField(rowId, 'disabled', true)
-      else document.deleteRowField(rowId, 'disabled')
-    })
-    this.changed('row')
-  }
-
-  /**
-   * What disabling or removing a package would strand: services its rows
-   * provide that rows outside it inject, and user-layer rows naming its modules.
-   * @param packageName - the package.
-   * @returns the dependents.
-   */
-  async dependents(packageName: string): Promise<PluginDependents> {
-    const runtime = this.runtime()
-    const owned = this.ownedEntries(runtime, packageName)
-    const ownedIds = new Set(owned.map(({ entry }) => entry.id))
-    const store = this.ctx.reflect.store
-    const services: PluginServiceDependent[] = []
-    for (const { entry } of owned) {
-      const fiber = entry.fiber
-      if (fiber === undefined) continue
-      for (const key of Object.getOwnPropertySymbols(store)) {
-        const impl = store[key]
-        /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */
-        if (impl === undefined || !withinFiber(impl.fiber, fiber)) continue
-        const injectedBy: string[] = []
-        for (const other of this.ctx.loader.entries()) {
-          if (ownedIds.has(other.id) || other.fiber === undefined) continue
-          if (impl.name in other.fiber.inject) injectedBy.push(other.id)
-        }
-        if (injectedBy.length > 0) services.push({ service: impl.name, providedBy: entry.id, injectedBy })
-      }
-    }
-    return { services, references: await this.rowReferences(runtime, packageName) }
-  }
-
-  /** Every user-layer row naming the package or one of its subpaths. */
-  private async rowReferences(runtime: ProfileRuntime, packageName: string): Promise<PluginRowReference[]> {
-    const found: PluginRowReference[] = []
-    const names = (target: PluginRowTarget, rows: readonly PatchRow[]): void => {
-      for (const row of rows) {
-        // A group's config is a row list once the patch parser accepted the file.
-        /* v8 ignore next */
-        if (row.group === true) names(target, (row.config ?? []) as PatchRow[])
-        if (typeof row.id !== 'string') continue
-        if (row.name === packageName || row.name.startsWith(`${packageName}/`)) {
-          found.push({ target, rowId: row.id, moduleName: row.name })
-        }
-      }
-    }
-    const scan = async (target: PluginRowTarget, file: string): Promise<void> => {
-      let patches
-      try {
-        patches = await readPatchListFile(NAME, file, 'patches')
-      } catch {
-        // An unreadable layer names nothing this call can act on; the
-        // launcher and the roster report it as their own failure.
-        return
-      }
-      for (const patch of patches ?? []) if (patch.insert !== undefined) names(target, patch.insert)
-    }
-    await scan({ kind: 'global' }, runtime.patchPath)
-    const presets = this.options.presets()
-    if (presets !== undefined) {
-      for (const preset of await presets.list()) {
-        if (preset.overlayPath !== undefined) await scan({ kind: 'preset', preset: preset.id }, preset.overlayPath)
-      }
-    }
-    return found
-  }
-
-  /** Whether the target layer's composition already carries a row with `rowId`. */
-  private async rowExists(runtime: ProfileRuntime, target: PluginRowTarget, rowId: string): Promise<boolean> {
-    if (target.kind === 'global') {
-      return [...this.ctx.loader.entries()].some(entry => entry.options.id === rowId)
-        || (await readPatchListFile(NAME, runtime.patchPath, 'patches') ?? []).some(patch => patch.insert?.some(row => row.id === rowId))
-    }
-    const composition = (await this.presets().compositionInventory()).find(candidate => candidate.id === target.preset)
-    return composition?.rows.some(row => row.entryId === rowId) ?? false
-  }
-
-  /** The roster's layers, or the failure a preset target without a roster receives. */
-  private presets(): PresetLayers {
-    const presets = this.options.presets()
-    if (presets === undefined) {
-      throw new PluginOperationError('plugins/unavailable', `${NAME}: no agent-preset roster is composed`, { reason: 'no roster' })
-    }
-    return presets
-  }
-
-  /** Edit one user layer file and, for the live global layer, recompose. */
-  private async editLayer(
-    runtime: ProfileRuntime,
-    target: PluginRowTarget,
-    mutate: Parameters<typeof mutatePatchFile>[1],
-  ): Promise<string> {
-    const file = target.kind === 'global' ? runtime.patchPath : await this.presets().overlayPathFor(target.preset)
-    await mutatePatchFile(file, mutate, { binName: NAME, mode: 0o600, dirMode: 0o700 })
-    // The profile launcher's watcher reapplies the global file on its own;
-    // recomposing here makes the change visible to this call's caller before
-    // it returns, and the watcher's later pass composes the same text.
-    if (target.kind === 'global' && runtime.patchReload === 'live') await runtime.recompose()
-    return file
-  }
-
-  private changed(reason: PluginChangeReason, packageName?: string): void {
-    this.ctx.emit('plugins/changed', { reason, ...optional('packageName', packageName) })
-  }
-}
-
-/**
- * Whether `fiber` is `root` itself or sits anywhere inside its subtree: a
- * service is provided by a fiber under the row's root fiber (a `ctx.inject`
- * continuation, an `isolate` realm). An entry holds the thenable wrapper
- * `ctx.plugin()` returns, which is never identical to the fiber in a parent
- * chain, so fibers of one runtime compare by `uid`.
- */
-function withinFiber(fiber: Fiber, root: Fiber): boolean {
-  let current = fiber
-  for (;;) {
-    if (current === root || (current.uid !== null && current.uid === root.uid)) return true
-    const parent = current.parent.fiber
-    if (parent === current) return false
-    current = parent
-  }
-}
-
-/** One optional field, present only when its value is. */
-function optional<K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } {
-  return value === undefined ? {} : { [key]: value } as { [P in K]?: V }
-}
-
-/** The row `name` for one declared addable module. */
-function moduleSpecifier(packageName: string, declared: string): string {
-  if (declared === '.') return packageName
-  return `${packageName}/${declared.replace(/^\.\//, '')}`
-}
-
-/** The row id derived from a package name and module: the unscoped name, then the subpath. */
-function derivedRowId(packageName: string, declared: string): string {
-  const base = packageName.replace(/^@/, '')
-  return declared === '.' ? base : `${base}/${declared.replace(/^\.\//, '')}`
-}
-
-/** The wire view of one probed addable module. */
-function addableView(packageName: string, entry: PluginProbe['addable'][number]): PluginPackageAddableView {
-  return {
-    moduleName: moduleSpecifier(packageName, entry.name),
-    declaredName: entry.name,
-    ...optional('title', entry.title),
-    // The probe read both from JSON: a manifest field and a child's report.
-    ...optional('config', entry.config as JsonValue | undefined),
-    ok: entry.ok,
-    ...optional('error', entry.error),
-    ...optional('configSchema', entry.configSchema as JsonValue | undefined),
-  }
-}
+export type { PluginToolingConfig, SpawnLike } from './helpers.ts'
+export { PluginInstaller, type PluginInstallerOptions, type PluginInstallOutcome } from './installer.ts'
+export { PluginManager, type PluginManagerOptions, type PresetLayers } from './manager.ts'
 
 /** Group id of an isolated bundle, for callers that address its rows in the tree. */
-export { bundleGroupId }
+export { bundleGroupId } from '@deepseek-ai/dsh-app-boot'

+ 288 - 0
packages/boot/plugin-manager/src/installer.ts

@@ -0,0 +1,288 @@
+/**
+ * Installing and removing packages in one profile with pnpm, and probing
+ * them, over the profile on disk: nothing here touches a running tree, which
+ * is what lets the `dsh plugin` command install before any plugin starts.
+ * @module @deepseek-ai/dsh-plugin-manager/installer
+ */
+
+import { spawn as spawnChild } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import { readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import {
+  awaitChildClose,
+  claimLayerIds,
+  healProfilesModuleFallback,
+  PLUGIN_PROBE_DIR,
+  probePackage,
+  readProbeCache,
+  readProfileManifest,
+  reconcileInstalledBundles,
+  resolveBundleDir,
+  resolveProfileLayer,
+  writeProbeCache,
+  type PluginProbe,
+  type Profile,
+  type ProfileManifest,
+} from '@deepseek-ai/dsh-app-boot'
+import { PluginOperationError } from './errors.ts'
+import { dependenciesOf, messageOf, NAME, type PluginToolingConfig, type SpawnLike } from './helpers.ts'
+import type { PluginInstallLogChunk, PluginInstallRejection, PluginInstallResult } from './types.ts'
+
+/** What one install run changed before any newly installed bundle was enabled. */
+export type PluginInstallOutcome = Omit<PluginInstallResult, 'enabled'>
+
+/** What {@link PluginInstaller} needs: the profile on disk, the tooling bounds, and where pnpm's output goes. */
+export interface PluginInstallerOptions {
+  /** The profile directory pnpm runs in and whose manifest records installs. */
+  readonly profileDir: string
+  /** The profile name, for diagnostics. */
+  readonly profileName: string
+  /** Absolute path of the dsh app's package.json: the first resolution anchor for installed packages. */
+  readonly installAnchor: string
+  /** The profile's composed layers, read when a new bundle's row ids are checked against them. */
+  readonly loadProfile: () => Profile
+  readonly config: PluginToolingConfig
+  /** Receives every chunk of a pnpm run's output, in order; the last chunk carries the exit code. */
+  readonly installLog: (chunk: PluginInstallLogChunk) => void
+  /** Test seam: the child spawner; defaults to `node:child_process`. */
+  readonly spawn?: SpawnLike
+  /** Test seam: the package probe; defaults to app-boot's. */
+  readonly probe?: typeof probePackage
+}
+
+/** The installed package's manifest slice the view reads. */
+export type InstalledManifest = ProfileManifest & { description?: string; dsh?: ProfileManifest['dsh'] & { title?: string } }
+
+/**
+ * Installs and removes packages in one profile with pnpm, and probes them.
+ *
+ * Every run reads the profile manifest afresh and writes it through the
+ * app-boot helpers the `dsh plugin` command uses, so the CLI and the Web
+ * host never disagree on the file: `dependencies` records what is installed,
+ * `dsh.profile.bundles` what is enabled. Nothing here touches a running
+ * tree, which is what lets the CLI install before any plugin starts.
+ */
+export class PluginInstaller {
+  private readonly spawn: SpawnLike
+  private readonly probeRunner: typeof probePackage
+
+  /**
+   * @param options - the profile, the tooling bounds, and the output sink.
+   */
+  constructor(private readonly options: PluginInstallerOptions) {
+    this.spawn = options.spawn ?? spawnChild
+    this.probeRunner = options.probe ?? probePackage
+  }
+
+  /**
+   * Whether the package is a profile dependency.
+   * @param packageName - the package.
+   * @returns true when the manifest lists it.
+   */
+  isInstalled(packageName: string): boolean {
+    return packageName in dependenciesOf(readProfileManifest(NAME, this.options.profileDir))
+  }
+
+  /**
+   * Refuse a package the profile does not have.
+   * @param packageName - the package.
+   * @throws {PluginOperationError} `plugins/not-installed`.
+   */
+  assertInstalled(packageName: string): void {
+    if (this.isInstalled(packageName)) return
+    throw new PluginOperationError(
+      'plugins/not-installed',
+      `${NAME}: ${packageName} is not installed in profile ${this.options.profileName}`,
+      { packageName },
+    )
+  }
+
+  /**
+   * The installed package's manifest.
+   * @param packageName - the package.
+   * @returns the manifest, or undefined when the package cannot be resolved from the profile.
+   */
+  readInstalledManifest(packageName: string): InstalledManifest | undefined {
+    let dir: string
+    try {
+      dir = resolveBundleDir(NAME, packageName, this.options.installAnchor, this.options.profileDir)
+    } catch {
+      // A dependency pnpm removed underneath the manifest, or one that never
+      // materialized; the caller reports what the manifest still says.
+      return undefined
+    }
+    // resolveBundleDir answers only a directory holding a package.json.
+    return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as InstalledManifest
+  }
+
+  /**
+   * The package's probe record, from the profile cache or a fresh probe
+   * written to it. A record for another installed version is not reused.
+   * @param packageName - the installed package.
+   * @returns the record.
+   */
+  async probe(packageName: string): Promise<PluginProbe> {
+    const version = this.readInstalledManifest(packageName)?.version
+    const cached = readProbeCache(this.options.profileDir, packageName, version)
+    if (cached !== undefined) return cached
+    const probe = await this.probeRunner({
+      binName: NAME,
+      profileDir: this.options.profileDir,
+      installAnchor: this.options.installAnchor,
+      packageName,
+      timeoutMs: this.options.config.probeTimeoutMs,
+    })
+    writeProbeCache(this.options.profileDir, probe)
+    return probe
+  }
+
+  /**
+   * Install a package with pnpm, probe every package the run added, and
+   * remove what has no place in a profile. New bundles are left disabled.
+   * @param spec - what to install, in pnpm's own vocabulary: a registry
+   * name, a `github:` or git URL, a tarball, or an absolute path.
+   * @returns what the run installed and what it removed again.
+   * @throws {PluginOperationError} `plugins/bad-request` for an empty spec,
+   * `plugins/install-failed` when pnpm exits non-zero, cannot be spawned, or times out.
+   */
+  async add(spec: string): Promise<PluginInstallOutcome> {
+    if (spec.trim().length === 0) {
+      throw new PluginOperationError('plugins/bad-request', `${NAME}: the package spec must not be empty`, {})
+    }
+    const { profileDir, installAnchor } = this.options
+    const manifestPath = join(profileDir, 'package.json')
+    const snapshot = readFileSync(manifestPath, 'utf8')
+    const before = readProfileManifest(NAME, profileDir)
+    let jobId: string
+    try {
+      jobId = await this.runPnpm(['add', spec], spec)
+    } catch (error) {
+      // pnpm may have written the manifest before failing; the profile keeps
+      // the manifest it had, and what pnpm left under node_modules is not a
+      // dependency until a manifest names it.
+      if (readFileSync(manifestPath, 'utf8') !== snapshot) writeFileSync(manifestPath, snapshot)
+      throw error
+    }
+    const outcome = reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
+    const after = readProfileManifest(NAME, profileDir)
+    const added = Object.keys(dependenciesOf(after)).filter(name => !(name in dependenciesOf(before)))
+    await healProfilesModuleFallback({ installAnchor, profile: this.options.loadProfile() })
+    const installed: string[] = []
+    const removed: PluginInstallRejection[] = []
+    for (const name of added) {
+      const reason = await this.rejection(name)
+      if (reason === undefined) {
+        installed.push(name)
+        continue
+      }
+      await this.remove(name)
+      removed.push({ name, reason })
+    }
+    const kept = new Set(installed)
+    return {
+      installed,
+      removed,
+      installedOnly: outcome.installedOnly.filter(name => kept.has(name)),
+      plain: outcome.plain.filter(name => kept.has(name)),
+      jobId,
+    }
+  }
+
+  /**
+   * Run `pnpm remove`, reconcile the layer list, and forget the probe record.
+   * @param packageName - the dependency to remove.
+   * @throws {PluginOperationError} `plugins/install-failed` when pnpm fails.
+   */
+  async remove(packageName: string): Promise<void> {
+    const { profileDir, installAnchor } = this.options
+    const before = readProfileManifest(NAME, profileDir)
+    await this.runPnpm(['remove', packageName], packageName)
+    reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
+    rmSync(join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`), { force: true })
+  }
+
+  /**
+   * The post-install check of one package pnpm added: a package that is
+   * neither a bundle nor a plugin module has no place in a profile, and a
+   * bundle whose row id another layer already owns could only mount as a
+   * conflict record. A package the probe cannot run stays installed: the
+   * view reports it as not enableable with the probe's own reason.
+   * @returns why the package is removed again, or undefined to keep it.
+   */
+  private async rejection(packageName: string): Promise<string | undefined> {
+    let probe: PluginProbe
+    try {
+      probe = await this.probe(packageName)
+    } catch {
+      return undefined // the failure is re-derived on every list and shown there
+    }
+    // A refused probe (an import that throws, another cordis copy) keeps the
+    // package: the view shows it as not enableable with the probe's reason.
+    if (!probe.ok) return undefined
+    if (probe.kind === 'library') return 'declares neither a dsh bundle nor a plugin module'
+    if (probe.kind !== 'bundle') return undefined
+    const { profileDir, installAnchor } = this.options
+    try {
+      const layer = resolveProfileLayer(NAME, readProfileManifest(NAME, profileDir), packageName, installAnchor, profileDir)
+      const lost = claimLayerIds([...this.options.loadProfile().layers, layer]).skipped.get(packageName)
+      if (lost === undefined) return undefined
+      return lost.map(conflict => `row ${JSON.stringify(conflict.rowId)} is already declared by ${conflict.declaredBy}`).join('; ')
+    } catch (error) {
+      return messageOf(error)
+    }
+  }
+
+  /**
+   * Run one pnpm command in the profile directory, streaming its output as
+   * install-log chunks.
+   * @returns the run's job id.
+   * @throws {PluginOperationError} `plugins/install-failed` on a non-zero exit, a signal, or the timeout.
+   */
+  private async runPnpm(args: readonly string[], spec: string): Promise<string> {
+    const { config, profileDir } = this.options
+    const jobId = randomUUID()
+    const tail: string[] = []
+    let tailBytes = 0
+    const record = (stream: 'stdout' | 'stderr', text: string): void => {
+      tail.push(text)
+      tailBytes += Buffer.byteLength(text)
+      while (tailBytes > config.installLogTailBytes && tail.length > 1) {
+        tailBytes -= Buffer.byteLength(tail.shift() as string)
+      }
+      this.options.installLog({ jobId, spec, stream, text })
+    }
+    // Windows resolves pnpm through its .cmd shim, which spawn() refuses
+    // without a shell since the CVE-2024-27980 hardening. The parent
+    // environment is passed whole, as the `dsh plugin` command does: pnpm
+    // needs the user's registry, proxy, and auth settings.
+    const child = this.spawn(config.pnpmCommand, args, {
+      cwd: profileDir,
+      stdio: ['ignore', 'pipe', 'pipe'],
+      shell: process.platform === 'win32',
+      env: process.env,
+    })
+    child.stdout?.setEncoding('utf8')
+    child.stderr?.setEncoding('utf8')
+    child.stdout?.on('data', (text: string) => { record('stdout', text) })
+    child.stderr?.on('data', (text: string) => { record('stderr', text) })
+    const exitCode = await awaitChildClose(
+      child, config.installTimeoutMs,
+      () => new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(config.installTimeoutMs)}ms`),
+    ).catch((error: unknown) => {
+      const message = messageOf(error)
+      record('stderr', `${message}\n`)
+      this.options.installLog({ jobId, spec, stream: 'stderr', text: '', exitCode: null })
+      throw new PluginOperationError('plugins/install-failed', `${NAME}: ${message}`, { spec, exitCode: null, log: tail.join('') }, { cause: error })
+    })
+    this.options.installLog({ jobId, spec, stream: 'stdout', text: '', exitCode })
+    if (exitCode !== 0) {
+      throw new PluginOperationError(
+        'plugins/install-failed',
+        `${NAME}: pnpm ${args.join(' ')} exited with ${String(exitCode)} in ${profileDir}`,
+        { spec, exitCode, log: tail.join('') },
+      )
+    }
+    return jobId
+  }
+}

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

@@ -0,0 +1,574 @@
+/**
+ * Plugin management over the booted profile: every operation the `plugins`
+ * Remote exposes, one mutation at a time, with the tree read through the
+ * Cordis context and the profile runtime, the preset roster, and the
+ * running-agent count read through per-call readers.
+ * @module @deepseek-ai/dsh-plugin-manager/manager
+ */
+
+import type { Context, Fiber } from '@deepseek-ai/cordis'
+import {
+  disableBundle,
+  enableBundle,
+  healProfilesModuleFallback,
+  loadProfile,
+  readProfileManifest,
+  recordContainedStates,
+  type probePackage,
+  type ProfileRuntime,
+} from '@deepseek-ai/dsh-app-boot'
+import { mutatePatchFile, readPatchListFile, type PatchRow } from '@deepseek-ai/dsh-app-boot/patch-file'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
+import { PluginOperationError } from './errors.ts'
+import { bundlesOf, dependenciesOf, messageOf, NAME, optional, type PluginToolingConfig, type SpawnLike } from './helpers.ts'
+import { PluginInstaller } from './installer.ts'
+import { derivedRowId, moduleSpecifier } from './modules.ts'
+import type {
+  PluginChangeReason,
+  PluginDependents,
+  PluginEnableResult,
+  PluginInstallResult,
+  PluginPackageView,
+  PluginRowAddition,
+  PluginRowReference,
+  PluginRowTarget,
+  PluginServiceDependent,
+} from './types.ts'
+import { ownedEntries, packageView } from './view.ts'
+
+/**
+ * The agent-preset roster as row operations see it: where each preset's user
+ * patch layer lives and which row ids its composition already carries. The
+ * roster service satisfies this structurally; a host passes it in so this
+ * package never imports the roster.
+ */
+export interface PresetLayers {
+  /**
+   * The user patch layer file of one preset, created on the first write.
+   * @param presetId - the preset id.
+   * @returns the absolute path.
+   */
+  overlayPathFor(presetId: string): Promise<string>
+  /**
+   * Every preset with the path of its user patch layer when it has one.
+   * @returns the presets.
+   */
+  list(): Promise<readonly { readonly id: string; readonly overlayPath?: string }[]>
+  /**
+   * Every preset's composition as rows, under the ids the composition declares; an anonymous row has none.
+   * @returns one composition per preset.
+   */
+  compositionInventory(): Promise<readonly { readonly id: string; readonly rows: readonly { readonly entryId: string | null }[] }[]>
+}
+
+/** What {@link PluginManager} needs beyond the Cordis context it reads the tree through. */
+export interface PluginManagerOptions {
+  readonly config: PluginToolingConfig
+  /** The booted profile, read per call; `undefined` means no profile is composed in this process. */
+  readonly runtime: () => ProfileRuntime | undefined
+  /** The preset roster's layers, read per call; `undefined` when no roster is composed. */
+  readonly presets: () => PresetLayers | undefined
+  /** How many agents are running; `add` and `uninstall` refuse while any is. */
+  readonly runningAgents: () => number
+  /** Test seam: the child spawner; defaults to `node:child_process`. */
+  readonly spawn?: SpawnLike
+  /** Test seam: the package probe; defaults to app-boot's. */
+  readonly probe?: typeof probePackage
+}
+
+/**
+ * Plugin management over the booted profile: every operation the `plugins`
+ * Remote exposes, with the tree read through the Cordis context and the
+ * profile runtime, the preset roster, and the running-agent count read
+ * through {@link PluginManagerOptions} per call.
+ *
+ * Every write touches the profile manifest, the user layers, or
+ * `node_modules`, and two at once would race on those files, so the manager
+ * runs one mutation at a time and refuses a second rather than queueing it.
+ * Every change is followed by a `plugins/changed` event on the context.
+ */
+export class PluginManager {
+  /** The mutation in flight, while one is; a second caller is refused rather than queued. */
+  private active: { operation: string; subject: string } | undefined
+
+  /**
+   * @param ctx - the context whose Loader tree and services the operations read.
+   * @param options - the profile runtime, the roster, the agent count, and the tooling bounds.
+   */
+  constructor(private readonly ctx: Context, private readonly options: PluginManagerOptions) {}
+
+  /**
+   * Run one mutation with the manager to itself.
+   * @throws {PluginOperationError} `plugins/busy` naming the operation in flight.
+   */
+  private async exclusive<T>(operation: string, subject: string, run: () => Promise<T>): Promise<T> {
+    if (this.active !== undefined) {
+      throw new PluginOperationError(
+        'plugins/busy',
+        `${NAME}: ${operation} ${subject} refused while ${this.active.operation} ${this.active.subject} is still running`,
+        { operation, subject, active: this.active },
+      )
+    }
+    this.active = { operation, subject }
+    try {
+      return await run()
+    } finally {
+      this.active = undefined
+    }
+  }
+
+  /**
+   * Refuse a change to `node_modules` while a session is running: pnpm
+   * rewrites the directory the running agents import from.
+   * @throws {PluginOperationError} `plugins/agents-running` with the count.
+   */
+  private assertNoRunningAgents(operation: string): void {
+    const running = this.options.runningAgents()
+    if (running === 0) return
+    throw new PluginOperationError(
+      'plugins/agents-running',
+      `${NAME}: ${operation} waits for ${String(running)} running session(s) to go idle`,
+      { operation, running },
+    )
+  }
+
+  /** The profile runtime, or the failure a caller without one receives. */
+  private runtime(): ProfileRuntime {
+    const runtime = this.options.runtime()
+    if (runtime === undefined) {
+      throw new PluginOperationError(
+        'plugins/unavailable',
+        `${NAME}: no profile is composed in this process, so there are no plugins to manage`,
+        { reason: 'no profile runtime' },
+      )
+    }
+    return runtime
+  }
+
+  /** The installer over the booted profile, streaming pnpm's output as `plugins/install-log`. */
+  private installer(runtime: ProfileRuntime): PluginInstaller {
+    return new PluginInstaller({
+      profileDir: runtime.dir,
+      profileName: runtime.profileName,
+      installAnchor: runtime.installAnchor,
+      loadProfile: () => runtime.current,
+      config: this.options.config,
+      installLog: (chunk) => { this.ctx.emit('plugins/install-log', chunk) },
+      ...this.options.spawn === undefined ? {} : { spawn: this.options.spawn },
+      ...this.options.probe === undefined ? {} : { probe: this.options.probe },
+    })
+  }
+
+  /**
+   * Every package the profile knows: its template and installed bundles,
+   * and every other installed dependency.
+   * @returns one view per package, bundles first in layer order.
+   */
+  async list(): Promise<PluginPackageView[]> {
+    const runtime = this.runtime()
+    const installer = this.installer(runtime)
+    const manifest = readProfileManifest(NAME, runtime.dir)
+    const names = [...new Set([...bundlesOf(manifest), ...Object.keys(dependenciesOf(manifest))])]
+    const views: PluginPackageView[] = []
+    for (const name of names) views.push(await packageView(this.ctx, runtime, installer, manifest, name))
+    return views
+  }
+
+
+  /**
+   * Install a package into the profile with pnpm, probe it, and leave it
+   * disabled unless asked otherwise. The run's output is emitted as
+   * `plugins/install-log` chunks carrying the returned `jobId`.
+   * @param spec - what to install, in pnpm's own vocabulary: a registry
+   * name, a `github:` or git URL, a tarball, or an absolute path.
+   * @param options - `enable` puts every newly installed bundle into the layer list at once.
+   * @returns what the run installed and enabled.
+   * @throws {PluginOperationError} `plugins/agents-running` while a session
+   * runs, `plugins/install-failed` when pnpm exits non-zero or the run times
+   * out, `plugins/enable-failed` when enabling was asked for and the tree
+   * rejected the bundle.
+   */
+  async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult> {
+    return this.exclusive('add', spec, () => this.addNow(spec, options))
+  }
+
+  private async addNow(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult> {
+    const runtime = this.runtime()
+    this.assertNoRunningAgents('add')
+    const outcome = await this.installer(runtime).add(spec)
+    const enabled: string[] = []
+    if (options?.enable === true) {
+      for (const name of outcome.installedOnly) {
+        await this.enableNow(name)
+        enabled.push(name)
+      }
+    }
+    this.changed('install')
+    return {
+      ...outcome,
+      enabled,
+      installedOnly: outcome.installedOnly.filter(name => !enabled.includes(name)),
+    }
+  }
+
+  /**
+   * Remove a package from the profile: disable it when enabled, drop every
+   * user-layer row that names it, run `pnpm remove`, and forget its probe.
+   * @param packageName - the installed dependency to remove.
+   * @throws {PluginOperationError} `plugins/not-installed`, `plugins/agents-running`,
+   * or `plugins/install-failed` when pnpm exits non-zero.
+   */
+  async uninstall(packageName: string): Promise<void> {
+    return this.exclusive('uninstall', packageName, () => this.uninstallNow(packageName))
+  }
+
+  private async uninstallNow(packageName: string): Promise<void> {
+    const runtime = this.runtime()
+    const installer = this.installer(runtime)
+    installer.assertInstalled(packageName)
+    this.assertNoRunningAgents('uninstall')
+    const references = await this.rowReferences(runtime, packageName)
+    if (bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) {
+      await this.disableNow(packageName)
+    }
+    for (const reference of references) {
+      await this.editLayer(runtime, reference.target, (document) => { document.removeInsert(reference.rowId) })
+    }
+    await installer.remove(packageName)
+    if (references.some(reference => reference.target.kind === 'global') && runtime.patchReload === 'live') {
+      await runtime.recompose()
+    }
+    this.changed('uninstall', packageName)
+  }
+
+  /**
+   * Put an installed bundle into the layer list and, on a live profile,
+   * recompose the tree with it. A rejected recomposition restores the list
+   * and reports the tree's reason; the tree that was running keeps running.
+   * @param packageName - the installed bundle.
+   * @returns whether the list changed and whether the change is live.
+   * @throws {PluginOperationError} `plugins/not-installed`, `plugins/not-enableable`
+   * for a package that declares no bundle or whose probe refused it, or
+   * `plugins/enable-failed`.
+   */
+  async enable(packageName: string): Promise<PluginEnableResult> {
+    return this.exclusive('enable', packageName, () => this.enableNow(packageName))
+  }
+
+  private async enableNow(packageName: string): Promise<PluginEnableResult> {
+    const runtime = this.runtime()
+    const installer = this.installer(runtime)
+    installer.assertInstalled(packageName)
+    const probe = await installer.probe(packageName).catch((error: unknown) => {
+      const reason = `cannot be probed: ${messageOf(error)}`
+      throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${packageName} ${reason}`, { packageName, reason })
+    })
+    if (!probe.ok) {
+      // The probe states a reason with every refusal; the fallback keeps the type total.
+      /* v8 ignore next */
+      const reason = probe.reason ?? 'the probe refused it'
+      throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${packageName} cannot be enabled: ${reason}`, { packageName, reason })
+    }
+    let changed: boolean
+    try {
+      changed = enableBundle(NAME, runtime.dir, runtime.installAnchor, packageName)
+    } catch (error) {
+      const reason = messageOf(error)
+      throw new PluginOperationError('plugins/not-enableable', reason, { packageName, reason })
+    }
+    if (runtime.patchReload !== 'live') {
+      this.changed('enable', packageName)
+      return { changed, effect: 'restart' }
+    }
+    if (changed) {
+      try {
+        // Links for the packages the bundle carries must exist before the
+        // recomposition imports its rows.
+        await healProfilesModuleFallback({
+          installAnchor: runtime.installAnchor,
+          profile: loadProfile(NAME, runtime.profileName, runtime.installAnchor, undefined, { userLayer: false }),
+        })
+        await runtime.recompose({ reloadBundles: true })
+        // The boot audit does not run again; record what the bundle's rows
+        // came to, so the list shows a waiting or failed row with its reason.
+        await recordContainedStates(this.ctx)
+      } catch (error) {
+        const reason = messageOf(error)
+        disableBundle(NAME, runtime.dir, packageName)
+        // The tree without the bundle is the tree that was running a moment
+        // ago; recomposing back to it is the update that already succeeded.
+        /* v8 ignore next */
+        await runtime.recompose({ reloadBundles: true }).catch(() => undefined)
+        throw new PluginOperationError(
+          'plugins/enable-failed',
+          `${NAME}: enabling ${packageName} failed and the layer list was restored: ${reason}`,
+          { packageName, reason },
+          { cause: error },
+        )
+      }
+    }
+    this.changed('enable', packageName)
+    return { changed, effect: 'live' }
+  }
+
+  /**
+   * Take a bundle out of the layer list and, on a live profile, recompose
+   * the tree without it.
+   * @param packageName - the enabled bundle.
+   * @returns whether the list changed and whether the change is live.
+   * @throws {PluginOperationError} `plugins/bad-request` for a template bundle, which is not a dependency.
+   */
+  async disable(packageName: string): Promise<PluginEnableResult> {
+    return this.exclusive('disable', packageName, () => this.disableNow(packageName))
+  }
+
+  private async disableNow(packageName: string): Promise<PluginEnableResult> {
+    const runtime = this.runtime()
+    let changed: boolean
+    try {
+      changed = disableBundle(NAME, runtime.dir, packageName)
+    } catch (error) {
+      throw new PluginOperationError('plugins/bad-request', messageOf(error), {}, { cause: error })
+    }
+    if (runtime.patchReload !== 'live') {
+      this.changed('disable', packageName)
+      return { changed, effect: 'restart' }
+    }
+    if (changed) await runtime.recompose({ reloadBundles: true })
+    this.changed('disable', packageName)
+    return { changed, effect: 'live' }
+  }
+
+  /**
+   * Compose an enabled bundle again from scratch: its group leaves the tree
+   * and returns, so rows that failed at boot get another start.
+   * @param packageName - the enabled bundle.
+   * @returns the enable outcome of the second step.
+   * @throws {PluginOperationError} `plugins/bad-request` when the bundle is not enabled, or the enable failures.
+   */
+  async retry(packageName: string): Promise<PluginEnableResult> {
+    return this.exclusive('retry', packageName, () => this.retryNow(packageName))
+  }
+
+  private async retryNow(packageName: string): Promise<PluginEnableResult> {
+    const runtime = this.runtime()
+    if (!bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) {
+      throw new PluginOperationError('plugins/bad-request', `${NAME}: ${packageName} is not enabled`, {})
+    }
+    await this.disableNow(packageName)
+    const result = await this.enableNow(packageName)
+    this.changed('retry', packageName)
+    return result
+  }
+
+  /**
+   * Add a row naming one of the package's modules to a user layer: the
+   * profile's global `cordis.patch.yml`, or an agent preset's.
+   * @param packageName - the installed package.
+   * @param target - which layer.
+   * @param options - `module` selects a declared `dsh.plugins[]` name (default `.`),
+   * `id` overrides the derived row id, `config` overrides the declared default.
+   * @returns where the row landed.
+   * @throws {PluginOperationError} `plugins/not-installed`, `plugins/not-enableable`
+   * when the module is not one the probe found addable, `plugins/row-conflict`,
+   * or `plugins/unavailable` for a preset target without a roster.
+   */
+  async addRow(
+    packageName: string,
+    target: PluginRowTarget,
+    options?: { module?: string; id?: string; config?: JsonValue },
+  ): Promise<PluginRowAddition> {
+    return this.exclusive('addRow', packageName, () => this.addRowNow(packageName, target, options))
+  }
+
+  private async addRowNow(
+    packageName: string,
+    target: PluginRowTarget,
+    options?: { module?: string; id?: string; config?: JsonValue },
+  ): Promise<PluginRowAddition> {
+    const runtime = this.runtime()
+    const installer = this.installer(runtime)
+    installer.assertInstalled(packageName)
+    const probe = await installer.probe(packageName)
+    const declared = options?.module ?? '.'
+    const addable = declared === '.' && probe.kind === 'plugin'
+      ? { ok: true, config: undefined, error: undefined }
+      : probe.addable.find(entry => entry.name === declared)
+    if (addable === undefined || !addable.ok) {
+      const reason = addable?.error ?? `${declared} is not a module ${packageName} declares addable`
+      throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${reason}`, { packageName, reason })
+    }
+    const moduleName = moduleSpecifier(packageName, declared)
+    const rowId = options?.id ?? derivedRowId(packageName, declared)
+    const row: PatchRow = { id: rowId, name: moduleName, config: options?.config ?? addable.config ?? {} }
+    if (await this.rowExists(runtime, target, rowId)) {
+      throw new PluginOperationError('plugins/row-conflict', `${NAME}: a row ${JSON.stringify(rowId)} already exists`, { rowId, target })
+    }
+    const file = await this.editLayer(runtime, target, (document) => { document.appendInsert(row) })
+    this.changed('row', packageName)
+    return { target, rowId, file }
+  }
+
+  /**
+   * Remove a row a user layer inserted.
+   * @param target - which layer.
+   * @param rowId - the inserted row's id.
+   * @throws {PluginOperationError} `plugins/bad-request` when the layer inserts no such row.
+   */
+  async removeRow(target: PluginRowTarget, rowId: string): Promise<void> {
+    return this.exclusive('removeRow', rowId, () => this.removeRowNow(target, rowId))
+  }
+
+  private async removeRowNow(target: PluginRowTarget, rowId: string): Promise<void> {
+    const runtime = this.runtime()
+    // A holder rather than a `let`: the assignment happens inside the edit
+    // callback, which control-flow narrowing does not see.
+    const outcome = { removed: false }
+    await this.editLayer(runtime, target, (document) => { outcome.removed = document.removeInsert(rowId) })
+    if (!outcome.removed) {
+      throw new PluginOperationError('plugins/bad-request', `${NAME}: the layer inserts no row ${JSON.stringify(rowId)}`, {})
+    }
+    this.changed('row')
+  }
+
+  /**
+   * Switch one row off or on in a user layer. Deny-only: `true` writes
+   * `disabled: true` for the row, `false` removes that key, so a bundle's
+   * own `!!js` gate is restored rather than overridden.
+   * @param target - which layer.
+   * @param rowId - the row's id as the composition declares it.
+   * @param disabled - whether the layer should switch the row off.
+   */
+  async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void> {
+    return this.exclusive('setRowDisabled', rowId, () => this.setRowDisabledNow(target, rowId, disabled))
+  }
+
+  private async setRowDisabledNow(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void> {
+    const runtime = this.runtime()
+    await this.editLayer(runtime, target, (document) => {
+      if (disabled) document.setRowField(rowId, 'disabled', true)
+      else document.deleteRowField(rowId, 'disabled')
+    })
+    this.changed('row')
+  }
+
+  /**
+   * What disabling or removing a package would strand: services its rows
+   * provide that rows outside it inject, and user-layer rows naming its modules.
+   * @param packageName - the package.
+   * @returns the dependents.
+   */
+  async dependents(packageName: string): Promise<PluginDependents> {
+    const runtime = this.runtime()
+    const owned = ownedEntries(this.ctx, runtime, packageName)
+    const ownedIds = new Set(owned.map(({ entry }) => entry.id))
+    const store = this.ctx.reflect.store
+    const services: PluginServiceDependent[] = []
+    for (const { entry } of owned) {
+      const fiber = entry.fiber
+      if (fiber === undefined) continue
+      for (const key of Object.getOwnPropertySymbols(store)) {
+        const impl = store[key]
+        /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */
+        if (impl === undefined || !withinFiber(impl.fiber, fiber)) continue
+        const injectedBy: string[] = []
+        for (const other of this.ctx.loader.entries()) {
+          if (ownedIds.has(other.id) || other.fiber === undefined) continue
+          if (impl.name in other.fiber.inject) injectedBy.push(other.id)
+        }
+        if (injectedBy.length > 0) services.push({ service: impl.name, providedBy: entry.id, injectedBy })
+      }
+    }
+    return { services, references: await this.rowReferences(runtime, packageName) }
+  }
+
+  /** Every user-layer row naming the package or one of its subpaths. */
+  private async rowReferences(runtime: ProfileRuntime, packageName: string): Promise<PluginRowReference[]> {
+    const found: PluginRowReference[] = []
+    const names = (target: PluginRowTarget, rows: readonly PatchRow[]): void => {
+      for (const row of rows) {
+        // A group's config is a row list once the patch parser accepted the file.
+        /* v8 ignore next */
+        if (row.group === true) names(target, (row.config ?? []) as PatchRow[])
+        if (typeof row.id !== 'string') continue
+        if (row.name === packageName || row.name.startsWith(`${packageName}/`)) {
+          found.push({ target, rowId: row.id, moduleName: row.name })
+        }
+      }
+    }
+    const scan = async (target: PluginRowTarget, file: string): Promise<void> => {
+      let patches
+      try {
+        patches = await readPatchListFile(NAME, file, 'patches')
+      } catch {
+        // An unreadable layer names nothing this call can act on; the
+        // launcher and the roster report it as their own failure.
+        return
+      }
+      for (const patch of patches ?? []) if (patch.insert !== undefined) names(target, patch.insert)
+    }
+    await scan({ kind: 'global' }, runtime.patchPath)
+    const presets = this.options.presets()
+    if (presets !== undefined) {
+      for (const preset of await presets.list()) {
+        if (preset.overlayPath !== undefined) await scan({ kind: 'preset', preset: preset.id }, preset.overlayPath)
+      }
+    }
+    return found
+  }
+
+  /** Whether the target layer's composition already carries a row with `rowId`. */
+  private async rowExists(runtime: ProfileRuntime, target: PluginRowTarget, rowId: string): Promise<boolean> {
+    if (target.kind === 'global') {
+      return [...this.ctx.loader.entries()].some(entry => entry.options.id === rowId)
+        || (await readPatchListFile(NAME, runtime.patchPath, 'patches') ?? []).some(patch => patch.insert?.some(row => row.id === rowId))
+    }
+    const composition = (await this.presets().compositionInventory()).find(candidate => candidate.id === target.preset)
+    return composition?.rows.some(row => row.entryId === rowId) ?? false
+  }
+
+  /** The roster's layers, or the failure a preset target without a roster receives. */
+  private presets(): PresetLayers {
+    const presets = this.options.presets()
+    if (presets === undefined) {
+      throw new PluginOperationError('plugins/unavailable', `${NAME}: no agent-preset roster is composed`, { reason: 'no roster' })
+    }
+    return presets
+  }
+
+  /** Edit one user layer file and, for the live global layer, recompose. */
+  private async editLayer(
+    runtime: ProfileRuntime,
+    target: PluginRowTarget,
+    mutate: Parameters<typeof mutatePatchFile>[1],
+  ): Promise<string> {
+    const file = target.kind === 'global' ? runtime.patchPath : await this.presets().overlayPathFor(target.preset)
+    await mutatePatchFile(file, mutate, { binName: NAME, mode: 0o600, dirMode: 0o700 })
+    // The profile launcher's watcher reapplies the global file on its own;
+    // recomposing here makes the change visible to this call's caller before
+    // it returns, and the watcher's later pass composes the same text.
+    if (target.kind === 'global' && runtime.patchReload === 'live') await runtime.recompose()
+    return file
+  }
+
+  private changed(reason: PluginChangeReason, packageName?: string): void {
+    this.ctx.emit('plugins/changed', { reason, ...optional('packageName', packageName) })
+  }
+}
+
+/**
+ * Whether `fiber` is `root` itself or sits anywhere inside its subtree: a
+ * service is provided by a fiber under the row's root fiber (a `ctx.inject`
+ * continuation, an `isolate` realm). An entry holds the thenable wrapper
+ * `ctx.plugin()` returns, which is never identical to the fiber in a parent
+ * chain, so fibers of one runtime compare by `uid`.
+ */
+function withinFiber(fiber: Fiber, root: Fiber): boolean {
+  let current = fiber
+  for (;;) {
+    if (current === root || (current.uid !== null && current.uid === root.uid)) return true
+    const parent = current.parent.fiber
+    if (parent === current) return false
+    current = parent
+  }
+}

+ 51 - 0
packages/boot/plugin-manager/src/modules.ts

@@ -0,0 +1,51 @@
+/**
+ * The modules a package offers a composition: how a declared `dsh.plugins`
+ * entry is named as a row and derived into a row id, and its wire view.
+ * @module @deepseek-ai/dsh-plugin-manager/modules
+ */
+
+import type { PluginProbe } from '@deepseek-ai/dsh-app-boot'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
+import { optional } from './helpers.ts'
+import type { PluginPackageAddableView } from './types.ts'
+
+/**
+ * The row `name` for one declared addable module.
+ * @param packageName - the package.
+ * @param declared - the `dsh.plugins[].name`, `.` for the main export.
+ * @returns the bare package for `.`, else `<package>/<subpath>`.
+ */
+export function moduleSpecifier(packageName: string, declared: string): string {
+  if (declared === '.') return packageName
+  return `${packageName}/${declared.replace(/^\.\//, '')}`
+}
+
+/**
+ * The row id derived from a package name and module: the unscoped name, then the subpath.
+ * @param packageName - the package.
+ * @param declared - the `dsh.plugins[].name`, `.` for the main export.
+ * @returns the row id.
+ */
+export function derivedRowId(packageName: string, declared: string): string {
+  const base = packageName.replace(/^@/, '')
+  return declared === '.' ? base : `${base}/${declared.replace(/^\.\//, '')}`
+}
+
+/**
+ * The wire view of one probed addable module.
+ * @param packageName - the package.
+ * @param entry - the probe's record of the declared module.
+ * @returns the view.
+ */
+export function addableView(packageName: string, entry: PluginProbe['addable'][number]): PluginPackageAddableView {
+  return {
+    moduleName: moduleSpecifier(packageName, entry.name),
+    declaredName: entry.name,
+    ...optional('title', entry.title),
+    // The probe read both from JSON: a manifest field and a child's report.
+    ...optional('config', entry.config as JsonValue | undefined),
+    ok: entry.ok,
+    ...optional('error', entry.error),
+    ...optional('configSchema', entry.configSchema as JsonValue | undefined),
+  }
+}

+ 205 - 0
packages/boot/plugin-manager/src/view.ts

@@ -0,0 +1,205 @@
+/**
+ * The view fold: one package's manifest facts, probe record, and live-tree
+ * rows folded into the `PluginPackageView` the manager lists, plus the row
+ * ownership walk the dependents query shares.
+ * @module @deepseek-ai/dsh-plugin-manager/view
+ */
+
+import type { Context, FiberState } from '@deepseek-ai/cordis'
+import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
+import { layerTrust, type PluginProbe, type ProfileManifest, type ProfileRuntime } from '@deepseek-ai/dsh-app-boot'
+import type { BundleStage } from '@deepseek-ai/dsh-package-manifest'
+import { bundlesOf, dependenciesOf, messageOf, optional } from './helpers.ts'
+import type { PluginInstaller } from './installer.ts'
+import { addableView } from './modules.ts'
+import type { PluginPackageRowView, PluginPackageStatus, PluginPackageView, PluginRowPhase } from './types.ts'
+
+/** Runtime mirror: FiberState is a cross-package const enum. */
+const FIBER_STATE = {
+  PENDING: 0 as FiberState.PENDING,
+  LOADING: 1 as FiberState.LOADING,
+  ACTIVE: 2 as FiberState.ACTIVE,
+  FAILED: 3 as FiberState.FAILED,
+  DISPOSED: 4 as FiberState.DISPOSED,
+  UNLOADING: 5 as FiberState.UNLOADING,
+} as const
+
+/** Complete projection of Cordis Fiber states onto the row phase vocabulary. */
+const ROW_PHASE = {
+  [FIBER_STATE.PENDING]: 'pending',
+  [FIBER_STATE.LOADING]: 'loading',
+  [FIBER_STATE.ACTIVE]: 'active',
+  [FIBER_STATE.FAILED]: 'failed',
+  [FIBER_STATE.DISPOSED]: null,
+  [FIBER_STATE.UNLOADING]: 'unloading',
+} as const satisfies Record<FiberState, PluginRowPhase>
+
+/** Fields of one row's provenance a package view needs. */
+export interface RowFacts {
+  readonly entry: Entry
+  readonly rowId: string
+}
+
+/**
+ * Fold one package's manifest, probe, and tree facts into its view.
+ * @param ctx - the context whose Loader tree and failure registry the rows come from.
+ * @param runtime - the booted profile.
+ * @param installer - the installer over the same profile, for the probe record and the installed manifest.
+ * @param manifest - the profile manifest, read once per list.
+ * @param name - the package.
+ * @returns the view.
+ */
+export async function packageView(
+  ctx: Context,
+  runtime: ProfileRuntime,
+  installer: PluginInstaller,
+  manifest: ProfileManifest,
+  name: string,
+): Promise<PluginPackageView> {
+  const installed = name in dependenciesOf(manifest)
+  const enabled = bundlesOf(manifest).includes(name)
+  const layer = runtime.layers.find(candidate => candidate.packageName === name)
+  const trust = layer?.trust ?? layerTrust(manifest, name)
+  const liveReload = runtime.patchReload === 'live'
+  let probe: PluginProbe | undefined
+  let probeFailure: string | undefined
+  if (installed) {
+    try {
+      probe = await installer.probe(name)
+    } catch (error) {
+      probeFailure = messageOf(error)
+    }
+  }
+  const packageManifest = installer.readInstalledManifest(name)
+  const stage: BundleStage = layer?.stage
+    ?? (manifest.dsh?.profile?.stages?.[name] ?? packageManifest?.dsh?.bundle?.stage ?? 'runtime')
+  const kind = probe?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'library')
+  const composed = layer !== undefined
+  const rows = composed ? composedRows(ctx, runtime, name) : probedRows(probe)
+  const status = packageStatus({ kind, installed, enabled, composed, liveReload, probe, probeFailure, rows })
+  const reason = probeFailure ?? probe?.reason ?? (status === 'restart-required'
+    ? 'the profile applies layer changes at its next start'
+    : status === 'failed' || status === 'partial'
+      ? rows.find(row => row.failure !== undefined)?.failure?.message
+      : undefined)
+  return {
+    name,
+    ...optional('version', packageManifest?.version),
+    ...optional('title', packageManifest?.dsh?.title),
+    ...optional('description', packageManifest?.description),
+    kind,
+    trust,
+    stage,
+    installed,
+    enabled,
+    status,
+    ...optional('reason', reason),
+    ...optional('enginesDsh', probe?.enginesDsh),
+    cordisSameCopy: probe?.cordisSameCopy ?? null,
+    rows,
+    overrides: probe?.overrides ?? [],
+    addable: (probe?.addable ?? []).map(entry => addableView(name, entry)),
+    ...optional('probedAt', probe?.checkedAt),
+    liveReload,
+  }
+}
+
+/** The package's status, folded from what the view already knows. */
+function packageStatus(facts: {
+  kind: PluginPackageView['kind']
+  installed: boolean
+  enabled: boolean
+  composed: boolean
+  liveReload: boolean
+  probe: PluginProbe | undefined
+  probeFailure: string | undefined
+  rows: readonly PluginPackageRowView[]
+}): PluginPackageStatus {
+  if (facts.kind !== 'bundle') return 'plain'
+  if (facts.installed && (facts.probeFailure !== undefined || facts.probe?.ok === false)) return 'not-enableable'
+  if (facts.enabled !== facts.composed) return 'restart-required'
+  if (!facts.enabled) return 'disabled'
+  const live = facts.rows.filter(row => row.enabled)
+  if (live.length === 0) return 'running'
+  const active = live.filter(row => row.phase === 'active').length
+  if (active === live.length) return 'running'
+  return active === 0 ? 'failed' : 'partial'
+}
+
+/** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */
+function composedRows(ctx: Context, runtime: ProfileRuntime, name: string): PluginPackageRowView[] {
+  const failures = ctx.get('pluginFailures')
+  const userDisabled = runtime.userDisabledRowIds()
+  const rows: PluginPackageRowView[] = []
+  const listed = new Set<string>()
+  for (const { entry, rowId } of ownedEntries(ctx, runtime, name)) {
+    listed.add(entry.id)
+    const failure = failures?.get(entry.id)
+    rows.push({
+      entryId: entry.id,
+      rowId,
+      moduleName: entry.options.name,
+      enabled: !entry.disabled,
+      ...entry.disabled ? { disabledBy: userDisabled.has(rowId) ? 'user' as const : 'composition' as const } : {},
+      phase: entry.fiber === undefined ? null : ROW_PHASE[entry.fiber.state],
+      ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
+    })
+  }
+  /* v8 ignore next -- the root include provides the registry on every boot; the guard answers its optional type */
+  for (const failure of failures?.list() ?? []) {
+    if (listed.has(failure.entryId)) continue
+    if (runtime.originOf(failure.rowId)?.packageName !== name) continue
+    rows.push({
+      entryId: failure.entryId,
+      rowId: failure.rowId,
+      moduleName: failure.moduleName,
+      enabled: true,
+      phase: 'failed',
+      failure: { stage: failure.stage, message: failure.message },
+    })
+  }
+  // A row the composition left out never reached the tree; the runtime's
+  // conflicts name the bundle that lost, so nothing is looked up by id.
+  for (const conflict of runtime.conflicts) {
+    if (conflict.packageName !== name) continue
+    rows.push({
+      entryId: `conflict:${conflict.layer}:${conflict.rowId}`,
+      rowId: conflict.rowId,
+      moduleName: conflict.moduleName,
+      enabled: true,
+      phase: 'failed',
+      failure: { stage: 'conflict', message: conflict.message },
+    })
+  }
+  return rows
+}
+
+/**
+ * The non-group tree entries a package's layer inserted.
+ * @param ctx - the context whose Loader tree to walk.
+ * @param runtime - the booted profile, which says which layer owns each row id.
+ * @param name - the package.
+ * @returns the entries with the row ids their patches declare.
+ */
+export function ownedEntries(ctx: Context, runtime: ProfileRuntime, name: string): RowFacts[] {
+  const found: RowFacts[] = []
+  for (const entry of ctx.loader.entries()) {
+    if (entry.options.group) continue
+    const rowId = entry.options.id
+    if (runtime.originOf(rowId)?.packageName !== name) continue
+    found.push({ entry, rowId })
+  }
+  return found
+}
+
+/** The rows a bundle would contribute, from its probe record, when it is not composed. */
+function probedRows(probe: PluginProbe | undefined): PluginPackageRowView[] {
+  return (probe?.rows ?? []).map(row => ({
+    entryId: row.id ?? row.name,
+    rowId: row.id ?? row.name,
+    moduleName: row.name,
+    enabled: !row.gated,
+    ...row.gated ? { disabledBy: 'composition' as const } : {},
+    phase: null,
+  }))
+}