refactor(plugin-manager): move plugin management out of the Web host

What plugin management does now lives in `dsh-plugin-manager`, a boot-group
package beside `app-boot`: `PluginInstaller` runs pnpm, probes, and judges new
packages over the profile on disk, and `PluginManager` adds every operation
over the booted tree, each refusal a `PluginOperationError` with a `plugins/*`
code and no Remote protocol attached. The manager reads the profile runtime,
the preset roster, and the running-agent count through per-call readers, and
the roster only as `PresetLayers`, so the package depends on neither the
roster nor the agent registry.

`dsh-host-plugin-manager` keeps `pluginManager` and the `plugins` Remote as a
relay: one Remote method per manager method, and one exhaustive switch
turning a failure into the Remote error of the same code (`plugins/bad-request`
into `gateway/bad-request`). Its `./types` re-exports the manager's types, so
`api-remotes` and the client import one vocabulary unchanged.

`dsh plugin add` and `remove` now run the installer instead of a bare pnpm
forwarder: every new package is probed, a package that declares neither a
bundle nor a plugin module is removed again with its reason, and newly
installed bundles are enabled as before. Other pnpm verbs still forward.
This commit is contained in:
Yichen Jiang committed 2026-09-07 17:47:08 +08:00
1 parent 42764971ca
commit 7b713ca83b
49 files changed
+3383 -2193

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
2026-09-04-plugin-manager-over-the-profile-runtime.md: 493ddad8a151c1fa3e9d926ba0d7c4c39f301e10
2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 4e8e4a400f84b6b65b67ebd2d6d60d2bc45e8a08
2026-09-04-plugin-manager-over-the-profile-runtime.md: a486806da2a62c4f7265e6e2b2204fff1b274843
2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 5ca6b436d5e446743b28d610e3957c1618e93818
@@ -10,7 +10,7 @@ Installing a plugin was a terminal-only act: `dsh plugin --profile web add <spec
## Decision
**One service, one manifest.** `dsh-host-plugin-manager` provides `pluginManager` and the `plugins` Remote: `list`, `install`, `uninstall`, `enable`, `disable`, `retry`, `addRow`, `removeRow`, `setRowDisabled`, `dependents`. Every operation reads the profile manifest afresh and writes it through the same app-boot helpers the CLI uses — `reconcileInstalledBundles`, `enableBundle`, `disableBundle` — so the CLI and the manager cannot disagree on the file: `dependencies` says what is installed, `dsh.profile.bundles` says what is enabled. The profile runtime is resolved per call rather than injected, so the web bundle's row starts in a composition booted without the profile launcher and answers `plugins/unavailable`.
**One manager, one Remote.** `dsh-plugin-manager`, in the boot group beside `app-boot`, owns what plugin management does: `PluginInstaller` needs only the profile on disk (pnpm runs, the probe, the post-install checks) and `PluginManager` adds every operation over the booted tree, each refusal a `PluginOperationError` with a `plugins/*` code. `dsh-host-plugin-manager` provides `pluginManager` and the `plugins` Remote — `list`, `add`, `uninstall`, `enable`, `disable`, `retry`, `addRow`, `removeRow`, `setRowDisabled`, `dependents` — as a relay: one Remote method per manager method, and one exhaustive switch turning a failure into the Remote error of the same code (`plugins/bad-request` into the Gateway's `gateway/bad-request`). The manager takes the profile runtime, the preset roster, and the running-agent count as readers called per call, and the roster only as `PresetLayers` (layer path, preset list, composition rows), so it depends on neither the roster nor the agent registry; `dsh plugin add` and `remove` run the same installer, so the CLI and the Web host share one admission rule without booting a profile in the terminal. Every operation reads the profile manifest afresh and writes it through the same app-boot helpers the CLI uses — `reconcileInstalledBundles`, `enableBundle`, `disableBundle` — so the CLI and the manager cannot disagree on the file: `dependencies` says what is installed, `dsh.profile.bundles` says what is enabled. The profile runtime is resolved per call rather than injected, so the web bundle's row starts in a composition booted without the profile launcher and answers `plugins/unavailable`.
**Enablement is the Loader's transaction.** `enable` puts the bundle in the layer list and calls `profileRuntime.recompose({ reloadBundles: true })`, after `healProfilesModuleFallback` has linked the packages the bundle carries. A rejected recomposition — a `boot`-stage bundle whose row throws — is the Loader rolling back to the tree that was running; the manager restores the list and reports `plugins/enable-failed`. A `runtime`-stage bundle whose row fails is isolated by the contained group and reported per row. Because the boot audit does not run again, the manager calls `recordContainedStates` after a live recomposition, and `ContainedGroup.create` now records a row that resolved in the pending state instead of clearing it — a reload re-creates every row of a group, and a waiting row must keep its record through that. `retry` is disable then enable: the Loader's update leaves an unchanged row alone, so only leaving and returning restarts a failed isolated row.
@@ -30,10 +30,12 @@ Installing a plugin was a terminal-only act: `dsh plugin --profile web add <spec
**Enabling a bundle without probing it.** Rejected: the probe is what turns a package that cannot import, or that resolves its own cordis copy, into a `not-enableable` view with a reason before the tree is asked to mount it.
**Keeping the operations inside the host package.** Rejected after review: `host/` is the web GUI's half, so the `dsh plugin` command could reuse the install path only by depending on a Web host package, and it kept its own pnpm forwarder without the post-install checks instead. The one-file-in-`app-boot` variant was rejected too: every `dsh` surface loads `app-boot` at start, and a thousand lines of pnpm streaming, probing, and dependents belong beside it, not inside it.
## Consequences
A running Web host can install, enable, disable, retry, and remove third-party bundles and add their modules to the global layer or a preset without a restart on a live profile. Updating a loaded package still needs a restart (Node's module cache); `dependents` stops at injection edges; `engines.dsh` is reported, not enforced; the client UI arrives in a later PR. An enable, disable, or row edit is not guarded by running sessions: it recomposes the tree, which is the Loader's transaction, and leaves `node_modules` alone.
## Testing
`packages/host/plugin-manager/tests/plugin-manager.spec.ts` boots a temporary profile through `boot()` with the profile runtime the launcher provides and a fake pnpm that edits the manifest the way the real one does: the view fold (installed, enabled, probed, waiting, user-disabled, first-party, hand-written manifests), install with and without enabling and its failures (exit code, spawn error, timeout, log tail, the manifest restored after a failed run), the post-install removal of a library and of a bundle whose row id another layer owns while a refused probe keeps its package, the one-mutation-at-a-time refusal and the running-session refusal, enable and disable live and on a `startup` profile, the boot-stage rollback, retry of a flaky isolated row, rows in the global and a preset's layer with conflicts, dependents by provided service and by user-layer reference, and uninstall. `packages/boot/app-boot/tests/contained-group.spec.ts` pins the waiting-row record across a reload.
`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` boots a temporary profile through `boot()` with the profile runtime the launcher provides and a fake pnpm that edits the manifest the way the real one does: the view fold (installed, enabled, probed, waiting, user-disabled, first-party, hand-written manifests), install with and without enabling and its failures (exit code, spawn error, timeout, log tail, the manifest restored after a failed run), the post-install removal of a library and of a bundle whose row id another layer owns while a refused probe keeps its package, the one-mutation-at-a-time refusal and the running-session refusal, enable and disable live and on a `startup` profile, the boot-stage rollback, retry of a flaky isolated row, rows in the global and a preset's layer with conflicts, dependents by provided service and by user-layer reference, and uninstall. `packages/host/plugin-manager/tests/plugin-manager.spec.ts` pins the relay: every Remote method with its arguments, each `plugins/*` code crossing as a Remote error with the failure as its cause, the generic refusal as `gateway/bad-request`, and the readers into the context. `apps/cli/tests/plugin.spec.ts` pins `dsh plugin add` and `remove` through the installer with a fake pnpm and probe: the new bundle enabled, a plain library removed again with its reason, and the exit codes of a failed or missing pnpm. `packages/boot/app-boot/tests/contained-group.spec.ts` pins the waiting-row record across a reload.
@@ -10,7 +10,7 @@ Status: implemented
## 决定
**一个服务,一份 manifest。** `dsh-host-plugin-manager` 提供 `pluginManager` 与 `plugins` Remote:`list`、`install`、`uninstall`、`enable`、`disable`、`retry`、`addRow`、`removeRow`、`setRowDisabled`、`dependents`。每个操作都重新读取 profile manifest,并通过 CLI 所用的同一组 app-boot 助手——`reconcileInstalledBundles`、`enableBundle`、`disableBundle`——写回,因此 CLI 与管理器不可能对这个文件有分歧:`dependencies` 说装了什么,`dsh.profile.bundles` 说启用了什么。profile runtime 按调用解析而非注入,于是 web 组合包的这一行在不经 profile launcher 启动的组合里也能启动,并回答 `plugins/unavailable`。
**一个管理器,一个 Remote。** boot 组里与 `app-boot` 并列的 `dsh-plugin-manager` 拥有插件管理做什么:`PluginInstaller` 只需要磁盘上的 profile(pnpm 运行、探针、装后检查),`PluginManager` 在其上加上已启动树的每项操作,每次拒绝都是带 `plugins/*` 码的 `PluginOperationError`。`dsh-host-plugin-manager` 提供 `pluginManager` 与 `plugins` Remote——`list`、`add`、`uninstall`、`enable`、`disable`、`retry`、`addRow`、`removeRow`、`setRowDisabled`、`dependents`——作为转接层:一个 Remote 方法对应一个管理器方法,一个穷尽的 switch 把失败转成同码的 Remote 错误(`plugins/bad-request` 转成 Gateway 的 `gateway/bad-request`)。管理器把 profile runtime、preset roster 与运行中 agent 数当作按调用读取的读取器接入,roster 只以 `PresetLayers`(层路径、preset 列表、组合行)的形态接入,因此既不依赖 roster 也不依赖 agent 注册表;`dsh plugin add` 与 `remove` 跑同一个安装器,CLI 与 Web 宿主共用一套准入规则,终端里不必启动 profile。每个操作都重新读取 profile manifest,并通过 CLI 所用的同一组 app-boot 助手——`reconcileInstalledBundles`、`enableBundle`、`disableBundle`——写回,因此 CLI 与管理器不可能对这个文件有分歧:`dependencies` 说装了什么,`dsh.profile.bundles` 说启用了什么。profile runtime 按调用解析而非注入,于是 web 组合包的这一行在不经 profile launcher 启动的组合里也能启动,并回答 `plugins/unavailable`。
**启用就是 Loader 的事务。** `enable` 把组合包放进层列表,在 `healProfilesModuleFallback` 链接好该组合包携带的包之后调用 `profileRuntime.recompose({ reloadBundles: true })`。被拒绝的重新组合——`boot` 阶段而行抛错的组合包——就是 Loader 回滚到原本运行的树;管理器恢复层列表并报告 `plugins/enable-failed`。`runtime` 阶段而行失败的组合包由受控组隔离并逐行报告。由于启动审计不会再跑一次,管理器在在线重新组合之后调用 `recordContainedStates`,而 `ContainedGroup.create` 现在把以 pending 状态完成创建的行记录下来而不是清除——重载会重新创建组里的每一行,等待中的行必须带着记录穿过这一过程。`retry` 是先停用再启用:Loader 的更新不碰未改变的行,只有离开再回来才能重启一条失败的隔离行。
@@ -30,10 +30,12 @@ Status: implemented
**不探测就启用组合包。** 否决:正是探针把无法 import、或解析到自己那份 cordis 副本的包,在树被要求挂载之前变成带原因的 `not-enableable` 视图。
**把操作留在宿主包里。** 评审后否决:`host/` 是 Web GUI 的那一半,`dsh plugin` 命令要复用安装路径就只能依赖一个 Web 宿主包,于是它一直保留着自己的、没有装后检查的 pnpm 转发器。放进 `app-boot` 一个文件的变体也被否决:每个 `dsh` 表面启动时都加载 `app-boot`,上千行的 pnpm 流式输出、探测与依赖检测应该与它并列,而不是塞进它。
## 后果
运行中的 Web 宿主可以在 live profile 上不重启地安装、启用、停用、重试与移除三方组合包,并把它们的模块加进全局层或某个 preset。更新已加载的包仍需重启(Node 的模块缓存);`dependents` 止于注入边;`engines.dsh` 只报告不强制;客户端 UI 在后续 PR 到来。启用、停用与行编辑不受运行中会话限制:它们重组的是树,那是 Loader 的事务,不碰 `node_modules`。
## 测试
`packages/host/plugin-manager/tests/plugin-manager.spec.ts` 经 `boot()` 启动一个临时 profile,带上 launcher 提供的 profile runtime 与一个按真实 pnpm 的方式编辑 manifest 的假 pnpm:视图折叠(已安装、已启用、已探测、等待中、用户停用、一方包、手写 manifest),带与不带启用的安装及其失败(退出码、spawn 错误、超时、日志尾部、失败后恢复的 manifest),装后移除库包与行 id 被别的层占有的组合包而探针拒绝的包保留,一次只跑一个变更的拒绝与会话运行中的拒绝,live 与 `startup` profile 上的启用与停用,boot 阶段的回滚,不稳定隔离行的重试,全局层与 preset 层里的行及其冲突,按提供服务与按用户层引用的依赖检测,以及卸载。`packages/boot/app-boot/tests/contained-group.spec.ts` 钉住等待中的行记录跨重载保留。
`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` 经 `boot()` 启动一个临时 profile,带上 launcher 提供的 profile runtime 与一个按真实 pnpm 的方式编辑 manifest 的假 pnpm:视图折叠(已安装、已启用、已探测、等待中、用户停用、一方包、手写 manifest),带与不带启用的安装及其失败(退出码、spawn 错误、超时、日志尾部、失败后恢复的 manifest),装后移除库包与行 id 被别的层占有的组合包而探针拒绝的包保留,一次只跑一个变更的拒绝与会话运行中的拒绝,live 与 `startup` profile 上的启用与停用,boot 阶段的回滚,不稳定隔离行的重试,全局层与 preset 层里的行及其冲突,按提供服务与按用户层引用的依赖检测,以及卸载。`packages/host/plugin-manager/tests/plugin-manager.spec.ts` 钉住转接层:每个 Remote 方法及其参数、每个 `plugins/*` 码以失败为 cause 过线成 Remote 错误、通用拒绝过线成 `gateway/bad-request`,以及读向上下文的读取器。`apps/cli/tests/plugin.spec.ts` 用假 pnpm 与假探针钉住经安装器的 `dsh plugin add` 与 `remove`:新组合包被启用、普通库带原因被再移除,以及 pnpm 失败或缺失时的退出码。`packages/boot/app-boot/tests/contained-group.spec.ts` 钉住等待中的行记录跨重载保留。
+1
View File
@@ -57,6 +57,7 @@
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-persona": "workspace:^",
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
+2 -2
View File
@@ -168,11 +168,11 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
resolved = resolveBoot(web, 'web', options, args)
})
const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
const plugin = program.command('plugin').description('manage a profile\'s plugins: add and remove install through the plugin manager, every other verb is forwarded to pnpm in the profile directory')
plugin
.requiredOption('--profile <name>', 'the profile whose plugins to manage (initialized on first use)')
.allowUnknownOption()
.argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
.argument('[args...]', 'add <pkg...>, remove <pkg...>, or any pnpm arguments forwarded verbatim (why <pkg>, update, ...)')
.action((args: string[], options: { profile: string }) => {
rejectParentOptions('plugin')
if (options.profile === '') program.error('error: --profile needs a name')
+1 -1
View File
@@ -36,7 +36,7 @@ switch (invocation.mode) {
}
case 'plugin': {
const { runPlugin } = await import('./plugin.ts')
process.exit(runPlugin(invocation.profile, invocation.args))
process.exit(await runPlugin(invocation.profile, invocation.args))
break
}
case 'dump-config': {
+116 -25
View File
@@ -1,12 +1,16 @@
/**
* `dsh plugin --profile <name> <args...>` — profile plugin management as a
* thin pnpm forwarder: initialize the profile on first use, run
* `pnpm <args...>` in the profile directory, then reconcile the
* `dsh.profile.bundles` layer list against the installed state (a dependency
* resolving to a package that declares `dsh.bundle` joins the layer stack; a
* removed or bundle-less dependency leaves it). Reconciling by installed
* state, not by dependency diff, means `update` activates a package that
* gained its `dsh.bundle` declaration in a newer version.
* `dsh plugin --profile <name> <args...>` — profile plugin management from
* the terminal. `add <spec...>` and `remove <name...>` go through the plugin
* installer the Web host shares: pnpm runs in the profile directory, every
* new package is probed, a package that declares neither a bundle nor a
* plugin module (or whose row id a composed layer already owns) is removed
* again with the reason printed, and every newly installed bundle joins the
* layer list — the CLI's install-and-enable semantics. Every other pnpm verb
* is forwarded verbatim and followed by a reconcile of the
* `dsh.profile.bundles` layer list against the installed state, so `update`
* activates a package that gained its `dsh.bundle` declaration in a newer
* version. Nothing here boots the profile: the plugins being managed never
* start.
* @module @deepseek-ai/dsh/plugin
*/
@@ -15,29 +19,47 @@ import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import {
DEFAULT_PROFILE_BUNDLES,
enableBundle,
initProfile,
loadProfile,
PROFILE_TEMPLATES,
readProfileManifest,
reconcileInstalledBundles,
resolveProfileDir,
type probePackage,
type ProfileManifest,
} from '@deepseek-ai/dsh-app-boot'
import {
PluginInstaller, pluginOperationFailureOf, type PluginInstallOutcome, type PluginToolingConfig, type SpawnLike,
} from '@deepseek-ai/dsh-plugin-manager'
import { INSTALL_ANCHOR } from './profile-boot.ts'
const NAME = 'dsh'
/** The tooling bounds the command runs with; the Web host reads the same values from its config. */
const TOOLING: PluginToolingConfig = { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 }
/** Test seams: the child spawner and the package probe, so no pnpm or probe child runs. */
export interface PluginCommandInternals {
spawn?: SpawnLike
probe?: typeof probePackage
}
/**
* Reconcile `dsh.profile.bundles` against the installed state with the CLI's
* install-and-enable semantics: pnpm has already written the real installed
* names (so a git/path/tarball/alias spec on the command line reconciles by
* its true package name) and materialized the packages, and every newly
* installed bundle joins the layer stack. Warns once per newly-added
* install-and-enable semantics after a forwarded pnpm verb: pnpm has already
* written the real installed names and materialized the packages, and every
* newly installed bundle joins the layer stack. Warns once per newly-added
* bundle-less dependency (a plain library or plugin module is fine; the
* warning is orientation).
*/
function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
const outcome = reconcileInstalledBundles(NAME, profileDir, INSTALL_ANCHOR, before, { autoEnable: true })
for (const packageName of outcome.plain) {
warnPlain(outcome.plain)
}
function warnPlain(plain: readonly string[]): void {
for (const packageName of plain) {
process.stderr.write(
`${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
+ '(a later update that gains one activates it automatically)\n',
@@ -66,13 +88,22 @@ function anchorPathSpec(argument: string, cwd: string): string {
return `${prefix}${resolve(cwd, match.groups.path)}`
}
/** Whether the arguments are an `add` or `remove` of plain specs, which the installer handles. */
function managedVerb(args: readonly string[]): 'add' | 'remove' | undefined {
const [verb, ...rest] = args
if ((verb !== 'add' && verb !== 'remove') || rest.length === 0 || rest.some(argument => argument.startsWith('-'))) return undefined
return verb
}
/**
* Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile.
* Run one `dsh plugin` invocation: init if needed, then install or remove
* through the shared installer, or forward to pnpm and reconcile.
* @param profile - the profile name.
* @param args - pnpm arguments with relative path specs anchored to the invoking directory.
* @returns the pnpm exit code.
* @param internals - test seams.
* @returns the exit code.
*/
export function runPlugin(profile: string, args: readonly string[]): number {
export async function runPlugin(profile: string, args: readonly string[], internals: PluginCommandInternals = {}): Promise<number> {
const dir = resolveProfileDir(profile)
if (!existsSync(join(dir, 'package.json'))) {
const template = PROFILE_TEMPLATES[profile]
@@ -83,6 +114,75 @@ export function runPlugin(profile: string, args: readonly string[]): number {
)
process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
}
const verb = managedVerb(args)
if (verb !== undefined) return runManaged(profile, dir, verb, args.slice(1), internals)
return forwardToPnpm(dir, args)
}
/** Install or remove through the installer, enabling every newly installed bundle as the CLI always has. */
async function runManaged(
profile: string,
dir: string,
verb: 'add' | 'remove',
specs: readonly string[],
internals: PluginCommandInternals,
): Promise<number> {
const installer = new PluginInstaller({
profileDir: dir,
profileName: profile,
installAnchor: INSTALL_ANCHOR,
loadProfile: () => loadProfile(NAME, profile, INSTALL_ANCHOR, undefined, { userLayer: false }),
config: TOOLING,
installLog: (chunk) => { (chunk.stream === 'stdout' ? process.stdout : process.stderr).write(chunk.text) },
...internals,
})
for (const argument of specs) {
const spec = anchorPathSpec(argument, process.cwd())
try {
if (verb === 'remove') {
await installer.remove(spec)
continue
}
report(dir, await installer.add(spec))
} catch (error) {
const failure = pluginOperationFailureOf(error)
if (failure?.code !== 'plugins/install-failed') throw error
return explainFailure(dir, spec, failure.details.exitCode, failure.cause)
}
}
return 0
}
/** Enable what the run installed, and say what it removed again and what it left as a plain dependency. */
function report(dir: string, outcome: PluginInstallOutcome): void {
for (const name of outcome.installedOnly) enableBundle(NAME, dir, INSTALL_ANCHOR, name)
for (const rejection of outcome.removed) {
process.stderr.write(`${NAME}: removed ${rejection.name} again: ${rejection.reason}\n`)
}
warnPlain(outcome.plain)
}
/** The exit code and the orientation a failed pnpm run leaves the user with. */
function explainFailure(dir: string, spec: string, exitCode: number | null, cause: unknown): number {
if ((cause as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`)
return 127
}
// pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
// one; the profile owns it, and the commonest failure here is pnpm ≥10
// blocking a git dependency's prepare (build) script until allowlisted.
process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`)
if (/^git\+|^github:|\.git(?:#|$)/.test(spec)) {
process.stderr.write(
`${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — `
+ `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`,
)
}
return exitCode ?? 1
}
/** Forward any other pnpm verb verbatim, then reconcile the layer list. */
function forwardToPnpm(dir: string, args: readonly string[]): number {
const before = readProfileManifest(NAME, dir)
// Windows resolves pnpm through its .cmd shim, which spawn() refuses
// without a shell since the CVE-2024-27980 hardening.
@@ -103,16 +203,7 @@ export function runPlugin(profile: string, args: readonly string[]): number {
if (exitCode === 0) {
reconcilePlugins(before, dir)
} else {
// pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
// one; the profile owns it, and the commonest failure here is pnpm ≥10
// blocking a git dependency's prepare (build) script until allowlisted.
process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`)
if (args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
process.stderr.write(
`${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — `
+ `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`,
)
}
}
return exitCode
}
+151
View File
@@ -0,0 +1,151 @@
/**
* `dsh plugin add` and `remove` through the shared installer: a temporary
* harness home, a fake pnpm that stages packages and edits the manifest the
* way the real one does, and a fake probe. Nothing boots.
*/
import { EventEmitter } from 'node:events'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import type { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readProfileManifest, resolveProfileDir, type probePackage } from '@deepseek-ai/dsh-app-boot'
import type { SpawnLike } from '@deepseek-ai/dsh-plugin-manager'
import { runPlugin } from '../src/plugin.ts'
let home: string
let previousHome: string | undefined
let stderr: string
let stdout: string
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'dsh-plugin-command-'))
previousHome = process.env.DSH_HOME
process.env.DSH_HOME = home
stderr = ''
stdout = ''
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { stderr += String(chunk); return true })
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdout += String(chunk); return true })
})
afterEach(() => {
vi.restoreAllMocks()
if (previousHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previousHome
rmSync(home, { recursive: true, force: true })
})
/** Stage one package under the profile's node_modules and record it as a dependency, as `pnpm add` does. */
function install(profileDir: string, name: string, bundle: boolean): void {
const dir = join(profileDir, 'node_modules', name)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name, version: '1.0.0', type: 'module',
...bundle ? { dsh: { bundle: { patch: './cordis.patch.yml' } } } : {},
}))
if (bundle) writeFileSync(join(dir, 'cordis.patch.yml'), '- insert:\n - id: hello\n name: cordis:good\n')
const path = join(profileDir, 'package.json')
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies?: Record<string, string> }
writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { ...manifest.dependencies, [name]: '1.0.0' } }, null, 2))
}
/** Forget one package, as `pnpm remove` does. */
function uninstall(profileDir: string, name: string): void {
const path = join(profileDir, 'package.json')
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies?: Record<string, string> }
const { [name]: _gone, ...remaining } = manifest.dependencies ?? {}
writeFileSync(path, JSON.stringify({ ...manifest, dependencies: remaining }, null, 2))
rmSync(join(profileDir, 'node_modules', name), { recursive: true, force: true })
}
/** A pnpm that installs `ext-bundle` as a bundle and everything else as a plain library, or fails as told. */
function fakePnpm(calls: string[][], failWith?: { code: number } | { error: NodeJS.ErrnoException }): SpawnLike {
return (_command, args, options) => {
calls.push([...args])
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough }
child.stdout = new PassThrough()
child.stderr = new PassThrough()
setTimeout(() => {
if (failWith !== undefined && 'error' in failWith) {
child.emit('error', failWith.error)
return
}
if (failWith !== undefined) {
child.stderr.write('ERR_PNPM_FETCH\n')
child.emit('close', failWith.code)
return
}
const [verb, target] = args
const profileDir = options.cwd as string
if (verb === 'add' && target !== undefined) install(profileDir, target, target === 'ext-bundle')
if (verb === 'remove' && target !== undefined) uninstall(profileDir, target)
child.stdout.write(`${verb === 'add' ? '+' : '-'} ${String(target)}\n`)
child.emit('close', 0)
}, 5)
return child as unknown as ChildProcess
}
}
/** A probe that reads the staged manifest: a package with `dsh.bundle` is a bundle, anything else a library. */
const fakeProbe: typeof probePackage = (options) => {
const manifest = JSON.parse(readFileSync(join(options.profileDir, 'node_modules', options.packageName, 'package.json'), 'utf8')) as { dsh?: { bundle?: unknown } }
return Promise.resolve({
packageName: options.packageName,
kind: manifest.dsh?.bundle === undefined ? 'library' : 'bundle',
ok: true,
cordisSameCopy: null,
rows: [],
overrides: [],
addable: [],
checkedAt: new Date().toISOString(),
})
}
describe('dsh plugin', () => {
it('add initializes the profile, installs through the installer, enables the new bundle, and removes a plain library again', async () => {
const calls: string[][] = []
const code = await runPlugin('web', ['add', 'ext-bundle', 'ext-lib'], { spawn: fakePnpm(calls), probe: fakeProbe })
expect(code).toBe(0)
const profileDir = resolveProfileDir('web', home)
expect(stderr).toContain(`dsh: initialized profile web at ${profileDir}`)
expect(calls).toEqual([['add', 'ext-bundle'], ['add', 'ext-lib'], ['remove', 'ext-lib']])
const manifest = readProfileManifest('dsh', profileDir)
expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-bundle')
expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-lib')
expect(manifest.dsh?.profile?.bundles).toContain('ext-bundle')
expect(stdout).toContain('+ ext-bundle')
expect(stderr).toContain('dsh: removed ext-lib again: declares neither a dsh bundle nor a plugin module')
expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
})
it('remove goes through the installer and forgets the probe record', async () => {
const calls: string[][] = []
await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
const code = await runPlugin('web', ['remove', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
expect(code).toBe(0)
expect(calls).toEqual([['add', 'ext-bundle'], ['remove', 'ext-bundle']])
const profileDir = resolveProfileDir('web', home)
const manifest = readProfileManifest('dsh', profileDir)
expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-bundle')
expect(manifest.dsh?.profile?.bundles).not.toContain('ext-bundle')
expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
})
it('reports a failed pnpm run with its exit code, the git hint, and a missing pnpm as 127', async () => {
const failed = await runPlugin('web', ['add', 'github:acme/plugin'], { spawn: fakePnpm([], { code: 1 }), probe: fakeProbe })
expect(failed).toBe(1)
expect(stderr).toContain('dsh: pnpm failed in profile directory')
expect(stderr).toContain('git-hosted plugins build on install via their prepare script')
const error = Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' })
const missing = await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm([], { error }), probe: fakeProbe })
expect(missing).toBe(127)
expect(stderr).toContain('dsh: pnpm not found on PATH')
})
})
+3
View File
@@ -20,6 +20,9 @@
{
"path": "../../packages/boot/app-boot"
},
{
"path": "../../packages/boot/plugin-manager"
},
{
"path": "../../packages/boot/cmdline"
},
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 17db4a1d98b9063a96f6483bd36188633b21cd1c
config-catalog.zh.md: 141edb9e0c4fda8280c8349db46e51ad73e7158e
config-catalog.md: 33c1da0e9c5dc88bd50abd522bd73a66b101ab42
config-catalog.zh.md: b3842a730b919307fd61094fe0b114f513148281
+3 -2
View File
@@ -900,7 +900,7 @@ Source: [`packages/host/frontend-static/src/index.ts:30`](../packages/host/front
Requires: `loader`
```ts config-catalog
/** Plugin config: the pnpm command and the bounds on the child processes this service runs. */
/** Plugin config: the pnpm command and the bounds on the child processes plugin management runs. */
export interface Config {
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
pnpmCommand: string
@@ -913,7 +913,7 @@ export interface Config {
}
```
Source: [`packages/host/plugin-manager/src/index.ts:99`](../packages/host/plugin-manager/src/index.ts)
Source: [`packages/host/plugin-manager/src/index.ts:42`](../packages/host/plugin-manager/src/index.ts)
<a id="deepseek-aidsh-host-webserver"></a>
@@ -3502,6 +3502,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-output-retention` ([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
- `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
- `@deepseek-ai/dsh-patch-file` ([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/src/index.ts))
- `@deepseek-ai/dsh-plugin-manager` ([`packages/boot/plugin-manager/src/index.ts`](../packages/boot/plugin-manager/src/index.ts))
- `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
- `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts))
+3 -2
View File
@@ -902,7 +902,7 @@ export interface Config {
需要:`loader`
```ts config-catalog
/** Plugin config: the pnpm command and the bounds on the child processes this service runs. */
/** Plugin config: the pnpm command and the bounds on the child processes plugin management runs. */
export interface Config {
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
pnpmCommand: string
@@ -915,7 +915,7 @@ export interface Config {
}
```
来源:[`packages/host/plugin-manager/src/index.ts:99`](../packages/host/plugin-manager/src/index.ts)
来源:[`packages/host/plugin-manager/src/index.ts:42`](../packages/host/plugin-manager/src/index.ts)
<a id="deepseek-aidsh-host-webserver"></a>
@@ -3503,6 +3503,7 @@ export interface Config {
- `@deepseek-ai/dsh-output-retention`([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
- `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
- `@deepseek-ai/dsh-patch-file`([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/src/index.ts))
- `@deepseek-ai/dsh-plugin-manager`([`packages/boot/plugin-manager/src/index.ts`](../packages/boot/plugin-manager/src/index.ts))
- `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
- `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
- `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts))
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: de4e274ed129635dd5da873b5a03310087e078cb
event-producer-consumer.zh.md: 1d74b1d59fa04ecc81916ad411290df1a201ccaa
event-producer-consumer.md: 758f2bf7fe63cbc97d791fb1b0291ea69c296f25
event-producer-consumer.zh.md: 99b2017dc95f02d6847bf28bbb282925d12d86f0
+2 -2
View File
@@ -46,8 +46,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `plugins/changed` | `emit` | [`packages/host/plugin-manager/src/types.ts:230`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` |
| `plugins/install-log` | `emit` | [`packages/host/plugin-manager/src/types.ts:236`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` |
| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:239`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:245`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+2 -2
View File
@@ -48,8 +48,8 @@
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `plugins/changed` | `emit` | [`packages/host/plugin-manager/src/types.ts:230`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` |
| `plugins/install-log` | `emit` | [`packages/host/plugin-manager/src/types.ts:236`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` |
| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:239`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:245`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: 8b8ad75fbb8c01348dc0d355ac278077d08b7107
module-graph.zh.md: 0bc5f1340f4c4c99b98ad3ca543ee8e5f2e2306f
module-graph.md: 75317ce075dbe0203a3e581e78ec3e3db465c6cc
module-graph.zh.md: c1ff7f113b9a5977e9a2c4bfbe1b9b96d4500e3a
+8 -3
View File
@@ -123,6 +123,7 @@ flowchart TD
subgraph group_boot["packages/boot"]
pkg_app_boot["app-boot"]
pkg_cmdline["cmdline"]
pkg_plugin_manager["plugin-manager"]
end
subgraph group_bundle["packages/bundle"]
pkg_acp_app["acp-app"]
@@ -464,6 +465,10 @@ flowchart TD
pkg_spill_local --> pkg_spill
pkg_session_log_export --> pkg_session
pkg_session_log_export --> pkg_session_persistence
pkg_plugin_manager --> pkg_app_boot
pkg_plugin_manager --> pkg_package_manifest
pkg_plugin_manager --> pkg_patch_file
pkg_plugin_manager --> pkg_util_values
pkg_message_feedback --> pkg_brand
pkg_message_feedback --> pkg_llm
pkg_message_feedback --> pkg_session
@@ -931,8 +936,7 @@ flowchart TD
pkg_host_plugin_manager --> pkg_agent
pkg_host_plugin_manager --> pkg_agent_presets
pkg_host_plugin_manager --> pkg_app_boot
pkg_host_plugin_manager --> pkg_package_manifest
pkg_host_plugin_manager --> pkg_patch_file
pkg_host_plugin_manager --> pkg_plugin_manager
pkg_host_plugin_manager --> pkg_typert_protocol
pkg_host_plugin_manager --> pkg_util_values
pkg_tool_bash --> pkg_agent
@@ -1301,6 +1305,7 @@ flowchart TD
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`app-boot`](../packages/boot/app-boot), [`package-manifest`](../packages/util/package-manifest), [`patch-file`](../packages/util/patch-file), [`util-values`](../packages/util/values) |
| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1395,7 +1400,7 @@ flowchart TD
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`package-manifest`](../packages/util/package-manifest), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`plugin-manager`](../packages/boot/plugin-manager), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
+8 -3
View File
@@ -125,6 +125,7 @@ flowchart TD
subgraph group_boot["packages/boot"]
pkg_app_boot["app-boot"]
pkg_cmdline["cmdline"]
pkg_plugin_manager["plugin-manager"]
end
subgraph group_bundle["packages/bundle"]
pkg_acp_app["acp-app"]
@@ -466,6 +467,10 @@ flowchart TD
pkg_spill_local --> pkg_spill
pkg_session_log_export --> pkg_session
pkg_session_log_export --> pkg_session_persistence
pkg_plugin_manager --> pkg_app_boot
pkg_plugin_manager --> pkg_package_manifest
pkg_plugin_manager --> pkg_patch_file
pkg_plugin_manager --> pkg_util_values
pkg_message_feedback --> pkg_brand
pkg_message_feedback --> pkg_llm
pkg_message_feedback --> pkg_session
@@ -933,8 +938,7 @@ flowchart TD
pkg_host_plugin_manager --> pkg_agent
pkg_host_plugin_manager --> pkg_agent_presets
pkg_host_plugin_manager --> pkg_app_boot
pkg_host_plugin_manager --> pkg_package_manifest
pkg_host_plugin_manager --> pkg_patch_file
pkg_host_plugin_manager --> pkg_plugin_manager
pkg_host_plugin_manager --> pkg_typert_protocol
pkg_host_plugin_manager --> pkg_util_values
pkg_tool_bash --> pkg_agent
@@ -1303,6 +1307,7 @@ flowchart TD
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`app-boot`](../packages/boot/app-boot), [`package-manifest`](../packages/util/package-manifest), [`patch-file`](../packages/util/patch-file), [`util-values`](../packages/util/values) |
| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1397,7 +1402,7 @@ flowchart TD
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`package-manifest`](../packages/util/package-manifest), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`plugin-manager`](../packages/boot/plugin-manager), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/core.md
core.md: c5f2c24024dbfae2e585cbcbe5c4f0015955bf26
core.zh.md: 47acf6d6923bc5fb99b7ecf2c6f76aca84edf5b7
core.md: b7a1975d18f93eab6c67d7c85852c4ca59593188
core.zh.md: 0fb0ed4e0470c760ec2d78ae13ff763dcd925f80
+16 -42
View File
@@ -860,85 +860,63 @@ roots(): Agent[]
Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts)
<a id="ctxpluginmanager--pluginmanager"></a>
<a id="ctxpluginmanager--pluginmanagerremote"></a>
### `ctx.pluginManager` — `PluginManager`
### `ctx.pluginManager` — `PluginManagerRemote`
The `pluginManager` service and the `plugins` Remote.
Every method that changes the profile reads the manifest afresh and writes it through the app-boot helpers the `dsh plugin` command uses, so the CLI and the manager never disagree on the file. The profile runtime is resolved per call: a composition without it (a test, a launcher other than the profile launcher) still mounts this service, and every call then reports `plugins/unavailable` rather than the service failing to start.
The row injects only the Loader; the profile runtime, the preset roster, and the agent registry are read off the context per call, so a composition without them (a test, a launcher other than the profile launcher) still mounts this service and every call then reports `plugins/unavailable` rather than the service failing to start.
```ts cordis-catalog
/**
* 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.
* Every package the profile knows, one view each.
* @returns the views, bundles first in layer order.
*/
@Remote('list') async list(): Promise<PluginPackageView[]>
/**
* Install a package into the profile with pnpm, probe it, and leave it
* disabled unless asked otherwise. The run's output streams 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.
* Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.
* @param spec - what to install, in pnpm's own vocabulary.
* @param options - `enable` puts every newly installed bundle into the layer list at once.
* @returns what the run installed and enabled.
* @throws {RemoteError} `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.
*/
@Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
/**
* 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.
* Remove a package from the profile with its user-layer rows and probe record.
* @param packageName - the installed dependency to remove.
* @throws {RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero.
*/
@Remote('uninstall') async uninstall(packageName: string): Promise<void>
/**
* 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.
* Put an installed bundle into the layer list and, on a live profile, recompose the tree with it.
* @param packageName - the installed bundle.
* @returns whether the list changed and whether the change is live.
* @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
* for a package that declares no bundle or whose probe refused it, or
* `plugins/enable-failed`.
*/
@Remote('enable') async enable(packageName: string): Promise<PluginEnableResult>
/**
* Take a bundle out of the layer list and, on a live profile, recompose
* the tree without it.
* 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 {RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency.
*/
@Remote('disable') async disable(packageName: string): Promise<PluginEnableResult>
/**
* Compose an enabled bundle again from scratch: its group leaves the tree
* and returns, so rows that failed at boot get another start.
* Compose an enabled bundle again from scratch.
* @param packageName - the enabled bundle.
* @returns the enable outcome of the second step.
* @throws {RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures.
*/
@Remote('retry') async retry(packageName: string): Promise<PluginEnableResult>
/**
* 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.
* Add a row naming one of the package's modules to a user layer.
* @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 {RemoteError} `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.
*/
@Remote('addRow') async addRow( packageName: string, target: PluginRowTarget, options?: { module?: string; id?: string; config?: JsonValue }, ): Promise<PluginRowAddition>
@@ -946,14 +924,11 @@ Every method that changes the profile reads the manifest afresh and writes it th
* Remove a row a user layer inserted.
* @param target - which layer.
* @param rowId - the inserted row's id.
* @throws {RemoteError} `gateway/bad-request` when the layer inserts no such row.
*/
@Remote('removeRow') async removeRow(target: PluginRowTarget, rowId: string): Promise<void>
/**
* 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.
* Switch one row off or on in a user layer; deny-only.
* @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.
@@ -961,8 +936,7 @@ Every method that changes the profile reads the manifest afresh and writes it th
@Remote('setRowDisabled') async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void>
/**
* What disabling or removing a package would strand: services its rows
* provide that rows outside it inject, and user-layer rows naming its modules.
* What disabling or removing a package would strand.
* @param packageName - the package.
* @returns the dependents.
*/
@@ -1403,7 +1377,7 @@ The manager changed what is installed, enabled, or composed.
'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void
```
Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts)
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
<a id="pluginsinstall-log--emit"></a>
@@ -1420,5 +1394,5 @@ One chunk of an install run's output, in order; the last chunk carries the exit
'plugins/install-log'(chunk: PluginInstallLogChunk): void
```
Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts)
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
<!-- END GENERATED cordis-surface -->
+16 -42
View File
@@ -870,85 +870,63 @@ roots(): Agent[]
Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts)
<a id="ctxpluginmanager--pluginmanager"></a>
<a id="ctxpluginmanager--pluginmanagerremote"></a>
### `ctx.pluginManager` — `PluginManager`
### `ctx.pluginManager` — `PluginManagerRemote`
The `pluginManager` service and the `plugins` Remote.
Every method that changes the profile reads the manifest afresh and writes it through the app-boot helpers the `dsh plugin` command uses, so the CLI and the manager never disagree on the file. The profile runtime is resolved per call: a composition without it (a test, a launcher other than the profile launcher) still mounts this service, and every call then reports `plugins/unavailable` rather than the service failing to start.
The row injects only the Loader; the profile runtime, the preset roster, and the agent registry are read off the context per call, so a composition without them (a test, a launcher other than the profile launcher) still mounts this service and every call then reports `plugins/unavailable` rather than the service failing to start.
```ts cordis-catalog
/**
* 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.
* Every package the profile knows, one view each.
* @returns the views, bundles first in layer order.
*/
@Remote('list') async list(): Promise<PluginPackageView[]>
/**
* Install a package into the profile with pnpm, probe it, and leave it
* disabled unless asked otherwise. The run's output streams 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.
* Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.
* @param spec - what to install, in pnpm's own vocabulary.
* @param options - `enable` puts every newly installed bundle into the layer list at once.
* @returns what the run installed and enabled.
* @throws {RemoteError} `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.
*/
@Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
/**
* 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.
* Remove a package from the profile with its user-layer rows and probe record.
* @param packageName - the installed dependency to remove.
* @throws {RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero.
*/
@Remote('uninstall') async uninstall(packageName: string): Promise<void>
/**
* 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.
* Put an installed bundle into the layer list and, on a live profile, recompose the tree with it.
* @param packageName - the installed bundle.
* @returns whether the list changed and whether the change is live.
* @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
* for a package that declares no bundle or whose probe refused it, or
* `plugins/enable-failed`.
*/
@Remote('enable') async enable(packageName: string): Promise<PluginEnableResult>
/**
* Take a bundle out of the layer list and, on a live profile, recompose
* the tree without it.
* 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 {RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency.
*/
@Remote('disable') async disable(packageName: string): Promise<PluginEnableResult>
/**
* Compose an enabled bundle again from scratch: its group leaves the tree
* and returns, so rows that failed at boot get another start.
* Compose an enabled bundle again from scratch.
* @param packageName - the enabled bundle.
* @returns the enable outcome of the second step.
* @throws {RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures.
*/
@Remote('retry') async retry(packageName: string): Promise<PluginEnableResult>
/**
* 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.
* Add a row naming one of the package's modules to a user layer.
* @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 {RemoteError} `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.
*/
@Remote('addRow') async addRow( packageName: string, target: PluginRowTarget, options?: { module?: string; id?: string; config?: JsonValue }, ): Promise<PluginRowAddition>
@@ -956,14 +934,11 @@ Every method that changes the profile reads the manifest afresh and writes it th
* Remove a row a user layer inserted.
* @param target - which layer.
* @param rowId - the inserted row's id.
* @throws {RemoteError} `gateway/bad-request` when the layer inserts no such row.
*/
@Remote('removeRow') async removeRow(target: PluginRowTarget, rowId: string): Promise<void>
/**
* 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.
* Switch one row off or on in a user layer; deny-only.
* @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.
@@ -971,8 +946,7 @@ Every method that changes the profile reads the manifest afresh and writes it th
@Remote('setRowDisabled') async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void>
/**
* What disabling or removing a package would strand: services its rows
* provide that rows outside it inject, and user-layer rows naming its modules.
* What disabling or removing a package would strand.
* @param packageName - the package.
* @returns the dependents.
*/
@@ -1413,7 +1387,7 @@ The manager changed what is installed, enabled, or composed.
'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void
```
Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts)
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
<a id="pluginsinstall-log--emit"></a>
@@ -1430,5 +1404,5 @@ One chunk of an install run's output, in order; the last chunk carries the exit
'plugins/install-log'(chunk: PluginInstallLogChunk): void
```
Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts)
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
<!-- END GENERATED cordis-surface -->
+2 -2
View File
@@ -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/README.md
README.md: 64a118a4a243d21508407ea28686fba4fd0772bd
README.zh.md: a092a5840cd0fd26543a2221d1aa5ca13ea5d352
README.md: 497b8159ee099d2442cc022b905c3bdcea8e8d6b
README.zh.md: 7e8f43955e048a5f9769e40a68a7ebfaca6a296d
+2 -1
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
The boot group provides what every dsh app bin needs to start: `app-boot` turns a `cordis.yml` plus your environment and patch layers into a running app with clear failure messages, and `cmdline` lets the app own its command-line flags and `--help`. With these packages you can run `dsh` or write a new application or test fixture that boots the same way. Both are libraries imported by `apps/cli` and test-only Loader fixtures, never plugins a composition loads. This page maps the group; each package README owns its per-package contract.
The boot group provides what every dsh app bin needs to start and to manage what it starts: `app-boot` turns a `cordis.yml` plus your environment and patch layers into a running app with clear failure messages, `cmdline` lets the app own its command-line flags and `--help`, and `plugin-manager` installs, enables, and edits a profile's plugins for the `dsh plugin` command and the Web host alike. With these packages you can run `dsh` or write a new application or test fixture that boots the same way. All three are libraries imported by `apps/cli`, host packages, and test-only Loader fixtures, never plugins a composition loads. This page maps the group; each package README owns its per-package contract.
## Table of Contents
@@ -24,6 +24,7 @@ The boot group provides what every dsh app bin needs to start: `app-boot` turns
|---|---|---|
| [`app-boot`](app-boot/README.md) | Boots a dsh app from a `cordis.yml`: loads `.env`, applies profile and patch layers, and reports startup failures clearly | (library for the bins) |
| [`cmdline`](cmdline/README.md) | Lets the app own its flags, `--help`, and exit code; passes everything after the launcher's flags through verbatim | `cmdlineArgs`, `appExit` |
| [`plugin-manager`](plugin-manager/README.md) | Installs and probes packages without booting, and enables, disables, retries, and edits user-layer rows over the booted profile; the `dsh plugin` command and the Web host's `plugins` Remote share it | (library for the bins and the host) |
<a id="related-documentation"></a>
## Related documentation
+2 -1
View File
@@ -9,7 +9,7 @@ kind: "package-group"
## 概述
boot 组提供每个 dsh app bin 启动所需的全部能力:`app-boot` 把 `cordis.yml` 连同你的环境与 patch 层变成运行中的应用,并给出清晰的失败信息;`cmdline` 让应用持有自己的命令行 flag 与 `--help`。借助这些包,你可以运行 `dsh`,也可以编写以同样方式启动的新应用或测试 fixture。两者都是 `apps/cli` 与测试专用 Loader fixture 导入的库,绝不是组合加载的插件。本页是组的映射;各包 README 负责各自的包级约定。
boot 组提供每个 dsh app bin 启动以及管理所启动之物所需的全部能力:`app-boot` 把 `cordis.yml` 连同你的环境与 patch 层变成运行中的应用,并给出清晰的失败信息;`cmdline` 让应用持有自己的命令行 flag 与 `--help`;`plugin-manager` 为 `dsh plugin` 命令与 Web 宿主安装、启用与编辑 profile 的插件。借助这些包,你可以运行 `dsh`,也可以编写以同样方式启动的新应用或测试 fixture。三者都是 `apps/cli`、宿主包与测试专用 Loader fixture 导入的库,绝不是组合加载的插件。本页是组的映射;各包 README 负责各自的包级约定。
## 目录
@@ -24,6 +24,7 @@ boot 组提供每个 dsh app bin 启动所需的全部能力:`app-boot` 把 `c
|---|---|---|
| [`app-boot`](app-boot/README.zh.md) | 从 `cordis.yml` 启动 dsh 应用:加载 `.env`、应用 profile 与 patch 层,并清晰报告启动失败 | (供各 bin 使用的库) |
| [`cmdline`](cmdline/README.zh.md) | 让应用持有自己的 flag、`--help` 与退出码;启动器自身 flag 之后的一切原样传入 | `cmdlineArgs`、`appExit` |
| [`plugin-manager`](plugin-manager/README.zh.md) | 不启动即可安装与探测包,在已启动 profile 上启用、停用、重试并编辑用户层的行;`dsh plugin` 命令与 Web 宿主的 `plugins` Remote 共用 | (供各 bin 与宿主使用的库) |
<a id="related-documentation"></a>
## 相关文档
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
README.md: ee2c9c3c82ae20022b9e719162c6ae72d1408830
README.zh.md: 83b015f1949a0b36a98ce3fe4c7e8f2c92bd1de5
+149
View File
@@ -0,0 +1,149 @@
---
description: "Plugin management over a dsh profile: the installer the dsh plugin command and the Web host share, and the manager over the booted profile that enables, disables, retries, edits user-layer rows, and reports every package."
kind: "package-reference"
---
# @deepseek-ai/dsh-plugin-manager
English | [中文](README.zh.md)
## Summary
`dsh-plugin-manager` is what changes a profile's plugins, with no Remote protocol attached. `PluginInstaller` needs only the profile on disk: it runs pnpm in the profile directory, probes every package a run added in a child process, and removes again what has no place in a profile — the `dsh plugin add` command uses it before any plugin starts. `PluginManager` needs the booted tree: it moves a bundle in and out of the profile's layer list and recomposes the tree through `profileRuntime`, composes a bundle again when its rows failed, adds and removes rows in the profile's global user layer or one agent preset's, says what disabling a package would strand, and folds the manifest, the probe record, and the live tree into one view per package. Every refusal or failure is a `PluginOperationError` carrying a `plugins/*` code; the Web host's [`dsh-host-plugin-manager`](../../host/plugin-manager/README.md) exposes the manager as the `plugins` Remote and turns each failure into a Remote error of the same code.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
### Installing without booting
Build a `PluginInstaller` from the profile directory, the install anchor (the dsh app's `package.json`), a `loadProfile` that answers the profile's composed layers, the tooling bounds, and a sink for pnpm's output; then `add(spec)` or `remove(name)`:
```ts
const installer = new PluginInstaller({
profileDir, profileName: 'web', installAnchor,
loadProfile: () => loadProfile('dsh', 'web', installAnchor, undefined, { userLayer: false }),
config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 },
installLog: (chunk) => process.stdout.write(chunk.text),
})
const outcome = await installer.add('@acme/dsh-sql-tool')
```
`add` takes a pnpm spec — a registry name, a `github:` or git URL, a tarball, an absolute path — runs `pnpm add`, records what pnpm wrote to `dependencies`, and probes every new package. A successful `pnpm add` is not yet an installed plugin: a package that declares neither a bundle nor a plugin module, or a bundle whose row id a composed layer already owns, is removed again with `pnpm remove` and listed under `removed` with the reason; a package the probe refused stays installed for a view to explain. New bundles are left disabled and listed under `installedOnly`, so the caller decides whether to enable them — the CLI always does, the Web host only when asked. A non-zero exit, a spawn failure, or the timeout fails the call with `plugins/install-failed` and the tail of the log, and the profile manifest is restored to what it was before the run.
### Managing the booted profile
Build a `PluginManager` over the Cordis context and readers for what it needs per call — the profile runtime, the preset roster's layers, and the running-agent count — so a composition that gains or lacks one of them is answered at call time rather than at mount:
```ts
const manager = new PluginManager(ctx, {
config,
runtime: () => ctx.get('profileRuntime'),
presets: () => ctx.get('agentPresets'),
runningAgents: () => (ctx.get('agents')?.list() ?? []).filter(agent => agent.status === 'running').length,
})
```
`list` returns one view per package the profile knows: its template and installed bundles and every other installed dependency. A view carries the manifest facts (name, version, title, description, `engines.dsh`), what the package is (`bundle`, `plugin`, or `library`), who supplied it (`builtin` or `external`), when its rows mount (`boot` or `runtime`), whether it is installed and enabled, and a folded `status`: `running`, `partial`, or `failed` for an enabled bundle by how many of its rows are active; `disabled` for an installed bundle outside the layer list; `not-enableable` when the probe refused it, with the reason; `restart-required` when the manifest and the live tree disagree on a profile that applies changes at its next start; `plain` for a library or plugin module, which is added to a composition rather than enabled. Rows come from the live tree while the bundle is composed — phase, disabled-by, and the recorded failure of an isolated row — and from the probe record otherwise, under the ids their patches declare. `addable` lists the modules the package declares in `dsh.plugins`, each with its default config and the probe's verdict.
`add` is the installer's `add` guarded by the running-agent count, with `enable` putting every newly installed bundle into the layer list at once. `enable` puts an installed bundle into the layer list and, on a live profile, recomposes the tree with it through the profile runtime. The recomposition is the Loader's own transaction: a bundle the tree rejects — a `boot`-stage bundle whose row throws — rolls back, the layer list is restored, and the call fails with `plugins/enable-failed` naming the reason, while the tree that was running keeps running. A `runtime`-stage bundle whose row fails is isolated instead: the call succeeds, the view reports the row's failure, and `retry` composes the bundle again from scratch. `disable` is the reverse; a template bundle, which is not a dependency, cannot be disabled. On a profile whose `patchReload` is `startup`, both write the manifest and report `effect: 'restart'`. `uninstall` disables the bundle when enabled, drops every user-layer row that names one of the package's modules, runs `pnpm remove`, and forgets the probe record.
`addRow` inserts a row naming one of the package's modules — its main export for a `plugin` package, or a `dsh.plugins` entry — into the profile's global `cordis.patch.yml` (`target: { kind: 'global' }`) or an agent preset's user layer (`{ kind: 'preset', preset }`, through the roster's `overlayPathFor`). The row id derives from the package name and subpath unless given; a taken id fails with `plugins/row-conflict`. `removeRow` removes an inserted row and `setRowDisabled` writes or removes a `disabled: true` for any row — deny-only, so a bundle's own `!!js` gate is restored rather than overridden. The global layer is recomposed live on the spot; a preset's layer reaches its next standing generation. `dependents` says what disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules.
The manager runs one mutation at a time — a second call while one runs fails with `plugins/busy` naming the operation in flight — and `add` and `uninstall` refuse to change `node_modules` while a session is running, with `plugins/agents-running`. Every change is followed by a `plugins/changed` event on the context, and an install run emits pnpm's output as `plugins/install-log` chunks.
### Failures
Every refusal or failure is a `PluginOperationError` with a stable `code` and `details` typed by it: `plugins/unavailable` (no profile runtime, or no roster for a preset target), `plugins/not-installed`, `plugins/not-enableable`, `plugins/enable-failed`, `plugins/install-failed`, `plugins/row-conflict`, `plugins/busy`, `plugins/agents-running`, and `plugins/bad-request` for a request that names nothing the profile has. `pluginOperationFailureOf` narrows a caught value to the code-discriminated union.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
### One manifest, two writers
Every change reads the profile manifest afresh and writes it through the same app-boot helpers the `dsh plugin` command's forwarded verbs use (`reconcileInstalledBundles`, `enableBundle`, `disableBundle`), so the CLI and the Web host never disagree on the file. `dependencies` records what is installed; `dsh.profile.bundles` records what is enabled.
### pnpm runs through `node:child_process`
The subprocess seam scrubs secret-shaped variables and has no shell mode, and pnpm needs both the user's registry, proxy, and auth settings and, on Windows, the shell that resolves its `.cmd` shim. The installer therefore spawns pnpm the way the CLI always did, with the parent environment and `shell` on Windows, and streams the child's output itself.
### Retry is disable then enable
The Loader's transactional update leaves an unchanged row alone, so a failed isolated row would not restart on a plain recomposition. Retry takes the bundle out of the layer list and puts it back: two recompositions, and the manifest ends as it began.
### What the manager reads and what it is handed
The manager reads the Loader tree, the reflect store, and the `pluginFailures` registry through the context it is built over. Everything that belongs to another package — the profile runtime, the preset roster, the agent registry — arrives through `PluginManagerOptions` as a reader called per call, and the roster only as `PresetLayers`: the layer path, the preset list, and the composition rows the row operations need. The package therefore depends on app-boot and the patch-file writer and on nothing that composes presets or agents.
### Source map
| 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/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. |
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
Read these when the manager's contract is not enough: the runtime it drives, the files it edits, and the surfaces that call it.
- [App boot](../app-boot/README.md) — the profile runtime, external bundle isolation, and the package probe.
- [Patch files](../../util/patch-file/README.md) — how user-layer rows are written.
- [Agent presets](../../preset/agent-presets/README.md) — the per-preset user layer a preset target writes.
- [Host plugin manager](../../host/plugin-manager/README.md) — the `plugins` Remote over this manager.
- [dsh app](../../../apps/cli/README.md) — the `dsh plugin` command over the installer.
-----
<a id="model-experience"></a>
## Model Experience
None, as plugin management registers nothing model-facing; the rows it composes own every registration they make.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
These limits define what the manager will not do for a caller. They are current package constraints, not a task backlog.
- **Updating a loaded package needs a restart** — Node caches ESM modules by URL and a hoisted install keeps the path; `pnpm update` through `add` rewrites the files but the running tree keeps the old modules until the process restarts.
- **Dependents stop at injection** — a registry-type dependency (a tool, an LLM adapter) has no `inject` edge, so `dependents` cannot name a row that only reads what the package registered.
- **A preset row is not composed live** — the manager writes the preset's layer; sessions created afterwards compose it, sessions already running keep their generation.
- **No `engines.dsh` check yet** — the range is reported, not enforced against the running harness version.
- **One process at a time** — the mutex is in-process and the patch-file writer takes a file lock, but the profile manifest has no lock: the CLI and a running Web host editing the same profile at once is not supported.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
+149
View File
@@ -0,0 +1,149 @@
---
description: "面向 dsh profile 的插件管理:dsh plugin 命令与 Web 宿主共用的安装器,以及在已启动 profile 上启用、停用、重试、编辑用户层的行并报告每个包的管理器。"
kind: "package-reference"
---
# @deepseek-ai/dsh-plugin-manager
[English](README.md) | 中文
## 概述
`dsh-plugin-manager` 是改变一个 profile 的插件的地方,不带任何 Remote 协议。`PluginInstaller` 只需要磁盘上的 profile:在 profile 目录运行 pnpm,在子进程里探测一次运行新增的每个包,并把在 profile 里没有位置的包再移除——`dsh plugin add` 命令在任何插件启动之前就用它。`PluginManager` 需要已启动的树:把组合包移入或移出 profile 的层列表并经 `profileRuntime` 重新组合树,在组合包的行失败时重新组合它,向 profile 的全局用户层或某个 agent preset 的用户层添加与移除行,说明停用一个包会搁浅什么,并把 manifest、探针记录与在线树折叠成每个包一份视图。每次拒绝或失败都是一个带 `plugins/*` 码的 `PluginOperationError`;Web 宿主的 [`dsh-host-plugin-manager`](../../host/plugin-manager/README.zh.md) 把管理器暴露为 `plugins` Remote,并把每个失败转成同码的 Remote 错误。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
### 不启动也能安装
用 profile 目录、安装锚点(dsh 应用的 `package.json`)、一个回答 profile 已组合层列表的 `loadProfile`、工具边界,以及 pnpm 输出的去处构造 `PluginInstaller`,然后 `add(spec)` 或 `remove(name)`:
```ts
const installer = new PluginInstaller({
profileDir, profileName: 'web', installAnchor,
loadProfile: () => loadProfile('dsh', 'web', installAnchor, undefined, { userLayer: false }),
config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 },
installLog: (chunk) => process.stdout.write(chunk.text),
})
const outcome = await installer.add('@acme/dsh-sql-tool')
```
`add` 接受一个 pnpm spec——registry 名字、`github:` 或 git URL、tarball、绝对路径——运行 `pnpm add`,记录 pnpm 写进 `dependencies` 的内容,并探测每个新包。`pnpm add` 成功还不等于装好了插件:既不声明组合包也不声明插件模块的包,或者某个行 id 已被已组合层占有的组合包,会再以 `pnpm remove` 移除并连同原因列在 `removed` 里;探针拒绝的包保留在原处,留给视图说明。新组合包保持停用并列在 `installedOnly` 里,由调用方决定是否启用——CLI 一律启用,Web 宿主只在被要求时启用。非零退出、spawn 失败或超时都以 `plugins/install-failed` 与日志尾部让调用失败,并把 profile manifest 恢复到运行前的样子。
### 管理已启动的 profile
在 Cordis 上下文之上构造 `PluginManager`,并交给它按调用读取所需之物的读取器——profile runtime、preset roster 的层、运行中的 agent 数——这样一个后来才有或始终没有其中之一的组合在调用时得到回答,而不是在挂载时:
```ts
const manager = new PluginManager(ctx, {
config,
runtime: () => ctx.get('profileRuntime'),
presets: () => ctx.get('agentPresets'),
runningAgents: () => (ctx.get('agents')?.list() ?? []).filter(agent => agent.status === 'running').length,
})
```
`list` 为 profile 知道的每个包返回一份视图:模板组合包与已安装的组合包,以及其他每个已安装的依赖。视图携带 manifest 事实(名字、版本、标题、描述、`engines.dsh`),这个包是什么(`bundle`、`plugin` 或 `library`),谁提供它(`builtin` 或 `external`),它的行何时挂载(`boot` 或 `runtime`),是否已安装与已启用,以及折叠出的 `status`:已启用的组合包按活跃行的多少是 `running`、`partial` 或 `failed`;已安装但不在层列表中的组合包是 `disabled`;探针拒绝时是 `not-enableable` 并附原因;在启动时才应用变更的 profile 上 manifest 与在线树不一致时是 `restart-required`;库或插件模块是 `plain`,它们被添加进组合而不是被启用。组合包已组合时行来自在线树——阶段、被谁停用,以及隔离行记录的失败——否则来自探针记录,id 保持各自 patch 声明的样子。`addable` 列出包在 `dsh.plugins` 里声明的模块,各自带默认配置与探针的判定。
`add` 就是安装器的 `add` 加上运行中 agent 数的守卫,`enable` 让每个新装的组合包一并进入层列表。`enable` 把已安装的组合包放进层列表,并在 live profile 上经 profile runtime 带着它重新组合树。这次重新组合就是 Loader 自己的事务:树拒绝的组合包——`boot` 阶段而行抛错的组合包——回滚,层列表恢复,调用以点名原因的 `plugins/enable-failed` 失败,而原本运行的树继续运行。`runtime` 阶段而行失败的组合包则被隔离:调用成功,视图报告该行的失败,`retry` 从头重新组合它。`disable` 是反向操作;模板组合包不是依赖,无法停用。在 `patchReload` 为 `startup` 的 profile 上,两者只写 manifest 并报告 `effect: 'restart'`。`uninstall` 在组合包已启用时先停用它,删除每一条点名该包模块的用户层行,运行 `pnpm remove`,并忘掉探针记录。
`addRow` 把一条点名该包某个模块的行——`plugin` 包的主导出,或某个 `dsh.plugins` 条目——插入 profile 的全局 `cordis.patch.yml`(`target: { kind: 'global' }`)或某个 agent preset 的用户层(`{ kind: 'preset', preset }`,经 roster 的 `overlayPathFor`)。行 id 未给出时由包名与子路径派生;已被占用的 id 以 `plugins/row-conflict` 失败。`removeRow` 移除一条插入的行,`setRowDisabled` 为任意行写入或移除 `disabled: true`——只写拒绝,因此组合包自己的 `!!js` 门被恢复而不是被覆盖。全局层当场在线重新组合;preset 的层在其下一个常驻代际生效。`dependents` 说明停用或移除一个包会搁浅什么:其行提供而包外的行注入的服务,以及点名其模块的用户层行。
管理器一次只跑一个变更——上一个还在跑时再调用会以 `plugins/busy` 失败并点名正在进行的操作——`add` 与 `uninstall` 在有会话运行时拒绝改动 `node_modules`,报 `plugins/agents-running`。每次变更之后在上下文上发出 `plugins/changed` 事件,安装运行把 pnpm 的输出以 `plugins/install-log` 分块发出。
### 失败
每次拒绝或失败都是一个 `PluginOperationError`,带稳定的 `code` 与按码定型的 `details`:`plugins/unavailable`(没有 profile runtime,或 preset 目标没有 roster)、`plugins/not-installed`、`plugins/not-enableable`、`plugins/enable-failed`、`plugins/install-failed`、`plugins/row-conflict`、`plugins/busy`、`plugins/agents-running`,以及请求点名了 profile 没有的东西时的 `plugins/bad-request`。`pluginOperationFailureOf` 把捕获到的值收窄为按码区分的联合。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内幕——点击展开</summary>
### 一份 manifest,两个写入者
每次变更都重新读取 profile manifest,并通过 `dsh plugin` 命令转发的动词所用的同一组 app-boot 助手(`reconcileInstalledBundles`、`enableBundle`、`disableBundle`)写回,因此 CLI 与 Web 宿主对这个文件永远不会有分歧。`dependencies` 记录装了什么;`dsh.profile.bundles` 记录启用了什么。
### pnpm 经 `node:child_process` 运行
subprocess seam 会清洗形似密钥的变量且没有 shell 模式,而 pnpm 既需要用户的 registry、代理与鉴权设置,在 Windows 上又需要解析其 `.cmd` shim 的 shell。于是安装器按 CLI 一直以来的方式生成 pnpm:带父进程环境、Windows 上开 `shell`,并自己流式读取子进程的输出。
### 重试即先停用再启用
Loader 的事务性更新不会碰未改变的行,因此一条失败的隔离行在普通的重新组合中不会重新启动。重试把组合包移出层列表再放回去:两次重新组合,manifest 首尾如一。
### 管理器自己读什么,别人交给它什么
管理器经它所在的上下文读取 Loader 树、reflect store 与 `pluginFailures` 注册表。属于别的包的东西——profile runtime、preset roster、agent 注册表——都经 `PluginManagerOptions` 以按调用读取的读取器交进来,roster 只以 `PresetLayers` 的形态交进来:层文件路径、preset 列表,以及行操作需要的组合行。因此本包只依赖 app-boot 与补丁文件写入器,不依赖任何组合 preset 或 agent 的包。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | `PluginInstaller`(pnpm 运行、探测、装后检查)与 `PluginManager`(已启动 profile 上的每项操作、互斥与视图折叠) |
| [`src/types.ts`](src/types.ts) | 载荷、`plugins/changed` 与 `plugins/install-log` 事件,以及 `plugins/*` 失败码及其 details |
| [`src/errors.ts`](src/errors.ts) | `PluginOperationError` 与按码区分的失败联合 |
| — | 不发布运行时不变量伴随件;每份视图都在每次调用时从 manifest、探针缓存与 Loader 持有的状态折叠而来。 |
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
当管理器的契约还不够时读这些:它驱动的运行时、它编辑的文件,以及调用它的表面。
- [App boot](../app-boot/README.zh.md)——profile runtime、外部组合包隔离与包探针。
- [补丁文件](../../util/patch-file/README.zh.md)——用户层的行如何写入。
- [Agent presets](../../preset/agent-presets/README.zh.md)——preset 目标所写的每预设用户层。
- [宿主插件管理器](../../host/plugin-manager/README.zh.md)——本管理器之上的 `plugins` Remote。
- [dsh 应用](../../../apps/cli/README.zh.md)——安装器之上的 `dsh plugin` 命令。
-----
<a id="model-experience"></a>
## 模型体验
无,插件管理不注册任何面向模型的东西;它组合出的行各自拥有自己做出的注册。
#### KV Cache 影响
无;本包既不组装也不发送 provider 请求。
## 已知限制与延期工作
<a id="known-limitations-and-deferred-work"></a>
这些限制界定管理器不会为调用方做什么。它们是当前包的约束,不是任务清单。
- **更新已加载的包需要重启**——Node 按 URL 缓存 ESM 模块,hoisted 安装下路径不变;经 `add` 做的 `pnpm update` 改写了文件,但运行中的树在进程重启前一直用旧模块。
- **依赖检测止于注入**——注册型依赖(工具、LLM 适配器)没有 `inject` 边,因此 `dependents` 无法点名只读取该包所注册内容的行。
- **preset 的行不在线组合**——管理器写入 preset 的层;之后创建的会话组合它,已在运行的会话保持其代际。
- **尚无 `engines.dsh` 检查**——该范围只被报告,不对运行中的 harness 版本强制执行。
- **一次只有一个进程**——互斥在进程内,补丁文件写入器持文件锁,但 profile manifest 没有锁:CLI 与运行中的 Web 宿主同时编辑同一个 profile 不受支持。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作上下文——点击展开</summary>
无。
</details>
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-plugin-manager",
"description": "Plugin management over a dsh profile: install, enable, disable, retry, user-layer rows, dependents, and one view per package, shared by the dsh plugin command and the Web host",
"version": "0.1.3-alpha.1",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/boot/plugin-manager"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-package-manifest": "workspace:^",
"@deepseek-ai/dsh-patch-file": "workspace:^",
"@deepseek-ai/dsh-util-values": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-package-manifest": "workspace:^",
"@deepseek-ai/dsh-patch-file": "workspace:^",
"@deepseek-ai/dsh-util-values": "workspace:^"
}
}
@@ -0,0 +1,48 @@
/**
* The failure a plugin operation raises: a code from the `plugins/*`
* vocabulary and details typed by that code, with no Remote protocol
* attached. The Web host's adapter turns one into a `RemoteError` of the same
* code; the `dsh plugin` command prints it.
* @module @deepseek-ai/dsh-plugin-manager/errors
*/
import type { PluginOperationCode, PluginOperationDetailsMap } from './types.ts'
/**
* One refused or failed plugin operation. Discriminate by `code`: the
* {@link PluginOperationFailure} union narrows `details` per code.
*/
export class PluginOperationError<Code extends PluginOperationCode = PluginOperationCode> extends Error {
/** The stable failure code declared in {@link PluginOperationDetailsMap}. */
readonly code: Code
/** The structured payload the code declares. */
readonly details: PluginOperationDetailsMap[Code]
/**
* @param code - the stable failure code.
* @param message - human diagnostic.
* @param details - structured payload typed by the code.
* @param options - standard Error options; `cause` keeps the underlying failure.
*/
constructor(code: Code, message: string, details: PluginOperationDetailsMap[Code], options?: ErrorOptions) {
super(message, options)
this.name = 'PluginOperationError'
this.code = code
this.details = details
}
}
/** A plugin operation failure as the code-discriminated union, so a `code` branch narrows `details`. */
export type PluginOperationFailure = {
[Code in PluginOperationCode]: PluginOperationError<Code>
}[PluginOperationCode]
/**
* Narrow a caught value to a plugin operation failure.
* @param value - a caught value.
* @returns the failure when `value` is one, otherwise undefined.
*/
export function pluginOperationFailureOf(value: unknown): PluginOperationFailure | undefined {
return value instanceof PluginOperationError ? value as PluginOperationFailure : undefined
}
File diff suppressed because it is too large. Load diff
+249
View File
@@ -0,0 +1,249 @@
/**
* Payloads, events, and failure codes of plugin management. Every value here
* is lossless JSON: the Web host's `plugins` Remote carries them unchanged
* and the `dsh plugin` command prints them.
* @module @deepseek-ai/dsh-plugin-manager/types
*/
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
/** What an installed package is: a bundle layer, a plugin module, or a plain library. */
export type PluginPackageKind = 'bundle' | 'plugin' | 'library'
/** Who supplied a package: the installation's own bundles, or a dependency the user installed. */
export type PluginPackageTrust = 'builtin' | 'external'
/** When a bundle's rows mount: isolated at `runtime` (the default), or with the built-in rows at `boot`. */
export type PluginPackageStage = 'boot' | 'runtime'
/**
* The state one package is in, folded from the profile manifest, the probe
* record, and the live tree:
*
* - `running`: enabled and every row active;
* - `partial`: enabled with at least one row failed or waiting;
* - `failed`: enabled and no row active;
* - `disabled`: installed and not in the layer list;
* - `not-enableable`: installed but the probe refused it (import failure, foreign cordis copy);
* - `restart-required`: its manifest state and the live tree disagree, which a profile without live reload resolves at the next start;
* - `plain`: a library or plugin module, which is added to a composition rather than enabled.
*/
export type PluginPackageStatus =
| 'running'
| 'partial'
| 'failed'
| 'disabled'
| 'not-enableable'
| 'restart-required'
| 'plain'
/** Lifecycle phase of a row's root fiber; null when the row has no live fiber. */
export type PluginRowPhase = 'pending' | 'loading' | 'active' | 'failed' | 'unloading' | null
/** One row a bundle contributes to the host tree, as the tree runs it. */
export interface PluginPackageRowView {
/** The row's tree-wide id, as `Entry.id` spells it (`include:<row id>` under the root include). */
readonly entryId: string
/** The row id as the composition declares it — what a user patch or a row action targets. */
readonly rowId: string
/** Module specifier the row names. */
readonly moduleName: string
/** Effective enablement, including a disabled owning group. */
readonly enabled: boolean
/** Present exactly when `enabled` is false. */
readonly disabledBy?: 'user' | 'composition'
/** Root-fiber phase, or null when the row has no live fiber. */
readonly phase: PluginRowPhase
/** The recorded startup failure of an isolated row, when one is recorded. */
readonly failure?: { readonly stage: string; readonly message: string }
}
/** One agent-plane module a package declares addable to a composition. */
export interface PluginPackageAddableView {
/** The module as it is named in a row: the bare package for `.`, else `<package>/<subpath>`. */
readonly moduleName: string
/** The `dsh.plugins[].name` the package declared. */
readonly declaredName: string
/** Display title, when declared. */
readonly title?: string
/** Default row config, when declared. */
readonly config?: JsonValue
/** Whether the probe imported the module and found a plugin. */
readonly ok: boolean
/** The probe's import failure, when `ok` is false. */
readonly error?: string
/** The module's `Config` schema envelope, when it declares one. */
readonly configSchema?: JsonValue
}
/** One package as the manager sees it. */
export interface PluginPackageView {
/** The package name; also the row-id prefix and group id of an isolated bundle. */
readonly name: string
/** The installed version, when the manifest declares one. */
readonly version?: string
/** Display title from `dsh.title`, when declared. */
readonly title?: string
/** Free text from the manifest. */
readonly description?: string
readonly kind: PluginPackageKind
readonly trust: PluginPackageTrust
readonly stage: PluginPackageStage
/** Whether the package is a profile dependency (installed by `dsh plugin add` or the manager). */
readonly installed: boolean
/** Whether the bundle is in the profile's layer list. */
readonly enabled: boolean
readonly status: PluginPackageStatus
/** Why the package cannot be enabled, or why it is not running, when the status calls for one. */
readonly reason?: string
/** The harness version range the package declares in `engines.dsh`. */
readonly enginesDsh?: string
/** Whether the package shares the harness's cordis copy; null when the probe could not tell. */
readonly cordisSameCopy: boolean | null
/** The rows the bundle contributes, from the live tree when enabled, else from the probe. */
readonly rows: readonly PluginPackageRowView[]
/** Ids of built-in rows the bundle's patch overrides. */
readonly overrides: readonly string[]
/** Agent-plane modules the package declares addable. */
readonly addable: readonly PluginPackageAddableView[]
/** ISO time of the probe record this view was folded from, when one exists. */
readonly probedAt?: string
/** Whether the profile applies user patch files while running; false means changes wait for a restart. */
readonly liveReload: boolean
}
/** Where a composition row is added or edited: the profile's global user layer, or one preset's. */
export type PluginRowTarget =
| { readonly kind: 'global' }
| { readonly kind: 'preset'; readonly preset: string }
/** A package pnpm installed that the run removed again, with the check it failed. */
export interface PluginInstallRejection {
/** The package name. */
readonly name: string
/** Why it was removed: not a dsh package, or a row id another layer already owns. */
readonly reason: string
}
/** What one install run changed. */
export interface PluginInstallResult {
/** Dependencies present after the run and absent before it that passed the post-install checks, by name. */
readonly installed: readonly string[]
/** Dependencies pnpm added that the run removed again, each with its reason. */
readonly removed: readonly PluginInstallRejection[]
/** Bundles newly enabled, when the caller asked for it. */
readonly enabled: readonly string[]
/** Newly installed bundles left disabled. */
readonly installedOnly: readonly string[]
/** Newly installed dependencies that declare no bundle. */
readonly plain: readonly string[]
/** The identifier the run's `plugins/install-log` chunks carried. */
readonly jobId: string
}
/** What enabling a package did. */
export interface PluginEnableResult {
/** Whether the layer list changed. */
readonly changed: boolean
/** `live` when the tree was recomposed, `restart` when the profile applies changes at its next start. */
readonly effect: 'live' | 'restart'
}
/** One row outside a package that injects a service one of its rows provides. */
export interface PluginServiceDependent {
/** The service name. */
readonly service: string
/** The package row providing it. */
readonly providedBy: string
/** Rows outside the package whose fiber injects it. */
readonly injectedBy: readonly string[]
}
/** One user-layer row that names a module of the package. */
export interface PluginRowReference {
readonly target: PluginRowTarget
readonly rowId: string
readonly moduleName: string
}
/** What disabling or uninstalling a package would strand. */
export interface PluginDependents {
readonly services: readonly PluginServiceDependent[]
readonly references: readonly PluginRowReference[]
}
/** Where a row was added. */
export interface PluginRowAddition {
readonly target: PluginRowTarget
/** The id the row was given. */
readonly rowId: string
/** The user layer file the row was written to. */
readonly file: string
}
/** Why the manager changed something, for a listener deciding what to refresh. */
export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable' | 'retry' | 'row'
/** One chunk of an install run's output. */
export interface PluginInstallLogChunk {
/** The run the chunk belongs to. */
readonly jobId: string
/** The package spec the run installs or removes. */
readonly spec: string
readonly stream: 'stdout' | 'stderr'
readonly text: string
/** Present on the run's last chunk, with pnpm's exit code (null for a signal). */
readonly exitCode?: number | null
}
/**
* The failure vocabulary of plugin operations: each code with the details a
* failure of that code carries. The Web host declares the same entries under
* its Remote failure map, so a client sees the codes unchanged.
*/
export interface PluginOperationDetailsMap {
/** No profile runtime is composed, so there is no profile to manage. */
'plugins/unavailable': { readonly reason: string }
/** The package is not a profile dependency. */
'plugins/not-installed': { readonly packageName: string }
/** The package cannot be enabled or added, with the probe's reason. */
'plugins/not-enableable': { readonly packageName: string; readonly reason: string }
/** Enabling composed the bundle and the tree rejected it; the layer list was restored. */
'plugins/enable-failed': { readonly packageName: string; readonly reason: string }
/** pnpm exited non-zero, could not be spawned, or timed out. */
'plugins/install-failed': { readonly spec: string; readonly exitCode: number | null; readonly log: string }
/** The row id is already taken in the target user layer. */
'plugins/row-conflict': { readonly rowId: string; readonly target: PluginRowTarget }
/** Another mutation is still running; the manager runs one at a time and refuses rather than queues. */
'plugins/busy': {
readonly operation: string
readonly subject: string
readonly active: { readonly operation: string; readonly subject: string }
}
/** `node_modules` cannot change while a session runs; `running` counts the agents in `running` status. */
'plugins/agents-running': { readonly operation: string; readonly running: number }
/** The request names nothing the profile has: an empty spec, a template bundle, a row the layer does not insert. */
'plugins/bad-request': {}
}
/** Every plugin operation failure code. */
export type PluginOperationCode = keyof PluginOperationDetailsMap
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* The manager changed what is installed, enabled, or composed.
* @mode emit
* @param change - why, and which package when one is concerned.
*/
'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void
/**
* One chunk of an install run's output, in order; the last chunk carries the exit code.
* @mode emit
* @param chunk - the chunk.
*/
'plugins/install-log'(chunk: PluginInstallLogChunk): void
}
}
export {}
@@ -0,0 +1,902 @@
/**
* The plugin manager over a real profile: a temporary harness home with one
* profile, packages staged the way pnpm leaves them, the host tree booted
* through `boot()` with the profile runtime the launcher provides, and a
* fake pnpm that edits the profile the way the real one does. The manager
* is built the way the Web host's adapter builds it, reading the runtime,
* the roster, and the agent registry off the context per call.
*/
import { EventEmitter } from 'node:events'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import type { ChildProcess } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
import { Context, type Plugin } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import {
boot, composeProfileStack, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry,
type ComposedStack, type Profile, type probePackage,
} from '@deepseek-ai/dsh-app-boot'
import {
PluginManager, type PluginInstallLogChunk, type PluginToolingConfig, type SpawnLike,
} from '@deepseek-ai/dsh-plugin-manager'
import type {} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-presets'
const NAME = 'dsh-test'
/** A complete tooling config: the host's schema fills these defaults at load, the type does not. */
function managerConfig(overrides: Partial<PluginToolingConfig> = {}): PluginToolingConfig {
return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384, ...overrides }
}
/** Test seams: the child spawner and the package probe. */
interface Internals {
spawn?: SpawnLike
probe?: typeof probePackage
}
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
/** Builtins the staged bundles name; registered on every boot. */
const good: Plugin.Function = () => {}
const throws: Plugin.Function = () => { throw new Error('boom at apply') }
let flakyCalls = 0
const flaky: Plugin.Function = () => {
flakyCalls += 1
if (flakyCalls === 1) throw new Error('flaky first start')
}
const provider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('fixtureSvc', { ready: true })) }
const lonelyProvider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('lonelySvc', { ready: true })) }
const consumer: Plugin.Object = { inject: ['fixtureSvc'], apply() {} }
const prepare = (ctx: Context): void => {
ctx.loader.builtins.good = good
ctx.loader.builtins.throws = throws
ctx.loader.builtins.flaky = flaky
ctx.loader.builtins.provider = provider
ctx.loader.builtins['lonely-provider'] = lonelyProvider
ctx.loader.builtins.consumer = consumer
}
interface StagedHome {
home: string
profileDir: string
anchor: string
}
/** A harness home with one empty live profile and an install anchor that carries nothing. */
async function stageHome(patchReload: 'live' | 'startup' = 'live'): Promise<StagedHome> {
const home = await mkdtemp(join(tmpdir(), 'dsh-plugin-manager-'))
const profileDir = join(home, 'profiles', 'web')
mkdirSync(profileDir, { recursive: true })
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
name: 'dsh-profile-web', private: true, dependencies: {}, dsh: { profile: { bundles: [], patchReload } },
}, null, 2))
writeFileSync(join(profileDir, 'cordis.yml'), '[]\n')
const anchorDir = join(home, 'anchor')
mkdirSync(anchorDir, { recursive: true })
const anchor = join(anchorDir, 'package.json')
writeFileSync(anchor, JSON.stringify({ name: 'dsh-anchor', version: '0.0.0', dependencies: {} }))
return { home, profileDir, anchor }
}
interface StagedPackage {
/** The bundle patch text; omitted stages a bundle-less package. */
patch?: string
/** `index.js` text, exported as the package main. */
main?: string
version?: string
/** Any string: a staged manifest may declare a stage the profile refuses. */
stage?: string
plugins?: { name: string; title?: string; config?: unknown }[]
files?: Record<string, string>
}
/** Stage one package under the profile's node_modules, the way pnpm leaves it. */
function stagePackage(profileDir: string, name: string, staged: StagedPackage): void {
const dir = join(profileDir, 'node_modules', name)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name,
version: staged.version ?? '1.0.0',
description: `staged ${name}`,
type: 'module',
...staged.main === undefined ? {} : { main: 'index.js' },
dsh: {
title: `Title of ${name}`,
...staged.patch === undefined ? {} : { bundle: { patch: './cordis.patch.yml', ...staged.stage === undefined ? {} : { stage: staged.stage } } },
...staged.plugins === undefined ? {} : { plugins: staged.plugins },
},
}, null, 2))
if (staged.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), staged.patch)
if (staged.main !== undefined) writeFileSync(join(dir, 'index.js'), staged.main)
for (const [file, text] of Object.entries(staged.files ?? {})) {
mkdirSync(join(dir, file, '..'), { recursive: true })
writeFileSync(join(dir, file), text)
}
}
/** Add a dependency to the profile manifest, as `pnpm add` does. */
function addDependency(profileDir: string, name: string, spec = '1.0.0'): void {
const path = join(profileDir, 'package.json')
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record<string, string> }
manifest.dependencies[name] = spec
writeFileSync(path, JSON.stringify(manifest, null, 2))
}
function manifestOf(profileDir: string): { dependencies: Record<string, string>; dsh: { profile: { bundles: string[] } } } {
return JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as ReturnType<typeof manifestOf>
}
/** What the fake pnpm does for one invocation. */
type PnpmBehavior = (args: readonly string[]) => { code: number | null; stdout?: string; stderr?: string; hang?: boolean; error?: unknown }
/** A fake `spawn` that runs `behavior` on the next tick and reports through a child-like emitter. */
function fakePnpm(profileDir: string, behavior: PnpmBehavior, calls: string[][] = []): SpawnLike {
return (command, args, options) => {
calls.push([command, ...args])
expect(options.cwd).toBe(profileDir)
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: (signal?: string) => boolean }
child.stdout = new PassThrough()
child.stderr = new PassThrough()
let killed = false
child.kill = () => { killed = true; return true }
setTimeout(() => {
const outcome = behavior(args)
if (outcome.error !== undefined) {
child.emit('error', outcome.error)
return
}
if (outcome.stdout !== undefined) child.stdout.write(outcome.stdout)
if (outcome.stderr !== undefined) child.stderr.write(outcome.stderr)
if (outcome.hang === true) {
// Report the kill the timeout sends, as a real child would.
const poll = setInterval(() => {
if (!killed) return
clearInterval(poll)
child.emit('close', null)
}, 10)
return
}
setTimeout(() => { child.emit('close', outcome.code) }, 5)
}, 5)
return child as unknown as ChildProcess
}
}
/** The pnpm every install test shares: `add <name>` stages nothing (the test did) and records the dependency. */
function recordingPnpm(profileDir: string, calls: string[][] = []): SpawnLike {
return fakePnpm(profileDir, (args) => {
const [verb, target] = args
if (verb === 'add' && target !== undefined) {
addDependency(profileDir, target)
return { code: 0, stdout: `+ ${target} 1.0.0\n` }
}
if (verb === 'remove' && target !== undefined) {
const path = join(profileDir, 'package.json')
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record<string, string> }
const { [target]: _removed, ...remaining } = manifest.dependencies
writeFileSync(path, JSON.stringify({ ...manifest, dependencies: remaining }, null, 2))
rmSync(join(profileDir, 'node_modules', target), { recursive: true, force: true })
return { code: 0, stdout: `- ${target}\n` }
}
return { code: 1, stderr: 'unexpected pnpm invocation\n' }
}, calls)
}
interface Booted {
ctx: Context
manager: PluginManager
runtime: ProfileRuntime
changes: { reason: string; packageName?: string }[]
log: PluginInstallLogChunk[]
}
/** Boot the profile the way the launcher does, then build the manager over it. */
async function bootProfile(staged: StagedHome, internals: Internals = {}, config: Partial<PluginToolingConfig> = {}): Promise<Booted> {
const load = (): Profile => loadProfile(NAME, 'web', staged.anchor, staged.home)
const composeFor = (profile: Profile): ComposedStack => {
const stack = composeProfileStack(NAME, profile.layers, [
{ label: profile.patchPath, patches: loadOptionalPatches(NAME, profile.patchPath) ?? [] },
])
return { ...stack, patches: structuredClone(stack.patches) }
}
const profile = load()
const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile).patches, prepare)
contexts.push(ctx)
await ctx.plugin(ProfileRuntime, {
profile,
stack: composeFor(profile),
installAnchor: staged.anchor,
loadProfile: load,
compose: composeFor,
rootEntry: () => rootIncludeEntry(ctx),
readUserPatches: () => loadOptionalPatches(NAME, profile.patchPath) ?? [],
})
const changes: Booted['changes'] = []
const log: PluginInstallLogChunk[] = []
ctx.on('plugins/changed', (change) => { changes.push(change) })
ctx.on('plugins/install-log', (chunk) => { log.push(chunk) })
const manager = managerOver(ctx, internals, config)
return { ctx, manager, runtime: ctx.profileRuntime, changes, log }
}
/** The manager as the Web host's adapter builds it: runtime, roster, and agent count read off the context per call. */
function managerOver(ctx: Context, internals: Internals = {}, config: Partial<PluginToolingConfig> = {}): PluginManager {
return new PluginManager(ctx, {
config: managerConfig(config),
runtime: () => ctx.get('profileRuntime'),
presets: () => ctx.get('agentPresets'),
runningAgents: () => (ctx.get('agents')?.list() ?? []).filter(agent => agent.status === 'running').length,
...internals,
})
}
const entryIds = (ctx: Context): string[] => [...ctx.loader.entries()].map(entry => entry.id)
const BUNDLE_ONE_ROW = '- insert:\n - id: hello\n name: cordis:good\n'
describe('PluginManager', () => {
it('reports plugins/unavailable without a profile runtime', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(Loader)
await expect(managerOver(ctx).list()).rejects.toMatchObject({ code: 'plugins/unavailable', details: { reason: 'no profile runtime' } })
})
describe('list', () => {
it('folds installed, enabled, and probed facts into one view per package', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js', title: 'Extra' }], files: { 'extra.js': 'export const name = "extra"\nexport function apply() {}\n' } })
stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
addDependency(staged.profileDir, 'ext-bundle')
addDependency(staged.profileDir, 'ext-lib')
addDependency(staged.profileDir, 'ext-plugin')
const { manager } = await bootProfile(staged)
const views = await manager.list()
expect(views.map(view => [view.name, view.kind, view.status, view.installed, view.enabled, view.trust])).toEqual([
['ext-bundle', 'bundle', 'disabled', true, false, 'external'],
['ext-lib', 'library', 'plain', true, false, 'external'],
['ext-plugin', 'plugin', 'plain', true, false, 'external'],
])
const bundle = views[0]
expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', stage: 'runtime', liveReload: true })
// Rows come from the probe while the bundle is not composed, under the ids the patch declares.
expect(bundle?.rows).toEqual([{ entryId: 'hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra', ok: true }])
expect(bundle?.probedAt).toEqual(expect.any(String) as string)
expect(readProbeCache(staged.profileDir, 'ext-bundle', '1.0.0')?.kind).toBe('bundle')
})
it('reads a composed bundle\'s rows from the live tree with their failures', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-mixed', { patch: '- insert:\n - id: ok\n name: cordis:good\n - id: bad\n name: cordis:throws\n' })
addDependency(staged.profileDir, 'ext-mixed')
const manifest = manifestOf(staged.profileDir)
manifest.dsh.profile.bundles.push('ext-mixed')
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
const { manager } = await bootProfile(staged)
const [view] = await manager.list()
expect(view).toMatchObject({ name: 'ext-mixed', status: 'partial', enabled: true })
expect(view?.reason).toContain('boom at apply')
expect(view?.rows.map(row => [row.entryId, row.phase, row.failure?.stage])).toEqual([
['include:ok', 'active', undefined],
['include:bad', 'failed', 'apply'],
])
})
it('lists a bundle left out by an id conflict as failed, with the conflict as its row', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-one', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-one')
stagePackage(staged.profileDir, 'ext-two', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-two')
const manifest = manifestOf(staged.profileDir)
manifest.dsh.profile.bundles.push('ext-one', 'ext-two')
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
const { ctx, manager } = await bootProfile(staged)
const views = await manager.list()
expect(entryIds(ctx)).toContain('include:hello')
expect(views.find(view => view.name === 'ext-one')).toMatchObject({ status: 'running' })
const two = views.find(view => view.name === 'ext-two')
expect(two).toMatchObject({ status: 'failed', enabled: true })
expect(two?.reason).toContain('already declared by ext-one')
expect(two?.rows).toEqual([{
entryId: 'conflict:ext-two:hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: 'failed',
failure: { stage: 'conflict', message: 'row "hello" is already declared by ext-one' },
}])
})
it('reports a package the probe refuses as not enableable, and a stale layer as restart-required', async () => {
const staged = await stageHome('startup')
stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
addDependency(staged.profileDir, 'ext-broken')
stagePackage(staged.profileDir, 'ext-later', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-later')
const { manager } = await bootProfile(staged)
// Enabled after boot on a startup-reload profile: the manifest says yes, the tree says no.
const manifest = manifestOf(staged.profileDir)
manifest.dsh.profile.bundles.push('ext-later')
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
const views = await manager.list()
expect(views.find(view => view.name === 'ext-broken')).toMatchObject({ status: 'not-enableable', reason: expect.stringContaining('no import for you') as string })
expect(views.find(view => view.name === 'ext-later')).toMatchObject({ status: 'restart-required', liveReload: false })
})
it('reports a package whose probe cannot run with the probe\'s failure', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-odd')
stagePackage(staged.profileDir, 'ext-refused', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-refused')
const { manager } = await bootProfile(staged, {
probe: ({ packageName }) => packageName === 'ext-odd'
? Promise.reject(new Error('probe exploded'))
: Promise.resolve({ packageName, kind: 'bundle', ok: false, reason: 'foreign cordis', cordisSameCopy: false, rows: [], overrides: [], addable: [], checkedAt: 'now' }),
})
const views = await manager.list()
expect(views.find(view => view.name === 'ext-odd')).toMatchObject({ status: 'not-enableable', reason: 'probe exploded', cordisSameCopy: null, rows: [] })
expect(views.find(view => view.name === 'ext-refused')).toMatchObject({ status: 'not-enableable', reason: 'foreign cordis', cordisSameCopy: false })
await expect(manager.enable('ext-odd')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'cannot be probed: probe exploded' } })
await expect(manager.enable('ext-refused')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'foreign cordis' } })
})
it('reads a hand-written manifest: no dependencies, a template bundle, a ghost, and a builtin layer added after boot', async () => {
const staged = await stageHome('startup')
stagePackage(staged.profileDir, 'tpl', { patch: BUNDLE_ONE_ROW })
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl'], patchReload: 'startup' } } }))
const { manager } = await bootProfile(staged)
expect((await manager.list()).map(view => [view.name, view.trust, view.status, view.installed, view.rows.length])).toEqual([['tpl', 'builtin', 'running', false, 1]])
// A manifest with no dsh section at all knows no bundles.
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
expect(await manager.list()).toEqual([])
// Bundles the manifest names after boot: one staged, one that resolves to nothing.
stagePackage(staged.profileDir, 'tpl-later', { patch: BUNDLE_ONE_ROW })
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl', 'tpl-later', 'ghost'], patchReload: 'startup' } } }))
const views = await manager.list()
expect(views.map(view => [view.name, view.trust, view.kind, view.status])).toEqual([
['tpl', 'builtin', 'bundle', 'running'],
['tpl-later', 'builtin', 'bundle', 'restart-required'],
['ghost', 'builtin', 'library', 'plain'],
])
await expect(manager.uninstall('ghost')).rejects.toMatchObject({ code: 'plugins/not-installed' })
// Retrying takes the bundle out first, which a template bundle refuses.
await expect(manager.retry('ghost')).rejects.toMatchObject({ code: 'plugins/bad-request' })
})
it('folds disabled, user-disabled, and waiting rows', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-off', { patch: '- insert:\n - id: a\n name: cordis:good\n disabled: true\n - id: b\n name: cordis:good\n' })
addDependency(staged.profileDir, 'ext-off')
stagePackage(staged.profileDir, 'ext-waiting', { patch: '- insert:\n - id: w\n name: cordis:consumer\n' })
addDependency(staged.profileDir, 'ext-waiting')
const { manager } = await bootProfile(staged)
await manager.enable('ext-off')
await manager.enable('ext-waiting')
await manager.setRowDisabled({ kind: 'global' }, 'b', true)
const views = await manager.list()
const off = views.find(view => view.name === 'ext-off')
expect(off).toMatchObject({ status: 'running' })
expect(off?.rows.map(row => [row.rowId, row.enabled, row.disabledBy, row.phase])).toEqual([
['a', false, 'composition', null],
['b', false, 'user', null],
])
await manager.setRowDisabled({ kind: 'global' }, 'b', true)
const waiting = views.find(view => view.name === 'ext-waiting')
expect(waiting).toMatchObject({ status: 'failed', reason: expect.stringContaining('fixtureSvc') as string })
expect(waiting?.rows).toEqual([expect.objectContaining({ entryId: 'include:w', phase: 'pending', failure: expect.objectContaining({ stage: 'inject-pending' }) as object })])
// Another bundle's recorded failure is not this one's row.
expect(off?.rows.some(row => row.failure !== undefined)).toBe(false)
})
it('reads anonymous and gated probe rows', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-anon', { patch: '- insert:\n - name: cordis:good\n - id: gated\n name: cordis:good\n disabled: true\n' })
addDependency(staged.profileDir, 'ext-anon')
stagePackage(staged.profileDir, 'fp-bundle', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'fp-bundle')
const manifest = JSON.parse(readFileSync(join(staged.profileDir, 'package.json'), 'utf8')) as { dsh: { profile: Record<string, unknown> } }
manifest.dsh.profile.firstParty = ['fp-bundle']
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
const { manager } = await bootProfile(staged)
const views = await manager.list()
expect(views.find(view => view.name === 'ext-anon')?.rows).toEqual([
{ entryId: 'cordis:good', rowId: 'cordis:good', moduleName: 'cordis:good', enabled: true, phase: null },
{ entryId: 'gated', rowId: 'gated', moduleName: 'cordis:good', enabled: false, disabledBy: 'composition', phase: null },
])
await manager.enable('fp-bundle')
const firstParty = (await manager.list()).find(view => view.name === 'fp-bundle')
expect(firstParty).toMatchObject({ trust: 'builtin', status: 'running' })
expect(firstParty?.rows.map(row => row.entryId)).toEqual(['include:hello'])
await manager.disable('fp-bundle')
expect((await manager.list()).find(view => view.name === 'fp-bundle')?.rows.map(row => row.entryId)).toEqual(['hello'])
})
})
describe('install', () => {
it('removes a package that is neither a bundle nor a plugin module and says why', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-lib', { main: 'export const answer = 42\n' })
const calls: string[][] = []
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
const result = await manager.add('ext-lib')
expect(result).toMatchObject({
installed: [], plain: [], installedOnly: [],
removed: [{ name: 'ext-lib', reason: 'declares neither a dsh bundle nor a plugin module' }],
})
expect(calls).toEqual([['pnpm', 'add', 'ext-lib'], ['pnpm', 'remove', 'ext-lib']])
expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-lib')
expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-lib.json'))).toBe(false)
expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(false)
})
it('removes an installed bundle whose row id another layer already owns', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-one', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-one')
const manifest = manifestOf(staged.profileDir)
manifest.dsh.profile.bundles.push('ext-one')
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
stagePackage(staged.profileDir, 'ext-two', { patch: BUNDLE_ONE_ROW })
const calls: string[][] = []
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
const result = await manager.add('ext-two', { enable: true })
expect(result).toMatchObject({
installed: [], enabled: [], installedOnly: [],
removed: [{ name: 'ext-two', reason: 'row "hello" is already declared by ext-one' }],
})
expect(calls).toEqual([['pnpm', 'add', 'ext-two'], ['pnpm', 'remove', 'ext-two']])
expect(manifestOf(staged.profileDir)).toMatchObject({ dsh: { profile: { bundles: expect.not.arrayContaining(['ext-two']) as string[] } } })
expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-two')
})
it('removes an installed bundle the profile cannot resolve and says why', async () => {
const staged = await stageHome()
// The probe reads the manifest without judging the stage; resolving the
// layer is what refuses it, and that refusal is the removal's reason.
stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW, stage: 'weird' })
const calls: string[][] = []
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
const result = await manager.add('ext-odd')
expect(result).toMatchObject({
installed: [], installedOnly: [],
removed: [{ name: 'ext-odd', reason: expect.stringContaining('declares stage "weird"') as string }],
})
expect(calls).toEqual([['pnpm', 'add', 'ext-odd'], ['pnpm', 'remove', 'ext-odd']])
expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-odd')
})
it('keeps a package whose probe refused it, for the view to explain', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
const result = await manager.add('ext-broken')
expect(result).toMatchObject({ installed: ['ext-broken'], removed: [] })
expect((await manager.list()).find(view => view.name === 'ext-broken')).toMatchObject({ status: 'not-enableable' })
})
it('restores the manifest when pnpm fails after writing it', async () => {
const staged = await stageHome()
const manifestPath = join(staged.profileDir, 'package.json')
const before = readFileSync(manifestPath, 'utf8')
const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, (args) => {
addDependency(staged.profileDir, args[1] ?? 'ext-ghost')
return { code: 1, stderr: 'ERR_PNPM_FETCH_404\n' }
}) })
await expect(manager.add('ext-ghost')).rejects.toMatchObject({ code: 'plugins/install-failed' })
expect(readFileSync(manifestPath, 'utf8')).toBe(before)
})
it('refuses a second mutation while one is still running', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-slow', { patch: BUNDLE_ONE_ROW })
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const spawn: SpawnLike = (_command, args) => {
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: () => boolean }
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = () => true
void gate.then(() => {
addDependency(staged.profileDir, args[1] ?? 'ext-slow')
child.emit('close', 0)
})
return child as unknown as ChildProcess
}
const { manager } = await bootProfile(staged, { spawn })
const first = manager.add('ext-slow')
await expect(manager.enable('ext-slow')).rejects.toMatchObject({
code: 'plugins/busy', details: { operation: 'enable', active: { operation: 'add', subject: 'ext-slow' } },
})
release()
await expect(first).resolves.toMatchObject({ installed: ['ext-slow'] })
// The lock is released with the run: the refused call now goes through.
await expect(manager.enable('ext-slow')).resolves.toMatchObject({ changed: true })
})
it('refuses to change node_modules while a session is running', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-bundle')
const calls: string[][] = []
const { ctx, manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
ctx.provide('agents', { list: () => [{ status: 'running' }, { status: 'idle' }] } as never)
await expect(manager.add('ext-new')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'add', running: 1 } })
await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'uninstall' } })
expect(calls).toEqual([])
// Enabling recomposes the tree without touching node_modules.
await expect(manager.enable('ext-bundle')).resolves.toMatchObject({ changed: true })
})
it('runs pnpm add, records the dependency, probes the package, and leaves it disabled', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
const calls: string[][] = []
const { manager, changes, log } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
const result = await manager.add('github:acme/ext-new')
expect(calls).toEqual([['pnpm', 'add', 'github:acme/ext-new']])
// The fake pnpm records the spec itself as the dependency name, which
// resolves to nothing: a plain dependency whose probe cannot run.
expect(result).toEqual({ installed: ['github:acme/ext-new'], removed: [], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string })
expect(log.map(chunk => [chunk.stream, chunk.text, chunk.exitCode])).toEqual([
['stdout', '+ github:acme/ext-new 1.0.0\n', undefined],
['stdout', '', 0],
])
expect(changes).toEqual([{ reason: 'install' }])
})
it('reconciles by the installed name, and enables the new bundle when asked', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
const result = await manager.add('ext-new', { enable: true })
expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] })
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new'])
expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:bundle/ext-new', 'include:hello']))
expect(readProbeCache(staged.profileDir, 'ext-new')).toBeDefined()
expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
})
it('reports a plugin module as plain and uninstalls it', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-lib', { main: 'export function apply() {}\n' })
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
expect(await manager.add('ext-lib')).toMatchObject({ installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [], removed: [] })
expect((await manager.list()).find(view => view.name === 'ext-lib')?.status).toBe('plain')
await manager.uninstall('ext-lib')
expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(false)
})
it('installs into a manifest that declares no dependencies yet', async () => {
const staged = await stageHome()
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: [], patchReload: 'live' } } }))
stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => {
const path = join(staged.profileDir, 'package.json')
const manifest = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { 'ext-new': '1.0.0' } }))
return { code: 0 }
}) })
expect(await manager.add('ext-new')).toMatchObject({ installed: ['ext-new'], installedOnly: ['ext-new'] })
})
it('fails loud on a non-zero exit, a spawn error, a timeout, and an empty spec', async () => {
const staged = await stageHome()
const exits = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => ({ code: 1, stderr: 'ERR_PNPM_NO_MATCHING_VERSION\n' })) })
await expect(exits.manager.add('nope')).rejects.toMatchObject({
code: 'plugins/install-failed', details: { spec: 'nope', exitCode: 1, log: 'ERR_PNPM_NO_MATCHING_VERSION\n' },
})
expect(exits.log.at(-1)).toMatchObject({ exitCode: 1 })
await expect(exits.manager.add(' ')).rejects.toMatchObject({ code: 'plugins/bad-request' })
const erroringHome = await stageHome()
const erroring = await bootProfile(erroringHome, { spawn: fakePnpm(erroringHome.profileDir, () => ({ code: null, error: 'spawn pnpm ENOENT' })) })
await expect(erroring.manager.add('x')).rejects.toMatchObject({ code: 'plugins/install-failed', details: { exitCode: null } })
expect(erroring.log.some(chunk => chunk.text.includes('ENOENT'))).toBe(true)
const hangingHome = await stageHome()
const hanging = await bootProfile(hangingHome, { spawn: fakePnpm(hangingHome.profileDir, () => ({ code: null, hang: true })) })
await expect(hanging.manager.add('x')).rejects.toMatchObject({ code: 'plugins/install-failed' })
expect(hanging.log.some(chunk => chunk.text.includes('timed out'))).toBe(true)
})
it('keeps only the tail of a long log in the failure', async () => {
const staged = await stageHome()
const { manager } = await bootProfile(staged, {
spawn: fakePnpm(staged.profileDir, () => ({ code: 2, stdout: 'a'.repeat(300), stderr: 'b'.repeat(300) })),
}, { installLogTailBytes: 256 })
await expect(manager.add('x')).rejects.toMatchObject({ details: { log: 'b'.repeat(300) } })
})
})
describe('enable, disable, and retry', () => {
it('composes an installed bundle live, and takes it out again', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-bundle')
const { ctx, manager, changes } = await bootProfile(staged)
expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
expect(entryIds(ctx)).toContain('include:hello')
expect(await manager.enable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
expect(entryIds(ctx)).not.toContain('include:hello')
expect(await manager.disable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
expect(changes.map(change => [change.reason, change.packageName])).toEqual([
['enable', 'ext-bundle'], ['enable', 'ext-bundle'], ['disable', 'ext-bundle'], ['disable', 'ext-bundle'],
])
})
it('only writes the manifest on a startup-reload profile', async () => {
const staged = await stageHome('startup')
stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-bundle')
const { ctx, manager } = await bootProfile(staged)
expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-bundle'])
expect(entryIds(ctx)).not.toContain('include:hello')
expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
})
it('refuses what cannot be enabled or disabled', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
addDependency(staged.profileDir, 'ext-lib')
stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
addDependency(staged.profileDir, 'ext-broken')
const { manager } = await bootProfile(staged)
await expect(manager.enable('absent')).rejects.toMatchObject({ code: 'plugins/not-installed' })
await expect(manager.enable('ext-lib')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('declares no dsh.bundle') as string } })
await expect(manager.enable('ext-broken')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('no import for you') as string } })
// A template bundle is not a dependency and cannot be disabled.
const manifest = manifestOf(staged.profileDir)
manifest.dsh.profile.bundles.push('template')
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
await expect(manager.disable('template')).rejects.toMatchObject({ code: 'plugins/bad-request' })
await expect(manager.retry('ext-lib')).rejects.toMatchObject({ code: 'plugins/bad-request' })
})
it('restores the layer list when the tree rejects a boot-stage bundle', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-fatal', { patch: '- insert:\n - id: bad\n name: cordis:throws\n', stage: 'boot' })
addDependency(staged.profileDir, 'ext-fatal')
stagePackage(staged.profileDir, 'ext-fine', { patch: BUNDLE_ONE_ROW })
addDependency(staged.profileDir, 'ext-fine')
const { ctx, manager } = await bootProfile(staged)
await manager.enable('ext-fine')
await expect(manager.enable('ext-fatal')).rejects.toMatchObject({
code: 'plugins/enable-failed', details: { packageName: 'ext-fatal', reason: expect.stringContaining('boom at apply') as string },
})
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine'])
expect(entryIds(ctx)).toContain('include:hello')
expect(entryIds(ctx)).not.toContain('include:bad')
})
it('retries an isolated bundle by composing it again', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-flaky', { patch: '- insert:\n - id: once\n name: cordis:flaky\n' })
addDependency(staged.profileDir, 'ext-flaky')
flakyCalls = 0
const { manager, changes } = await bootProfile(staged)
await manager.enable('ext-flaky')
expect((await manager.list())[0]).toMatchObject({ status: 'failed', reason: expect.stringContaining('flaky first start') as string })
expect(await manager.retry('ext-flaky')).toEqual({ changed: true, effect: 'live' })
expect((await manager.list())[0]).toMatchObject({ status: 'running' })
expect(changes.map(change => change.reason)).toEqual(['enable', 'disable', 'enable', 'retry'])
})
})
describe('rows in user layers', () => {
it('adds, disables, re-enables, and removes a row in the live global layer', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, '@acme/ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
addDependency(staged.profileDir, '@acme/ext-plugin')
const { ctx, manager, changes } = await bootProfile(staged)
const added = await manager.addRow('@acme/ext-plugin', { kind: 'global' })
expect(added).toEqual({ target: { kind: 'global' }, rowId: 'acme/ext-plugin', file: join(staged.profileDir, 'cordis.patch.yml') })
expect(readFileSync(added.file, 'utf8')).toBe('- insert:\n - id: acme/ext-plugin\n name: "@acme/ext-plugin"\n config: {}\n')
// Composed live: the row is in the tree, though its module cannot import from the temp home.
expect(entryIds(ctx)).toContain('include:acme/ext-plugin')
await expect(manager.addRow('@acme/ext-plugin', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/row-conflict' })
await manager.setRowDisabled({ kind: 'global' }, 'acme/ext-plugin', true)
expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(true)
expect(readFileSync(added.file, 'utf8')).toContain('- id: acme/ext-plugin\n disabled: true\n')
await manager.setRowDisabled({ kind: 'global' }, 'acme/ext-plugin', false)
expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(false)
expect(readFileSync(added.file, 'utf8')).not.toContain('disabled')
await manager.removeRow({ kind: 'global' }, 'acme/ext-plugin')
expect(entryIds(ctx)).not.toContain('include:acme/ext-plugin')
await expect(manager.removeRow({ kind: 'global' }, 'acme/ext-plugin')).rejects.toMatchObject({ code: 'plugins/bad-request' })
expect(changes.map(change => change.reason)).toEqual(['row', 'row', 'row', 'row'])
})
it('detects a conflict through the layer file when the tree was not recomposed', async () => {
const staged = await stageHome('startup')
stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
addDependency(staged.profileDir, 'ext-plugin')
const { ctx, manager } = await bootProfile(staged)
await manager.addRow('ext-plugin', { kind: 'global' })
expect(entryIds(ctx)).not.toContain('include:ext-plugin')
await expect(manager.addRow('ext-plugin', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/row-conflict' })
})
it('adds a declared addable module with its default config and an explicit id, and refuses the rest', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-bundle', {
patch: BUNDLE_ONE_ROW,
plugins: [
{ name: './tools/sql.js', title: 'SQL', config: { dsn: 'sqlite://' } },
{ name: './missing.js' },
],
files: { 'tools/sql.js': 'export const name = "sql"\nexport function apply() {}\n' },
})
addDependency(staged.profileDir, 'ext-bundle')
const { manager } = await bootProfile(staged)
const added = await manager.addRow('ext-bundle', { kind: 'global' }, { module: './tools/sql.js', id: 'sql' })
expect(added.rowId).toBe('sql')
expect(readFileSync(added.file, 'utf8')).toContain('- id: sql\n name: ext-bundle/tools/sql.js\n config:\n dsn: sqlite://\n')
await expect(manager.addRow('ext-bundle', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
await expect(manager.addRow('ext-bundle', { kind: 'global' }, { module: './missing.js' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
await expect(manager.addRow('absent', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-installed' })
})
it('writes a preset\'s layer through the roster, and refuses a preset target without one', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
addDependency(staged.profileDir, 'ext-plugin')
const { ctx, manager } = await bootProfile(staged)
await expect(manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' })).rejects.toMatchObject({ code: 'plugins/unavailable' })
const overlay = join(staged.home, '.agent-presets', 'standard', 'cordis.patch.yml')
const roster = {
overlayPathFor: (id: string) => Promise.resolve(join(staged.home, '.agent-presets', id, 'cordis.patch.yml')),
compositionInventory: () => Promise.resolve([{ id: 'standard', rows: [{ entryId: 'tool-web' }] }]),
list: () => Promise.resolve([{ id: 'standard', overlayPath: existsSync(overlay) ? overlay : undefined }]),
}
ctx.provide('agentPresets', roster)
// No layer exists yet: nothing references the package.
expect((await manager.dependents('ext-plugin')).references).toEqual([])
// A preset the inventory does not know has no rows to conflict with.
const elsewhere = await manager.addRow('ext-plugin', { kind: 'preset', preset: 'other' })
expect(elsewhere.file).toBe(join(staged.home, '.agent-presets', 'other', 'cordis.patch.yml'))
const added = await manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' })
expect(added).toEqual({ target: { kind: 'preset', preset: 'standard' }, rowId: 'ext-plugin', file: overlay })
await expect(manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' }, { id: 'tool-web' })).rejects.toMatchObject({ code: 'plugins/row-conflict' })
await manager.setRowDisabled({ kind: 'preset', preset: 'standard' }, 'tool-web', true)
expect(readFileSync(overlay, 'utf8')).toBe('- insert:\n - id: ext-plugin\n name: ext-plugin\n config: {}\n- id: tool-web\n disabled: true\n')
// The preset's layer is not part of the host tree.
expect(entryIds(ctx)).not.toContain('include:ext-plugin')
const dependents = await manager.dependents('ext-plugin')
expect(dependents.references).toEqual([{ target: { kind: 'preset', preset: 'standard' }, rowId: 'ext-plugin', moduleName: 'ext-plugin' }])
await manager.removeRow({ kind: 'preset', preset: 'standard' }, 'ext-plugin')
expect(readFileSync(overlay, 'utf8')).toBe('- id: tool-web\n disabled: true\n')
})
})
describe('dependents and uninstall', () => {
it('names the services other rows inject and the user-layer rows that reference the package', async () => {
const staged = await stageHome()
// Beside the provider: a row nobody injects, and a row switched off (no fiber to read).
stagePackage(staged.profileDir, 'ext-provider', { patch: '- insert:\n - id: svc\n name: cordis:provider\n - id: lonely\n name: cordis:lonely-provider\n - id: off\n name: cordis:good\n disabled: true\n' })
addDependency(staged.profileDir, 'ext-provider')
writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), [
'- insert:',
' - name: ext-provider/anonymous.js',
' disabled: true',
' - id: ref',
' name: ext-provider/tools/x.js',
' disabled: true',
' - id: grp',
' name: cordis:group',
' group: true',
' config:',
' - id: nested-ref',
' name: ext-provider',
' disabled: true',
'',
].join('\n'))
const { manager, runtime } = await bootProfile(staged)
await manager.enable('ext-provider')
// A built-in row injecting the bundle's service, composed once the
// provider is up (a boot would refuse a row left waiting).
writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), `${readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')}- insert:\n - id: needs-svc\n name: cordis:consumer\n`)
await runtime.recompose()
const dependents = await manager.dependents('ext-provider')
expect(dependents.services).toEqual([{ service: 'fixtureSvc', providedBy: 'include:svc', injectedBy: ['include:needs-svc'] }])
expect(dependents.references.map(reference => reference.rowId)).toEqual(['ref', 'nested-ref'])
expect(dependents.references[0]).toEqual({ target: { kind: 'global' }, rowId: 'ref', moduleName: 'ext-provider/tools/x.js' })
})
it('ignores an unreadable user layer while collecting references', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
addDependency(staged.profileDir, 'ext-lib')
const { manager } = await bootProfile(staged)
writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'a: [\n')
expect((await manager.dependents('ext-lib')).references).toEqual([])
})
it('disables, drops references, removes the package, and forgets its probe', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js' }], files: { 'extra.js': 'export function apply() {}\n' } })
addDependency(staged.profileDir, 'ext-bundle')
const calls: string[][] = []
const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
await manager.enable('ext-bundle')
await manager.addRow('ext-bundle', { kind: 'global' }, { module: './extra.js' })
expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello', 'include:ext-bundle/extra.js']))
expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
await manager.uninstall('ext-bundle')
expect(calls).toEqual([['pnpm', 'remove', 'ext-bundle']])
expect(manifestOf(staged.profileDir)).toMatchObject({ dependencies: {}, dsh: { profile: { bundles: [] } } })
expect(entryIds(ctx)).not.toContain('include:hello')
expect(entryIds(ctx)).not.toContain('include:ext-bundle/extra.js')
expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
expect(changes.map(change => change.reason)).toEqual(['enable', 'row', 'disable', 'uninstall'])
await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/not-installed' })
})
})
})
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../../vendor/include"
},
{
"path": "../app-boot"
},
{
"path": "../../util/patch-file"
},
{
"path": "../../util/values"
},
{
"path": "../../util/package-manifest"
}
]
}
@@ -1403,69 +1403,62 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'pluginManager',
summary: 'The `pluginManager` service and the `plugins` Remote.',
description: 'The `pluginManager` service and the `plugins` Remote.\n\nEvery method that changes the profile reads the manifest afresh and writes it through the app-boot helpers the `dsh plugin` command uses, so the CLI and the manager never disagree on the file. The profile runtime is resolved per call: a composition without it (a test, a launcher other than the profile launcher) still mounts this service, and every call then reports `plugins/unavailable` rather than the service failing to start.',
description: 'The `pluginManager` service and the `plugins` Remote.\n\nThe row injects only the Loader; the profile runtime, the preset roster, and the agent registry are read off the context per call, so a composition without them (a test, a launcher other than the profile launcher) still mounts this service and every call then reports `plugins/unavailable` rather than the service failing to start.',
methods: [
{
signature: '@Remote(\'list\') async list(): Promise<PluginPackageView[]>',
description: 'Every package the profile knows: its template and installed bundles, and every other installed dependency.',
description: 'Every package the profile knows, one view each.',
parameters: [],
returns: 'one view per package, bundles first in layer order.',
returns: 'the views, bundles first in layer order.',
},
{
signature: '@Remote(\'add\') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>',
description: 'Install a package into the profile with pnpm, probe it, and leave it disabled unless asked otherwise. The run\'s output streams as `plugins/install-log` chunks carrying the returned `jobId`.',
parameters: [{ name: 'spec', description: 'what to install, in pnpm\'s own vocabulary: a registry name, a `github:` or git URL, a tarball, or an absolute path.' }, { name: 'options', description: '`enable` puts every newly installed bundle into the layer list at once.' }],
description: 'Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.',
parameters: [{ name: 'spec', description: 'what to install, in pnpm\'s own vocabulary.' }, { name: 'options', description: '`enable` puts every newly installed bundle into the layer list at once.' }],
returns: 'what the run installed and enabled.',
throws: ['{RemoteError} `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.'],
},
{
signature: '@Remote(\'uninstall\') async uninstall(packageName: string): Promise<void>',
description: '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.',
description: 'Remove a package from the profile with its user-layer rows and probe record.',
parameters: [{ name: 'packageName', description: 'the installed dependency to remove.' }],
throws: ['{RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero.'],
},
{
signature: '@Remote(\'enable\') async enable(packageName: string): Promise<PluginEnableResult>',
description: '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.',
description: 'Put an installed bundle into the layer list and, on a live profile, recompose the tree with it.',
parameters: [{ name: 'packageName', description: 'the installed bundle.' }],
returns: 'whether the list changed and whether the change is live.',
throws: ['{RemoteError} `plugins/not-installed`, `plugins/not-enableable` for a package that declares no bundle or whose probe refused it, or `plugins/enable-failed`.'],
},
{
signature: '@Remote(\'disable\') async disable(packageName: string): Promise<PluginEnableResult>',
description: 'Take a bundle out of the layer list and, on a live profile, recompose the tree without it.',
parameters: [{ name: 'packageName', description: 'the enabled bundle.' }],
returns: 'whether the list changed and whether the change is live.',
throws: ['{RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency.'],
},
{
signature: '@Remote(\'retry\') async retry(packageName: string): Promise<PluginEnableResult>',
description: 'Compose an enabled bundle again from scratch: its group leaves the tree and returns, so rows that failed at boot get another start.',
description: 'Compose an enabled bundle again from scratch.',
parameters: [{ name: 'packageName', description: 'the enabled bundle.' }],
returns: 'the enable outcome of the second step.',
throws: ['{RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures.'],
},
{
signature: '@Remote(\'addRow\') async addRow( packageName: string, target: PluginRowTarget, options?: { module?: string; id?: string; config?: JsonValue }, ): Promise<PluginRowAddition>',
description: '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.',
description: 'Add a row naming one of the package\'s modules to a user layer.',
parameters: [{ name: 'packageName', description: 'the installed package.' }, { name: 'target', description: 'which layer.' }, { name: 'options', description: '`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: ['{RemoteError} `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.'],
},
{
signature: '@Remote(\'removeRow\') async removeRow(target: PluginRowTarget, rowId: string): Promise<void>',
description: 'Remove a row a user layer inserted.',
parameters: [{ name: 'target', description: 'which layer.' }, { name: 'rowId', description: 'the inserted row\'s id.' }],
throws: ['{RemoteError} `gateway/bad-request` when the layer inserts no such row.'],
},
{
signature: '@Remote(\'setRowDisabled\') async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void>',
description: '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.',
description: 'Switch one row off or on in a user layer; deny-only.',
parameters: [{ name: 'target', description: 'which layer.' }, { name: 'rowId', description: 'the row\'s id as the composition declares it.' }, { name: 'disabled', description: 'whether the layer should switch the row off.' }],
},
{
signature: '@Remote(\'dependents\') async dependents(packageName: string): Promise<PluginDependents>',
description: 'What disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules.',
description: 'What disabling or removing a package would strand.',
parameters: [{ name: 'packageName', description: 'the package.' }],
returns: 'the dependents.',
},
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/README.md
README.md: 19b4debcaa3de4ca2370f8900adc34205741db82
README.zh.md: 2870048e3fcb9ddb4303a917d54160dd8b3f1e6e
README.md: b49b9fdf9e81ab1ce4ff18e4819e8186aae8689b
README.zh.md: 4a88840b7f7a5ecb582ce9e477b57c7beee3ee0b
+3 -2
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, and the read-only plugin inventory projection. All seven packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam.
The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the read-only plugin inventory projection, and the `plugins` Remote over the shared plugin manager. All eight packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam.
## Table of Contents
@@ -22,7 +22,7 @@ The `host/` group provides the web GUI's plain HTTP server, the SPA dist server
<a id="packages"></a>
## Packages
Seven packages play the host roles; each package README owns its contract and configuration.
Eight packages play the host roles; each package README owns its contract and configuration.
| Package | Role | ctx key |
|---|---|---|
@@ -33,6 +33,7 @@ Seven packages play the host roles; each package README owns its contract and co
| [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend, including for remote clients | registers `ctx.directoryPicker` |
| [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive chooser that mounts the matching backend at boot | mounts a backend |
| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` |
| [`plugin-manager/`](plugin-manager/README.md) | The `plugins` Remote: a relay onto the shared plugin manager, mapping its failures onto Remote error codes | `ctx.pluginManager`, Remote `plugins/*` |
-----
+3 -2
View File
@@ -9,7 +9,7 @@ kind: "package-group"
## 概述
`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam,以及只读的插件清单投影。这七个包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。
`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、只读的插件清单投影,以及共享插件管理器之上的 `plugins` Remote。这八个包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。
## 目录
@@ -22,7 +22,7 @@ kind: "package-group"
<a id="packages"></a>
## 包
七个包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。
八个包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。
| 包 | 职责 | ctx 键 |
|---|---|---|
@@ -33,6 +33,7 @@ kind: "package-group"
| [`directory-picker-browse/`](directory-picker-browse/README.zh.md) | 应用内目录浏览器后端,也服务于远程客户端 | 注册 `ctx.directoryPicker` |
| [`directory-picker-auto/`](directory-picker-auto/README.zh.md) | 在启动时挂载匹配后端的宿主自适应选择器 | 挂载一个后端 |
| [`plugin-inventory/`](plugin-inventory/README.zh.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` |
| [`plugin-manager/`](plugin-manager/README.zh.md) | `plugins` Remote:转接到共享插件管理器,并把其失败映射为 Remote 错误码 | `ctx.pluginManager`、Remote `plugins/*` |
-----
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/plugin-manager/README.md
README.md: fd2f5ac7da5004f5dbd202e1cde19718f8fd021d
README.zh.md: 41d5505be0ce6ada8811ba5bfdd4622fd5dd78f2
README.md: e6e966ae7d970e2c9d498b52c4938e4cd333976a
README.zh.md: c8530361bb20b421fa54cf87f9e26ea14d62f97b
+17 -39
View File
@@ -1,5 +1,5 @@
---
description: "Plugin management over the booted profile: the pluginManager service and the plugins Remote that install, enable, disable, and retry bundles, edit user-layer rows, and report every package's state."
description: "The plugins Remote of the Web host: pluginManager as a Typert service that relays each call to the shared plugin manager over the booted profile and maps its failures onto Remote error codes."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
`dsh-host-plugin-manager` is the one place that changes what a running profile is composed of. The profile launcher composes the host tree from bundle layers and user patch files and, through `profileRuntime`, can recompose it while it runs; this service drives that: it runs pnpm in the profile directory to install or remove a package, moves a bundle in and out of the profile's layer list and recomposes the tree with the outcome, composes a bundle again when its rows failed at boot, adds and removes rows in the profile's global user layer or one agent preset's, and folds the manifest, the probe record, and the live tree into one view per package. The `plugins` Remote exposes each operation; every change is followed by a `plugins/changed` event and an install run streams pnpm's output as `plugins/install-log`. Client packages consume the Remote through the [`api-remotes`](../../api/remotes/README.md) assembly.
`dsh-host-plugin-manager` is the Web host's face of plugin management. It mounts `pluginManager` as a Typert service and exposes the `plugins` Remote — `list`, `add`, `uninstall`, `enable`, `disable`, `retry`, `addRow`, `removeRow`, `setRowDisabled`, `dependents` — each method relaying to the [`dsh-plugin-manager`](../../boot/plugin-manager/README.md) `PluginManager` built over this context, and each `plugins/*` failure crossing the wire as a `RemoteError` of the same code. What the operations do, and the `plugins/changed` and `plugins/install-log` events that follow them, belong to that package; this one reads the profile runtime, the preset roster, and the agent registry off the context and hands them over. Client packages consume the Remote through the [`api-remotes`](../../api/remotes/README.md) assembly.
## Table of Contents
@@ -27,23 +27,13 @@ English | [中文](README.zh.md)
Mount the row in a host composition beside the plugin inventory; the web bundle does. The row injects only the Loader and resolves the profile runtime per call, so a composition booted without the profile launcher still starts and answers every call with `plugins/unavailable`.
### What a package view says
### The Remote
`plugins/list` returns one view per package the profile knows: its template and installed bundles and every other installed dependency. A view carries the manifest facts (name, version, title, description, `engines.dsh`), what the package is (`bundle`, `plugin`, or `library`), who supplied it (`builtin` or `external`), when its rows mount (`boot` or `runtime`), whether it is installed and enabled, and a folded `status`: `running`, `partial`, or `failed` for an enabled bundle by how many of its rows are active; `disabled` for an installed bundle outside the layer list; `not-enableable` when the probe refused it, with the reason; `restart-required` when the manifest and the live tree disagree on a profile that applies changes at its next start; `plain` for a library or plugin module, which is added to a composition rather than enabled. Rows come from the live tree while the bundle is composed — phase, disabled-by, and the recorded failure of an isolated row — and from the probe record otherwise, under the ids their patches declare. `addable` lists the modules the package declares in `dsh.plugins`, each with its default config and the probe's verdict.
`plugins/list`, `plugins/add`, `plugins/uninstall`, `plugins/enable`, `plugins/disable`, `plugins/retry`, `plugins/addRow`, `plugins/removeRow`, `plugins/setRowDisabled`, and `plugins/dependents` carry the arguments and answers the manager's methods of the same names define; the [manager README](../../boot/plugin-manager/README.md#use-this-package) documents each. The `./types` export re-exports the manager's payload types unchanged, so a client imports one vocabulary.
### Installing and enabling
### Failure codes
`plugins/add` takes a pnpm spec — a registry name, a `github:` or git URL, a tarball, an absolute path — runs `pnpm add` in the profile directory, records what pnpm wrote to `dependencies`, probes every new package in a child process, and leaves new bundles disabled unless `enable` was asked for. pnpm's output arrives as `plugins/install-log` chunks carrying the run's `jobId`; the last chunk carries the exit code. A non-zero exit, a spawn failure, or the timeout fails the call with `plugins/install-failed` and the tail of the log, and the profile manifest is restored to what it was before the run. A successful `pnpm add` is not yet an installed plugin: a new package that declares neither a bundle nor a plugin module, or a bundle whose row id a composed layer already owns, is removed again with `pnpm remove` and listed under `removed` with the reason; a package the probe refused stays installed and the view reports why it cannot be enabled. The manager runs one mutation at a time — a second call while one runs fails with `plugins/busy` naming the operation in flight — and refuses to change `node_modules` while a session is running, with `plugins/agents-running`.
`plugins/enable` puts an installed bundle into the layer list and, on a live profile, recomposes the tree with it through the profile runtime. The recomposition is the Loader's own transaction: a bundle the tree rejects — a `boot`-stage bundle whose row throws — rolls back, the layer list is restored, and the call fails with `plugins/enable-failed` naming the reason, while the tree that was running keeps running. A `runtime`-stage bundle whose row fails is isolated instead: the call succeeds, the view reports the row's failure, and `plugins/retry` composes the bundle again from scratch. `plugins/disable` is the reverse; a template bundle, which is not a dependency, cannot be disabled. On a profile whose `patchReload` is `startup`, both write the manifest and report `effect: 'restart'`.
`plugins/uninstall` disables the bundle when enabled, drops every user-layer row that names one of the package's modules, runs `pnpm remove`, and forgets the probe record. Like `add`, it waits for running sessions: `plugins/agents-running` while any agent is running.
### Rows in user layers
`plugins/addRow` inserts a row naming one of the package's modules — its main export for a `plugin` package, or a `dsh.plugins` entry — into the profile's global `cordis.patch.yml` (`target: { kind: 'global' }`) or an agent preset's user layer (`{ kind: 'preset', preset }`, through the roster's `overlayPathFor`). The row id derives from the package name and subpath unless given; a taken id fails with `plugins/row-conflict`. `plugins/removeRow` removes an inserted row and `plugins/setRowDisabled` writes or removes a `disabled: true` for any row — deny-only, so a bundle's own `!!js` gate is restored rather than overridden. The global layer is recomposed live on the spot; a preset's layer reaches its next standing generation.
`plugins/dependents` says what disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules.
A manager failure reaches the client as a `RemoteError` with the same `code` and `details`: `plugins/unavailable`, `plugins/not-installed`, `plugins/not-enableable`, `plugins/enable-failed`, `plugins/install-failed`, `plugins/row-conflict`, `plugins/busy`, and `plugins/agents-running`, each declared in the Remote failure map with the manager's details type. The manager's generic refusal, `plugins/bad-request`, crosses as the Gateway's `gateway/bad-request`. Any other error the manager throws propagates untouched, which the Gateway reports as `gateway/internal`.
### Configuration
@@ -62,25 +52,17 @@ Mount the row in a host composition beside the plugin inventory; the web bundle
<details>
<summary>Implementation internals — click to expand</summary>
### One manifest, two writers
### A relay, not a second manager
Every change reads the profile manifest afresh and writes it through the same app-boot helpers the `dsh plugin` command uses (`reconcileInstalledBundles`, `enableBundle`, `disableBundle`), so the CLI and the manager never disagree on the file. `dependencies` records what is installed; `dsh.profile.bundles` records what is enabled.
### pnpm runs through `node:child_process`
The subprocess seam scrubs secret-shaped variables and has no shell mode, and pnpm needs both the user's registry, proxy, and auth settings and, on Windows, the shell that resolves its `.cmd` shim. The manager therefore spawns pnpm the way the CLI does, with the parent environment and `shell` on Windows, and streams the child's output itself.
### Retry is disable then enable
The Loader's transactional update leaves an unchanged row alone, so a failed isolated row would not restart on a plain recomposition. Retry takes the bundle out of the layer list and puts it back: two recompositions, and the manifest ends as it began.
The constructor builds one `PluginManager` with readers into the context — `ctx.get('profileRuntime')`, `ctx.get('agentPresets')`, and the running-agent count from `ctx.get('agents')` — so what is composed is read when a call arrives, not when the row mounts. Every Remote method is `relay(() => this.manager.x(...))`: a `PluginOperationError` becomes the Remote error of its code through one exhaustive switch, and anything else is rethrown. Tests hand a manager in through `PluginManagerInternals.manager`; the spawn and probe seams pass through to the manager.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | `PluginManager`: the `pluginManager` service, the `plugins` Remote methods, the pnpm runner, and the view fold |
| [`src/types.ts`](src/types.ts) | Wire payloads, the `plugins/changed` and `plugins/install-log` events, and the `plugins/*` failure codes |
| — | No runtime invariant companion is published; every view is folded from the manifest, the probe cache, and Loader-owned state on each call. |
| [`src/index.ts`](src/index.ts) | `PluginManagerRemote`: the `pluginManager` service, the ten `plugins` Remote methods, and `remoteErrorOf` |
| [`src/types.ts`](src/types.ts) | The manager's types re-exported, and the `plugins/*` codes declared in the Remote failure map |
| — | No runtime invariant companion is published; the row holds no state of its own. |
Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
@@ -91,11 +73,10 @@ Typert generates the Host and Client Remote artifacts exposed by `./typert` and
<a id="further-exploration"></a>
## Further Exploration
Read these when the manager's contract is not enough: the runtime it drives, the files it edits, and the surface that renders it.
Read these when the relay's contract is not enough: the manager behind it and the surface that renders it.
- [App boot](../../boot/app-boot/README.md) — the profile runtime, external bundle isolation, and the package probe.
- [Patch files](../../util/patch-file/README.md) — how user-layer rows are written.
- [Agent presets](../../preset/agent-presets/README.md) — the per-preset user layer a preset target writes.
- [Plugin manager](../../boot/plugin-manager/README.md) — what every operation does, the events, and the failure codes.
- [App boot](../../boot/app-boot/README.md) — the profile runtime the manager drives.
- [Plugin inventory](../plugin-inventory/README.md) — the row-level read-only projection beside this service.
-----
@@ -103,7 +84,7 @@ Read these when the manager's contract is not enough: the runtime it drives, the
<a id="model-experience"></a>
## Model Experience
None, as the host-side plugin manager registers nothing model-facing; the rows it composes own every registration they make.
None, as the Remote registers nothing model-facing; the rows the manager composes own every registration they make.
#### KV Cache effect
@@ -114,12 +95,9 @@ None; this package neither assembles nor sends a provider request.
<a id="known-limitations-and-deferred-work"></a>
These limits define what the manager will not do for a client. They are current package constraints, not a task backlog.
These limits define what the Remote will not do for a client. They are current package constraints, not a task backlog.
- **Updating a loaded package needs a restart** — Node caches ESM modules by URL and a hoisted install keeps the path; `pnpm update` through `install` rewrites the files but the running tree keeps the old modules until the process restarts.
- **Dependents stop at injection** — a registry-type dependency (a tool, an LLM adapter) has no `inject` edge, so `dependents` cannot name a row that only reads what the package registered.
- **A preset row is not composed live** — the manager writes the preset's layer; sessions created afterwards compose it, sessions already running keep their generation.
- **No `engines.dsh` check yet** — the range is reported, not enforced against the running harness version.
- **What the manager cannot do, the Remote cannot either** — the [manager's limits](../../boot/plugin-manager/README.md#known-limitations-and-deferred-work) apply unchanged: a loaded package updates at the next restart, dependents stop at injection, a preset row composes for later sessions, `engines.dsh` is reported and not enforced.
<a id="dev-note"></a>
### Dev Note
+17 -39
View File
@@ -1,5 +1,5 @@
---
description: "面向已启动 profile 的插件管理:pluginManager 服务与 plugins Remote,负责安装、启用、停用与重试组合包、编辑用户层的行,并报告每个包的状态。"
description: "Web 宿主的 plugins Remote:pluginManager 作为 Typert 服务,把每次调用转接给已启动 profile 上的共享插件管理器,并把它的失败映射为 Remote 错误码。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
`dsh-host-plugin-manager` 是改变一个运行中 profile 由什么组成的唯一地方。profile launcher 从组合包层与用户补丁文件组合出宿主树,并经 `profileRuntime` 在运行中重新组合;本服务驱动这一切:在 profile 目录运行 pnpm 安装或移除包,把组合包移入或移出 profile 的层列表并以结果重新组合树,在组合包的行启动失败时重新组合它,向 profile 的全局用户层或某个 agent preset 的用户层添加与移除行,并把 manifest、探针记录与在线树折叠成每个包一份视图。`plugins` Remote 暴露每一项操作;每次变更之后发出 `plugins/changed` 事件,安装运行把 pnpm 的输出以 `plugins/install-log` 流式发出。客户端包通过 [`api-remotes`](../../api/remotes/README.zh.md) 装配消费该 Remote。
`dsh-host-plugin-manager` 是插件管理在 Web 宿主上的那一面。它把 `pluginManager` 挂为 Typert 服务并暴露 `plugins` Remote——`list`、`add`、`uninstall`、`enable`、`disable`、`retry`、`addRow`、`removeRow`、`setRowDisabled`、`dependents`——每个方法转接给在本上下文之上构造的 [`dsh-plugin-manager`](../../boot/plugin-manager/README.zh.md) `PluginManager`,每个 `plugins/*` 失败以同码的 `RemoteError` 过线。操作做什么、以及跟随操作的 `plugins/changed` 与 `plugins/install-log` 事件,都属于那个包;本包只从上下文读出 profile runtime、preset roster 与 agent 注册表并交过去。客户端包通过 [`api-remotes`](../../api/remotes/README.zh.md) 装配消费该 Remote。
## 目录
@@ -27,23 +27,13 @@ kind: "package-reference"
把这一行挂在宿主组合里插件清单旁边;web 组合包已经这么做了。该行只注入 Loader,并在每次调用时解析 profile runtime,因此不经 profile launcher 启动的组合仍能启动,只是每次调用都回答 `plugins/unavailable`。
### 一份包视图说了什么
### Remote
`plugins/list` 为 profile 知道的每个包返回一份视图:模板组合包与已安装的组合包,以及其他每个已安装的依赖。视图携带 manifest 事实(名字、版本、标题、描述、`engines.dsh`),这个包是什么(`bundle`、`plugin` 或 `library`),谁提供它(`builtin` 或 `external`),它的行何时挂载(`boot` 或 `runtime`),是否已安装与已启用,以及折叠出的 `status`:已启用的组合包按活跃行的多少是 `running`、`partial` 或 `failed`;已安装但不在层列表中的组合包是 `disabled`;探针拒绝时是 `not-enableable` 并附原因;在启动时才应用变更的 profile 上 manifest 与在线树不一致时是 `restart-required`;库或插件模块是 `plain`,它们被添加进组合而不是被启用。组合包已组合时行来自在线树——阶段、被谁停用,以及隔离行记录的失败——否则来自探针记录,id 保持各自 patch 声明的样子。`addable` 列出包在 `dsh.plugins` 里声明的模块,各自带默认配置与探针的判定。
`plugins/list`、`plugins/add`、`plugins/uninstall`、`plugins/enable`、`plugins/disable`、`plugins/retry`、`plugins/addRow`、`plugins/removeRow`、`plugins/setRowDisabled` 与 `plugins/dependents` 携带管理器同名方法定义的参数与答复;[管理器 README](../../boot/plugin-manager/README.zh.md#use-this-package) 逐一说明。`./types` 导出原样 re-export 管理器的载荷类型,客户端只需导入一套词汇。
### 安装与启用
### 失败码
`plugins/add` 接受一个 pnpm spec——registry 名字、`github:` 或 git URL、tarball、绝对路径——在 profile 目录运行 `pnpm add`,记录 pnpm 写进 `dependencies` 的内容,在子进程里探测每个新包,并让新组合包保持停用,除非调用方要求 `enable`。pnpm 的输出以带本次 `jobId` 的 `plugins/install-log` 分块到达;最后一块携带退出码。非零退出、spawn 失败或超时都以 `plugins/install-failed` 与日志尾部让调用失败,并把 profile manifest 恢复到运行前的样子。`pnpm add` 成功还不等于装好了插件:新包既不声明组合包也不声明插件模块,或者是某个行 id 已被已组合层占有的组合包,会再以 `pnpm remove` 移除并连同原因列在 `removed` 里;探针拒绝的包保留在原处,由视图说明它为何不能启用。管理器一次只跑一个变更——上一个还在跑时再调用会以 `plugins/busy` 失败并点名正在进行的操作——并且在有会话运行时拒绝改动 `node_modules`,报 `plugins/agents-running`。
`plugins/enable` 把已安装的组合包放进层列表,并在 live profile 上经 profile runtime 带着它重新组合树。这次重新组合就是 Loader 自己的事务:树拒绝的组合包——`boot` 阶段而行抛错的组合包——回滚,层列表恢复,调用以点名原因的 `plugins/enable-failed` 失败,而原本运行的树继续运行。`runtime` 阶段而行失败的组合包则被隔离:调用成功,视图报告该行的失败,`plugins/retry` 从头重新组合它。`plugins/disable` 是反向操作;模板组合包不是依赖,无法停用。在 `patchReload` 为 `startup` 的 profile 上,两者只写 manifest 并报告 `effect: 'restart'`。
`plugins/uninstall` 在组合包已启用时先停用它,删除每一条点名该包模块的用户层行,运行 `pnpm remove`,并忘掉探针记录。与 `add` 一样,它等待运行中的会话:只要有 agent 在运行就报 `plugins/agents-running`。
### 用户层里的行
`plugins/addRow` 把一条点名该包某个模块的行——`plugin` 包的主导出,或某个 `dsh.plugins` 条目——插入 profile 的全局 `cordis.patch.yml`(`target: { kind: 'global' }`)或某个 agent preset 的用户层(`{ kind: 'preset', preset }`,经 roster 的 `overlayPathFor`)。行 id 未给出时由包名与子路径派生;已被占用的 id 以 `plugins/row-conflict` 失败。`plugins/removeRow` 移除一条插入的行,`plugins/setRowDisabled` 为任意行写入或移除 `disabled: true`——只写拒绝,因此组合包自己的 `!!js` 门被恢复而不是被覆盖。全局层当场在线重新组合;preset 的层在其下一个常驻代际生效。
`plugins/dependents` 说明停用或移除一个包会搁浅什么:其行提供而包外的行注入的服务,以及点名其模块的用户层行。
管理器的失败以同样的 `code` 与 `details` 作为 `RemoteError` 到达客户端:`plugins/unavailable`、`plugins/not-installed`、`plugins/not-enableable`、`plugins/enable-failed`、`plugins/install-failed`、`plugins/row-conflict`、`plugins/busy` 与 `plugins/agents-running`,各自以管理器的 details 类型声明在 Remote 失败表里。管理器的通用拒绝 `plugins/bad-request` 以 Gateway 的 `gateway/bad-request` 过线。管理器抛出的其他错误原样传播,由 Gateway 报为 `gateway/internal`。
### 配置
@@ -62,25 +52,17 @@ kind: "package-reference"
<details>
<summary>实现内幕——点击展开</summary>
### 一份 manifest,两个写入者
### 转接,不是第二个管理器
每次变更都重新读取 profile manifest,并通过 `dsh plugin` 命令所用的同一组 app-boot 助手(`reconcileInstalledBundles`、`enableBundle`、`disableBundle`)写回,因此 CLI 与管理器对这个文件永远不会有分歧。`dependencies` 记录装了什么;`dsh.profile.bundles` 记录启用了什么。
### pnpm 经 `node:child_process` 运行
subprocess seam 会清洗形似密钥的变量且没有 shell 模式,而 pnpm 既需要用户的 registry、代理与鉴权设置,在 Windows 上又需要解析其 `.cmd` shim 的 shell。于是管理器按 CLI 的方式生成 pnpm:带父进程环境、Windows 上开 `shell`,并自己流式读取子进程的输出。
### 重试即先停用再启用
Loader 的事务性更新不会碰未改变的行,因此一条失败的隔离行在普通的重新组合中不会重新启动。重试把组合包移出层列表再放回去:两次重新组合,manifest 首尾如一。
构造器构造一个 `PluginManager`,交给它读向上下文的读取器——`ctx.get('profileRuntime')`、`ctx.get('agentPresets')`,以及从 `ctx.get('agents')` 数出的运行中 agent 数——因此组合里有什么在调用到达时读取,而不是在行挂载时。每个 Remote 方法都是 `relay(() => this.manager.x(...))`:`PluginOperationError` 经一个穷尽的 switch 变成其码对应的 Remote 错误,其他东西原样重抛。测试经 `PluginManagerInternals.manager` 交入管理器;spawn 与探针的测试缝透传给管理器。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | `PluginManager`:`pluginManager` 服务、`plugins` Remote 方法、pnpm 运行器与视图折叠 |
| [`src/types.ts`](src/types.ts) | wire 载荷、`plugins/changed` 与 `plugins/install-log` 事件,以及 `plugins/*` 失败码 |
| — | 不发布运行时不变量伴随件;每份视图都在每次调用时从 manifest、探针缓存与 Loader 持有的状态折叠而来。 |
| [`src/index.ts`](src/index.ts) | `PluginManagerRemote`:`pluginManager` 服务、十个 `plugins` Remote 方法与 `remoteErrorOf` |
| [`src/types.ts`](src/types.ts) | re-export 管理器的类型,并把 `plugins/*` 码声明进 Remote 失败表 |
| — | 不发布运行时不变量伴随件;该行不持有自己的状态。 |
Typert 生成 `./typert` 与 `./remote` 暴露的宿主与客户端 Remote 工件。
@@ -91,11 +73,10 @@ Typert 生成 `./typert` 与 `./remote` 暴露的宿主与客户端 Remote 工
<a id="further-exploration"></a>
## 进一步探索
当管理器的契约还不够时读这些:它驱动的运行时、它编辑的文件,以及渲染它的界面。
当转接层的契约还不够时读这些:它背后的管理器,以及渲染它的界面。
- [App boot](../../boot/app-boot/README.zh.md)——profile runtime、外部组合包隔离与包探针。
- [补丁文件](../../util/patch-file/README.zh.md)——用户层的行如何写入。
- [Agent presets](../../preset/agent-presets/README.zh.md)——preset 目标所写的每预设用户层。
- [插件管理器](../../boot/plugin-manager/README.zh.md)——每项操作做什么、事件与失败码。
- [App boot](../../boot/app-boot/README.zh.md)——管理器驱动的 profile runtime。
- [插件清单](../plugin-inventory/README.zh.md)——本服务旁边的行级只读投影。
-----
@@ -103,7 +84,7 @@ Typert 生成 `./typert` 与 `./remote` 暴露的宿主与客户端 Remote 工
<a id="model-experience"></a>
## 模型体验
无,宿主侧的插件管理器不注册任何面向模型的东西;它组合出的行各自拥有自己做出的注册。
无,该 Remote 不注册任何面向模型的东西;管理器组合出的行各自拥有自己做出的注册。
#### KV Cache 影响
@@ -114,12 +95,9 @@ Typert 生成 `./typert` 与 `./remote` 暴露的宿主与客户端 Remote 工
<a id="known-limitations-and-deferred-work"></a>
这些限制界定管理器不会为客户端做什么。它们是当前包的约束,不是任务清单。
这些限制界定该 Remote 不会为客户端做什么。它们是当前包的约束,不是任务清单。
- **更新已加载的包需要重启**——Node 按 URL 缓存 ESM 模块,hoisted 安装下路径不变;经 `install` 做的 `pnpm update` 改写了文件,但运行中的树在进程重启前一直用旧模块。
- **依赖检测止于注入**——注册型依赖(工具、LLM 适配器)没有 `inject` 边,因此 `dependents` 无法点名只读取该包所注册内容的行。
- **preset 的行不在线组合**——管理器写入 preset 的层;之后创建的会话组合它,已在运行的会话保持其代际。
- **尚无 `engines.dsh` 检查**——该范围只被报告,不对运行中的 harness 版本强制执行。
- **管理器做不到的,Remote 也做不到**——[管理器的限制](../../boot/plugin-manager/README.zh.md#known-limitations-and-deferred-work)原样适用:已加载的包下次重启才更新,依赖检测止于注入,preset 的行供之后的会话组合,`engines.dsh` 只报告不强制。
<a id="dev-note"></a>
### 开发备注
+3 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-host-plugin-manager",
"description": "Plugin management over the booted profile: install, enable, disable, and retry bundles, add rows to the global or a preset's user layer, and report each package's state through the plugins Remote",
"description": "The plugins Remote of the Web host: pluginManager as a Typert service that relays each call to the shared plugin manager and maps its failures onto Remote error codes",
"version": "0.1.3-alpha.1",
"publishConfig": {
"access": "public"
@@ -53,8 +53,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-package-manifest": "workspace:^",
"@deepseek-ai/dsh-patch-file": "workspace:^",
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-util-values": "workspace:^"
},
@@ -71,8 +70,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-package-manifest": "workspace:^",
"@deepseek-ai/dsh-patch-file": "workspace:^",
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-util-values": "workspace:^"
}
File diff suppressed because it is too large. Load diff
+15 -221
View File
@@ -1,239 +1,33 @@
/**
* Client-safe payloads, events, and failure codes of the plugin manager.
* Every value here crosses the Typert wire as lossless JSON.
* Client-safe payloads, events, and failure codes of the `plugins` Remote:
* the plugin manager's own vocabulary, re-exported unchanged, plus its codes
* declared in the Remote failure map so a client narrows a failure's
* `details` by code.
* @module @deepseek-ai/dsh-host-plugin-manager/types
*/
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type { PluginOperationDetailsMap } from '@deepseek-ai/dsh-plugin-manager/types'
/** What an installed package is: a bundle layer, a plugin module, or a plain library. */
export type PluginPackageKind = 'bundle' | 'plugin' | 'library'
/** Who supplied a package: the installation's own bundles, or a dependency the user installed. */
export type PluginPackageTrust = 'builtin' | 'external'
/** When a bundle's rows mount: isolated at `runtime` (the default), or with the built-in rows at `boot`. */
export type PluginPackageStage = 'boot' | 'runtime'
/**
* The state one package is in, folded from the profile manifest, the probe
* record, and the live tree:
*
* - `running`: enabled and every row active;
* - `partial`: enabled with at least one row failed or waiting;
* - `failed`: enabled and no row active;
* - `disabled`: installed and not in the layer list;
* - `not-enableable`: installed but the probe refused it (import failure, foreign cordis copy);
* - `restart-required`: its manifest state and the live tree disagree, which a profile without live reload resolves at the next start;
* - `plain`: a library or plugin module, which is added to a composition rather than enabled.
*/
export type PluginPackageStatus =
| 'running'
| 'partial'
| 'failed'
| 'disabled'
| 'not-enableable'
| 'restart-required'
| 'plain'
/** Lifecycle phase of a row's root fiber; null when the row has no live fiber. */
export type PluginRowPhase = 'pending' | 'loading' | 'active' | 'failed' | 'unloading' | null
/** One row a bundle contributes to the host tree, as the tree runs it. */
export interface PluginPackageRowView {
/** The row's tree-wide id, as `Entry.id` spells it (`include:<row id>` under the root include). */
readonly entryId: string
/** The row id as the composition declares it — what a user patch or a row action targets. */
readonly rowId: string
/** Module specifier the row names. */
readonly moduleName: string
/** Effective enablement, including a disabled owning group. */
readonly enabled: boolean
/** Present exactly when `enabled` is false. */
readonly disabledBy?: 'user' | 'composition'
/** Root-fiber phase, or null when the row has no live fiber. */
readonly phase: PluginRowPhase
/** The recorded startup failure of an isolated row, when one is recorded. */
readonly failure?: { readonly stage: string; readonly message: string }
}
/** One agent-plane module a package declares addable to a composition. */
export interface PluginPackageAddableView {
/** The module as it is named in a row: the bare package for `.`, else `<package>/<subpath>`. */
readonly moduleName: string
/** The `dsh.plugins[].name` the package declared. */
readonly declaredName: string
/** Display title, when declared. */
readonly title?: string
/** Default row config, when declared. */
readonly config?: JsonValue
/** Whether the probe imported the module and found a plugin. */
readonly ok: boolean
/** The probe's import failure, when `ok` is false. */
readonly error?: string
/** The module's `Config` schema envelope, when it declares one. */
readonly configSchema?: JsonValue
}
/** One package as the manager sees it. */
export interface PluginPackageView {
/** The package name; also the row-id prefix and group id of an isolated bundle. */
readonly name: string
/** The installed version, when the manifest declares one. */
readonly version?: string
/** Display title from `dsh.title`, when declared. */
readonly title?: string
/** Free text from the manifest. */
readonly description?: string
readonly kind: PluginPackageKind
readonly trust: PluginPackageTrust
readonly stage: PluginPackageStage
/** Whether the package is a profile dependency (installed by `dsh plugin add` or the manager). */
readonly installed: boolean
/** Whether the bundle is in the profile's layer list. */
readonly enabled: boolean
readonly status: PluginPackageStatus
/** Why the package cannot be enabled, or why it is not running, when the status calls for one. */
readonly reason?: string
/** The harness version range the package declares in `engines.dsh`. */
readonly enginesDsh?: string
/** Whether the package shares the harness's cordis copy; null when the probe could not tell. */
readonly cordisSameCopy: boolean | null
/** The rows the bundle contributes, from the live tree when enabled, else from the probe. */
readonly rows: readonly PluginPackageRowView[]
/** Ids of built-in rows the bundle's patch overrides. */
readonly overrides: readonly string[]
/** Agent-plane modules the package declares addable. */
readonly addable: readonly PluginPackageAddableView[]
/** ISO time of the probe record this view was folded from, when one exists. */
readonly probedAt?: string
/** Whether the profile applies user patch files while running; false means changes wait for a restart. */
readonly liveReload: boolean
}
/** Where a composition row is added or edited: the profile's global user layer, or one preset's. */
export type PluginRowTarget =
| { readonly kind: 'global' }
| { readonly kind: 'preset'; readonly preset: string }
/** A package pnpm installed that the run removed again, with the check it failed. */
export interface PluginInstallRejection {
/** The package name. */
readonly name: string
/** Why it was removed: not a dsh package, or a row id another layer already owns. */
readonly reason: string
}
/** What one install run changed. */
export interface PluginInstallResult {
/** Dependencies present after the run and absent before it that passed the post-install checks, by name. */
readonly installed: readonly string[]
/** Dependencies pnpm added that the run removed again, each with its reason. */
readonly removed: readonly PluginInstallRejection[]
/** Bundles newly enabled, when the caller asked for it. */
readonly enabled: readonly string[]
/** Newly installed bundles left disabled. */
readonly installedOnly: readonly string[]
/** Newly installed dependencies that declare no bundle. */
readonly plain: readonly string[]
/** The identifier the run's `plugins/install-log` chunks carried. */
readonly jobId: string
}
/** What enabling a package did. */
export interface PluginEnableResult {
/** Whether the layer list changed. */
readonly changed: boolean
/** `live` when the tree was recomposed, `restart` when the profile applies changes at its next start. */
readonly effect: 'live' | 'restart'
}
/** One row outside a package that injects a service one of its rows provides. */
export interface PluginServiceDependent {
/** The service name. */
readonly service: string
/** The package row providing it. */
readonly providedBy: string
/** Rows outside the package whose fiber injects it. */
readonly injectedBy: readonly string[]
}
/** One user-layer row that names a module of the package. */
export interface PluginRowReference {
readonly target: PluginRowTarget
readonly rowId: string
readonly moduleName: string
}
/** What disabling or uninstalling a package would strand. */
export interface PluginDependents {
readonly services: readonly PluginServiceDependent[]
readonly references: readonly PluginRowReference[]
}
/** Where a row was added. */
export interface PluginRowAddition {
readonly target: PluginRowTarget
/** The id the row was given. */
readonly rowId: string
/** The user layer file the row was written to. */
readonly file: string
}
/** Why the manager changed something, for a listener deciding what to refresh. */
export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable' | 'retry' | 'row'
/** One chunk of an install run's output. */
export interface PluginInstallLogChunk {
/** The run the chunk belongs to. */
readonly jobId: string
/** The package spec the run installs or removes. */
readonly spec: string
readonly stream: 'stdout' | 'stderr'
readonly text: string
/** Present on the run's last chunk, with pnpm's exit code (null for a signal). */
readonly exitCode?: number | null
}
export type * from '@deepseek-ai/dsh-plugin-manager/types'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
/** No profile runtime is composed, so there is no profile to manage. */
'plugins/unavailable': { readonly reason: string }
'plugins/unavailable': PluginOperationDetailsMap['plugins/unavailable']
/** The package is not a profile dependency. */
'plugins/not-installed': { readonly packageName: string }
'plugins/not-installed': PluginOperationDetailsMap['plugins/not-installed']
/** The package cannot be enabled or added, with the probe's reason. */
'plugins/not-enableable': { readonly packageName: string; readonly reason: string }
'plugins/not-enableable': PluginOperationDetailsMap['plugins/not-enableable']
/** Enabling composed the bundle and the tree rejected it; the layer list was restored. */
'plugins/enable-failed': { readonly packageName: string; readonly reason: string }
/** pnpm exited non-zero. */
'plugins/install-failed': { readonly spec: string; readonly exitCode: number | null; readonly log: string }
'plugins/enable-failed': PluginOperationDetailsMap['plugins/enable-failed']
/** pnpm exited non-zero, could not be spawned, or timed out. */
'plugins/install-failed': PluginOperationDetailsMap['plugins/install-failed']
/** The row id is already taken in the target user layer. */
'plugins/row-conflict': { readonly rowId: string; readonly target: PluginRowTarget }
'plugins/row-conflict': PluginOperationDetailsMap['plugins/row-conflict']
/** Another mutation is still running; the manager runs one at a time and refuses rather than queues. */
'plugins/busy': {
readonly operation: string
readonly subject: string
readonly active: { readonly operation: string; readonly subject: string }
}
'plugins/busy': PluginOperationDetailsMap['plugins/busy']
/** `node_modules` cannot change while a session runs; `running` counts the agents in `running` status. */
'plugins/agents-running': { readonly operation: string; readonly running: number }
}
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* The manager changed what is installed, enabled, or composed.
* @mode emit
* @param change - why, and which package when one is concerned.
*/
'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void
/**
* One chunk of an install run's output, in order; the last chunk carries the exit code.
* @mode emit
* @param chunk - the chunk.
*/
'plugins/install-log'(chunk: PluginInstallLogChunk): void
'plugins/agents-running': PluginOperationDetailsMap['plugins/agents-running']
}
}
File diff suppressed because it is too large. Load diff
+3 -6
View File
@@ -24,22 +24,19 @@
"path": "../../boot/app-boot"
},
{
"path": "../../preset/agent-presets"
"path": "../../boot/plugin-manager"
},
{
"path": "../../util/patch-file"
"path": "../../preset/agent-presets"
},
{
"path": "../../typert/protocol"
},
{
"path": "../../util/values"
},
{
"path": "../../core/agent"
},
{
"path": "../../util/package-manifest"
"path": "../../util/values"
}
]
}
+29 -5
View File
@@ -223,6 +223,9 @@ importers:
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:^
version: link:../../packages/plan/plan-mode
'@deepseek-ai/dsh-plugin-manager':
specifier: workspace:^
version: link:../../packages/boot/plugin-manager
'@deepseek-ai/dsh-pwsh-local':
specifier: workspace:^
version: link:../../packages/shell/pwsh-local
@@ -1101,6 +1104,30 @@ importers:
specifier: ^15.0.0
version: 15.0.0
packages/boot/plugin-manager:
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-app-boot':
specifier: workspace:^
version: link:../app-boot
'@deepseek-ai/dsh-package-manifest':
specifier: workspace:^
version: link:../../util/package-manifest
'@deepseek-ai/dsh-patch-file':
specifier: workspace:^
version: link:../../util/patch-file
'@deepseek-ai/dsh-util-values':
specifier: workspace:^
version: link:../../util/values
packages/bundle/acp-app:
dependencies:
'@deepseek-ai/dsh-acp':
@@ -6013,12 +6040,9 @@ importers:
'@deepseek-ai/dsh-app-boot':
specifier: workspace:^
version: link:../../boot/app-boot
'@deepseek-ai/dsh-package-manifest':
'@deepseek-ai/dsh-plugin-manager':
specifier: workspace:^
version: link:../../util/package-manifest
'@deepseek-ai/dsh-patch-file':
specifier: workspace:^
version: link:../../util/patch-file
version: link:../../boot/plugin-manager
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:^
version: link:../../typert/protocol
@@ -129,7 +129,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/webhook/webhook-github': { kind: 'indirect', reason: 'The adapter delegates model-visible text to matching rules and dsh-webhook.' },
'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' },
'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' },
'packages/host/plugin-manager': { kind: 'none', reason: 'Host-side plugin management over the profile; the rows it composes own every model-facing registration.' },
'packages/host/plugin-manager': { kind: 'none', reason: 'The plugins Remote over the shared plugin manager; the rows the manager composes own every model-facing registration.' },
'packages/boot/plugin-manager': { kind: 'none', reason: 'Plugin management over the profile; the rows it composes own every model-facing registration.' },
'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' },
'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
+2
View File
@@ -151,6 +151,7 @@
"@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"],
"@deepseek-ai/dsh-host-plugin-manager": ["./packages/host/plugin-manager/src"],
"@deepseek-ai/dsh-host-plugin-manager/types": ["./packages/host/plugin-manager/src/types.ts"],
"@deepseek-ai/dsh-plugin-manager/types": ["./packages/boot/plugin-manager/src/types.ts"],
"@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],
"@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"],
"@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],
@@ -318,6 +319,7 @@
"@deepseek-ai/dsh-persona": ["./packages/preset/persona/src"],
"@deepseek-ai/dsh-plan-mode": ["./packages/plan/plan-mode/src"],
"@deepseek-ai/dsh-plan-mode/invariant": ["./packages/plan/plan-mode/src/invariant.ts"],
"@deepseek-ai/dsh-plugin-manager": ["./packages/boot/plugin-manager/src"],
"@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"],
"@deepseek-ai/dsh-pwsh-sandbox": ["./packages/shell/pwsh-sandbox/src"],
"@deepseek-ai/dsh-repeat-tool-reminder": ["./packages/guard/repeat-tool-reminder/src"],
+1
View File
@@ -287,6 +287,7 @@
{ "path": "./packages/bundle/web-app" },
{ "path": "./packages/boot/app-boot" },
{ "path": "./packages/boot/cmdline" },
{ "path": "./packages/boot/plugin-manager" },
{ "path": "./packages/sdk/server" },
{ "path": "./packages/test-support/llm-replay" },
{ "path": "./packages/typert/generator" },