diff --git a/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.i18n.yaml b/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.i18n.yaml index ca0d2c8d42..a58f17730b 100644 --- a/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.md -2026-08-14-plugin-enable-disable-in-page.md: 9616cc207f8032faf61d17e5211e64ce4253dee9 -2026-08-14-plugin-enable-disable-in-page.zh.md: 1251c0aa7e57fce2364d99119c52d68540e728ae +2026-08-14-plugin-enable-disable-in-page.md: 0644b1b01ac3342a8b2ebb3f9e0a9b3a7554274d +2026-08-14-plugin-enable-disable-in-page.zh.md: 5d548cb9dae0542292645744c8261ab6f305cd93 diff --git a/docs/plugin-system.i18n.yaml b/docs/plugin-system.i18n.yaml new file mode 100644 index 0000000000..cfaac1b822 --- /dev/null +++ b/docs/plugin-system.i18n.yaml @@ -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 docs/plugin-system.md +plugin-system.md: 8efa47dfa553db3b9054bc3d8d122f629d10c5c0 +plugin-system.zh.md: b6d29ffdd15a908c97f75eb769769a2e272411ba diff --git a/docs/plugin-system.md b/docs/plugin-system.md new file mode 100644 index 0000000000..8efa47dfa5 --- /dev/null +++ b/docs/plugin-system.md @@ -0,0 +1,55 @@ +# The Plugin System + +English | [中文](plugin-system.zh.md) + +This reference explains how plugins are installed, composed, and injected in the harness, and how the shipped base bundle splits into system-required and freely optional plugins. It is a reference for how the plugin system works today; the Cordis runtime semantics it relies on live in the [cordis primer](cordis-primer.md) and the [Cordis API](cordis-api/context.md), and the package composition map is in [architecture.md](architecture.md). + +## Three threads + +A plugin is an ordinary npm (or workspace) package that is composed into a profile by name, resolved through Node module resolution, and mounted as a Cordis fiber that runs only once the services it injects are active. Three threads make that work: **composition** (which plugins a profile mounts), **resolution** (how a name becomes loaded code), and **activation** (when a plugin actually runs). + +## Installation and composition + +There is no code that downloads or registers plugin code by name. "Installing" a plugin means two things: it is a resolvable npm package in the profile's (or the installation's) `node_modules`, and it is declared in a composition layer. + +A profile is a directory (`$DSH_HOME/profiles/`) whose `package.json` lists ordered **bundles** under `dsh.profile.bundles`. Each bundle is a package carrying a `dsh.bundle.patch` field pointing at a `cordis.patch.yml` — a top-level array of loader patch entries. When `dsh --profile web` boots, `apps/cli` `composeProfile` stacks the bundle patches, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, any `--patch` overlays, and the telemetry switch into one ordered patch list; `boot` installs the `Loader` service and mounts a root `include` entry over the profile's empty `cordis.yml` (which exists only to anchor the Loader's `baseUrl` at the profile directory); the include reads its patch list through `applyEntryPatches` and reconciles the composed rows into a live entry tree via `EntryGroup.update`, which creates, updates, or disables each `Entry` transactionally; and each entry imports its plugin module by `name` (its module specifier) through Node `internal.import`, interpolates `!!js` config and `disabled` expressions in the entry's own context, and mounts the module as a Cordis fiber. + +Bundles resolve installation-first, then from the profile directory; a flat symlink closure (`healProfilesModuleFallback`) keeps every in-box plugin Node-resolvable from any profile. `dsh plugin add ` is literally `pnpm add` run in the profile directory, after which any installed package that declares `dsh.bundle` is reconciled into `dsh.profile.bundles`. The `verify-cordis-config` gate enforces the invariant behind both install paths: every bare plugin specifier a composition references must appear in the `dependencies` of the package that resolves it. + +## Injection and activation + +A function plugin named-exports `name`, `inject`, `Config`, and `apply` (with no default export); a service plugin default-exports a `Service` subclass that registers itself on construction; an object plugin is `{ apply }`. `inject` lists the services the plugin requires. + +Services are provided by `Service` subclasses via `super(ctx, name)`, which registers the instance in the context store. `ctx.` reads go through a property proxy that walks the plugin's ancestors in the fiber tree (topology sensitive), while `ctx.get(name)` reads the global store and only returns a provider whose fiber is active. This is why a declared injection is readable only once its provider is active in the plugin's ancestry. + +Activation order is driven by service availability. A fiber whose injected services are missing stays `PENDING` and never runs `apply`; it is woken reactively when a provider appears. After the tree settles, the host audits every enabled entry (`assertEntriesActivated`) and treats any `PENDING` entry as a boot failure, listing the missing services. The canonical example is a tool that injects `workflowEngine` (e.g. `dsh-tool-ralph`) remaining pending when the workflow engine provider is not mounted. + +## System-required versus optional plugins + +"Required" is a dependency-graph property: a plugin is system-required when disabling it leaves a load-bearing service unavailable, which in turn leaves the core plugins that inject that service `PENDING`. The shipped `dsh-base` bundle is the shared core every mode mounts. Grouping its rows by this property: + +| Plugin (module specifier) | Load-bearing service | Class | +|---|---|---| +| `cordis-plugin-loader` | the loader entry tree itself | required | +| `dsh-typert-registry` | `ctx.typert` (Remote RPC type registry) | required | +| `dsh-typert-loader` | Remote RPC type loading | required | +| `dsh-api-gateway` | `ctx.typertGateway` (RPC invocation) | required | +| `dsh-agent` | `ctx.agents` | required | +| `dsh-session` | `ctx.sessions` | required | +| `dsh-llm` | `ctx.llm` | required | +| `dsh-tools` | `ctx.tools` (tool registry) | required | +| `dsh-system-prompt` | system-prompt assembly | required | +| `dsh-agent-loop` | `ctx.agentLoop` (injects agents/sessions/llm/tools/systemPrompt) | required | +| `dsh-settings-file` | user settings document (config) | required | +| `dsh-credentials-local` | credential store (keys) | required | +| everything else in `dsh-base` | none (leaf capability) | optional | + +`dsh-agent-loop` is the most load-bearing consumer: it injects `['agents', 'sessions', 'llm', 'tools', 'systemPrompt']`, so disabling it or any of its injected providers leaves the whole agent runtime `PENDING`. The configuration and credential rows are required in the sense that removing them takes away the config and key spine the rest of the tree reads. + +The optional rows are leaf capabilities whose disable removes a feature but never breaks the core: `hmr`, `timer`, `web` and its search, the shell and terminal providers, `skill`, `goal`, `plan-mode`, `compaction`, `subagent`, `workflow`, `sandbox` and the concrete sandbox providers, `approval` and `permission`, `telemetry`, `spill`, `todo`, and the individual `tool-*` and `command-*` rows. + +Two borderline groups deserve a note. The safety spine — `dsh-sandbox`, `dsh-fs-sandbox`, `dsh-sandbox-policy`, `dsh-permission-presets`, `dsh-user-approval` — is required for the default workspace-write-plus-ask posture but a deployment may relax it. The web profile additionally composes `dsh-web-app` (host transport, connection, frontend serving) and the optional `dsh-image-recognition-bundle`; `dsh-headless` composes `dsh-base` plus the headless runner rows. Those bundles' rows are required only within the mode that mounts them. + +## The plugin inventory's `protected` flag + +The plugin-inventory host projects each loader entry with `enabled = !entry.disabled` and `protected = isRequiredPlugin(entry.options.name)`. `isRequiredPlugin` in `src/required.ts` is a default-open guard: every plugin is toggleable unless its module specifier is in a small explicit set — currently the loader, the typert spine, `dsh-session`, and `dsh-agent`. That set is a conservative subset of the dependency-derived required list above: it does not protect `dsh-agent-loop`, `dsh-llm`, `dsh-tools`, `dsh-system-prompt`, or `dsh-api-gateway`. The full dependency-derived classification here is the basis for widening that set if the guard is ever made to reflect "cannot disable without breaking the core". diff --git a/docs/plugin-system.zh.md b/docs/plugin-system.zh.md new file mode 100644 index 0000000000..b6d29ffdd1 --- /dev/null +++ b/docs/plugin-system.zh.md @@ -0,0 +1,55 @@ +# 插件系统 + +[English](plugin-system.md) | 中文 + +本参考文档说明插件在 harness 中如何被安装、组合与注入,以及随包 `dsh-base` bundle 如何划分为系统必需插件与自由可选插件。它是关于插件系统当前运作方式的参考;它所依赖的 Cordis 运行时语义见 [cordis primer](cordis-primer.md) 与 [Cordis API](cordis-api/context.md),包组合总览见 [architecture.md](architecture.md)。 + +## 三条主线 + +插件就是一个普通的 npm(或 workspace)包,它按名称被组合进某个 profile,通过 Node 模块解析加载,并挂载成 Cordis fiber——只有当它所注入的服务激活后才会运行。支撑这一点的是三条主线:**组合**(profile 挂载哪些插件)、**解析**(一个名称如何变成已加载的代码)、**激活**(插件何时真正运行)。 + +## 安装与组合 + +没有"按名称下载并注册插件代码"的代码。"安装"一个插件意味着两件事:它是 profile(或安装)的 `node_modules` 里一个可解析的 npm 包,并且它出现在某个组合层里。 + +profile 是一个目录(`$DSH_HOME/profiles/`),其 `package.json` 在 `dsh.profile.bundles` 下列出有序的 **bundle**。每个 bundle 是一个包,带 `dsh.bundle.patch` 字段,指向一个 `cordis.patch.yml`——一个由 loader patch 条目组成的顶层数组。当 `dsh --profile web` 启动时,`apps/cli` 的 `composeProfile` 把各 bundle 的 patch、profile 自身的 `cordis.patch.yml`、首页级 `$DSH_HOME/cordis.patch.yml`、任何 `--patch` 覆盖以及 telemetry 开关按序叠成一个 patch 列表;`boot` 安装 `Loader` 服务,并在 profile 的空 `cordis.yml`(仅用于把 Loader 的 `baseUrl` 锚定到 profile 目录)之上挂载一个根 `include` 条目;include 通过 `applyEntryPatches` 读取其 patch 列表,并通过 `EntryGroup.update` 把组合出的行事务性地对账为存活的 entry 树——创建、更新或停用每条 `Entry`;每个 entry 通过 Node `internal.import` 按 `name`(其模块标识符)导入插件模块,在 entry 自身上下文里插值 `!!js` 的 config 与 `disabled` 表达式,并把模块挂载为 Cordis fiber。 + +bundle 先按安装锚点、再按 profile 目录解析;一个扁平软链闭包(`healProfilesModuleFallback`)保证每个随包插件都能从任意 profile 被 Node 解析。`dsh plugin add ` 本质上是在 profile 目录里执行 `pnpm add`,之后任何声明了 `dsh.bundle` 的已安装包会被并入 `dsh.profile.bundles`。`verify-cordis-config` 门禁强制了两条安装路径背后的不变量:组合所引用的每个裸插件 specifier 都必须出现在负责解析它的包的 `dependencies` 里。 + +## 注入与激活 + +函数插件具名导出 `name`、`inject`、`Config`、`apply`(无 default 导出);服务插件默认导出一个 `Service` 子类,构造时自行注册;对象插件是 `{ apply }`。`inject` 列出插件所需的服务。 + +服务由 `Service` 子类通过 `super(ctx, name)` 提供,它在上下文 store 里注册实例。`ctx.` 读取走一个属性代理,沿 fiber 树遍历插件的祖先(拓扑敏感);`ctx.get(name)` 读全局 store,只返回其 fiber 处于 active 状态的提供者。这就是为什么一条声明过的注入只有当其提供者在插件祖先链中激活后才是可读的。 + +激活顺序由服务可用性决定。注入服务缺失的 fiber 保持 `PENDING`,永不运行 `apply`;当提供者出现时它会被反应式唤醒。树稳定后,宿主审计每条启用条目(`assertEntriesActivated`),把任何 `PENDING` 条目视为启动失败并列出缺失服务。典型例子是注入 `workflowEngine` 的工具(如 `dsh-tool-ralph`)在工作流引擎提供者未挂载时保持 pending。 + +## 系统必需插件与可选插件 + +"必需"是一个依赖图属性:当停用某插件会令承重服务不可用、进而让注入该服务的核心插件保持 `PENDING` 时,它就是系统必需插件。随包 `dsh-base` bundle 是每种模式都会挂载的共享核心。按这一属性对它的行分组: + +| 插件(模块标识符) | 承重服务 | 类别 | +|---|---|---| +| `cordis-plugin-loader` | loader entry 树本身 | 必需 | +| `dsh-typert-registry` | `ctx.typert`(Remote RPC 类型注册表) | 必需 | +| `dsh-typert-loader` | Remote RPC 类型加载 | 必需 | +| `dsh-api-gateway` | `ctx.typertGateway`(RPC 调用) | 必需 | +| `dsh-agent` | `ctx.agents` | 必需 | +| `dsh-session` | `ctx.sessions` | 必需 | +| `dsh-llm` | `ctx.llm` | 必需 | +| `dsh-tools` | `ctx.tools`(工具注册表) | 必需 | +| `dsh-system-prompt` | 系统提示词组装 | 必需 | +| `dsh-agent-loop` | `ctx.agentLoop`(注入 agents/sessions/llm/tools/systemPrompt) | 必需 | +| `dsh-settings-file` | 用户设置文档(配置) | 必需 | +| `dsh-credentials-local` | 凭据存储(密钥) | 必需 | +| `dsh-base` 中其余所有行 | 无(叶子能力) | 可选 | + +`dsh-agent-loop` 是最大的承重消费方:它注入 `['agents', 'sessions', 'llm', 'tools', 'systemPrompt']`,因此停用它或它的任一注入提供者都会让整个 agent 运行时保持 `PENDING`。配置与凭据行之所以必需,是因为去掉它们就失去了树的其余部分所读取的配置与密钥主干。 + +可选行是叶子能力,停用只移除某个功能、绝不破坏核心:`hmr`、`timer`、`web` 及其搜索、shell 与 terminal 提供者、`skill`、`goal`、`plan-mode`、`compaction`、`subagent`、`workflow`、`sandbox` 及各具体 sandbox 提供者、`approval` 与 `permission`、`telemetry`、`spill`、`todo`,以及各条 `tool-*` 与 `command-*` 行。 + +有两组边界情况值得说明。安全主干——`dsh-sandbox`、`dsh-fs-sandbox`、`dsh-sandbox-policy`、`dsh-permission-presets`、`dsh-user-approval`——对默认的"工作区可写 + 询问"姿态是必需的,但部署可以放宽它。web profile 还会组合 `dsh-web-app`(宿主传输、连接、前端服务)与可选的 `dsh-image-recognition-bundle`;`dsh-headless` 组合 `dsh-base` 加 headless runner 各行。这些 bundle 的行只在挂载它们的模式内才是必需的。 + +## 插件清单的 `protected` 标志 + +插件清单宿主把每条 loader entry 投影为 `enabled = !entry.disabled` 与 `protected = isRequiredPlugin(entry.options.name)`。`src/required.ts` 里的 `isRequiredPlugin` 是一个默认开放的守卫:除非某个模块标识符出现在一个小的显式集合里——目前是 loader、typert 主干、`dsh-session`、`dsh-agent`——否则每个插件都可切换。这个集合是上面依赖图派生必需列表的保守子集:它没有保护 `dsh-agent-loop`、`dsh-llm`、`dsh-tools`、`dsh-system-prompt` 或 `dsh-api-gateway`。这里给出的完整依赖图分类,正是将来若要把守卫改成"反映不可停用核心"时用于扩展该集合的依据。 diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index 930764034b..8d51158920 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -159,6 +159,8 @@ export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [ '**/__pycache__/**', '**/.pytest_cache/**', 'apps/web/dist/**', + 'apps/desktop/dist/**', + 'apps/desktop/build/harness/**', '.artifacts/**', 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*/**', 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**',