From b1d0c14d387be6d07b8ede2bbef83b82dd4db93c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 23 Apr 2026 20:59:07 -0700 Subject: [PATCH] refactor(plugins): track activation compat hints --- docs/plugins/architecture.md | 39 +++-- docs/plugins/manifest.md | 43 +++-- docs/plugins/sdk-migration.md | 24 +-- src/plugin-sdk/compat.ts | 2 +- src/plugins/activation-planner.test.ts | 114 +++++++++++- src/plugins/activation-planner.ts | 234 +++++++++++++++++++------ src/plugins/compat-reasons.ts | 10 ++ src/plugins/manifest.ts | 14 +- 8 files changed, 374 insertions(+), 106 deletions(-) create mode 100644 src/plugins/compat-reasons.ts diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 4de8c088eef..9d886a97390 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -119,22 +119,31 @@ signals also appear in `openclaw status --all` and `openclaw plugins doctor`. ## Architecture overview -OpenClaw's plugin system has four layers: +OpenClaw's plugin system is split into four planes: -1. **Manifest + discovery** - OpenClaw finds candidate plugins from configured paths, workspace roots, - global plugin roots, and bundled plugins. Discovery reads native - `openclaw.plugin.json` manifests plus supported bundle manifests first. -2. **Enablement + validation** - Core decides whether a discovered plugin is enabled, disabled, blocked, or - selected for an exclusive slot such as memory. -3. **Runtime loading** - Native OpenClaw plugins are loaded in-process via jiti and register - capabilities into a central registry. Compatible bundles are normalized into - registry records without importing runtime code. -4. **Surface consumption** - The rest of OpenClaw reads the registry to expose tools, channels, provider - setup, hooks, HTTP routes, CLI commands, and services. +1. **Source plane** + OpenClaw decides where a plugin comes from and how it can be installed. This + includes bundled catalogs, official external catalogs, ClawHub/npm specs, + local source paths, minimum host version, expected npm integrity, and install + policy checks. +2. **Control plane** + OpenClaw reads package and manifest metadata before runtime code executes. + This includes discovery, config schemas, provider/channel ownership, + setup/onboarding hints, contracts, auth choices, and enablement policy. +3. **Load plane** + OpenClaw builds deterministic plans for concrete needs such as a provider, + channel, command, hook stage, or contract. Legacy `activation.*` fields are + compatibility hints in this plane, not the preferred public contract. +4. **Runtime plane** + OpenClaw imports plugin code only for actual execution. Native plugins + register capabilities into scoped or compatibility registries; compatible + bundles can still normalize into registry records without importing runtime + code. + +The important compatibility rule: documented external plugins and existing +bundled plugins must keep working while contracts migrate. Breaking changes need +a replacement contract, compatibility adapter, diagnostics, tests, docs, and an +approved deprecation window before removal. For plugin CLI specifically, root command discovery is split in two phases: diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 7788b0d2b82..fecdeaa36da 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -239,11 +239,15 @@ runtime still owns actual CLI registration through a lightweight | `commandName` | Yes | `string` | Subcommand mounted beneath `openclaw qa`, for example `matrix`. | | `description` | No | `string` | Fallback help text used when the shared host needs a stub command. | -This block is metadata only. It does not register runtime behavior, and it does -not replace `register(...)`, `setupEntry`, or other runtime/plugin entrypoints. -Current consumers use it as a narrowing hint before broader plugin loading, so -missing activation metadata usually only costs performance; it should not -change correctness while legacy manifest ownership fallbacks still exist. +This block is legacy hint metadata. It does not register runtime behavior, and +it does not replace `register(...)`, `setupEntry`, or other runtime/plugin +entrypoints. Existing plugins may keep these fields, but new manifests should +prefer explicit ownership fields such as `providers`, `channels`, `contracts`, +`commandAliases`, and `setup`. + +Current consumers still parse `activation` through the compatibility layer so +existing bundled and external plugins keep working. New code should treat these +fields as fallback hints for load planning, not as the primary plugin contract. ```json { @@ -257,23 +261,24 @@ change correctness while legacy manifest ownership fallbacks still exist. } ``` -| Field | Required | Type | What it means | -| ---------------- | -------- | ---------------------------------------------------- | ----------------------------------------------------------------- | -| `onProviders` | No | `string[]` | Provider ids that should activate this plugin when requested. | -| `onCommands` | No | `string[]` | Command ids that should activate this plugin. | -| `onChannels` | No | `string[]` | Channel ids that should activate this plugin. | -| `onRoutes` | No | `string[]` | Route kinds that should activate this plugin. | -| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Broad capability hints used by control-plane activation planning. | +| Field | Required | Type | What it means | +| ---------------- | -------- | ---------------------------------------------------- | ----------------------------------------------------------------------------- | +| `onProviders` | No | `string[]` | Legacy provider load hint. Prefer top-level `providers`. | +| `onCommands` | No | `string[]` | Legacy command load hint. Prefer command aliases or CLI descriptors. | +| `onChannels` | No | `string[]` | Legacy channel load hint. Prefer top-level `channels`. | +| `onRoutes` | No | `string[]` | Legacy route load hint. Keep only when no narrower route metadata exists yet. | +| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Legacy broad capability hint. Do not add new uses. | Current live consumers: -- command-triggered CLI planning falls back to legacy - `commandAliases[].cliCommand` or `commandAliases[].name` -- channel-triggered setup/channel planning falls back to legacy `channels[]` - ownership when explicit channel activation metadata is missing -- provider-triggered setup/runtime planning falls back to legacy - `providers[]` and top-level `cliBackends[]` ownership when explicit provider - activation metadata is missing +- command-triggered CLI planning prefers `commandAliases[].cliCommand` or + `commandAliases[].name` before legacy `activation.onCommands` +- channel-triggered setup/channel planning prefers `channels[]` before legacy + `activation.onChannels` +- provider-triggered setup/runtime planning prefers `providers[]` and + `setup.providers[]` before legacy `activation.onProviders` +- broad capability planning prefers explicit ownership metadata before legacy + `activation.onCapabilities` ## setup reference diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index f6d18fc4475..fd726846b22 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -25,12 +25,14 @@ anything they needed from a single entry point: host-side helpers like the embedded agent runner. Both surfaces are now **deprecated**. They still work at runtime, but new -plugins must not use them, and existing plugins should migrate before the next -major release removes them. +plugins must not use them, and existing plugins should migrate before an +approved breaking release removes them. - The backwards-compatibility layer will be removed in a future major release. - Plugins that still import from these surfaces will break when that happens. + The backwards-compatibility layer remains supported during the migration + window. Any removal must go through a documented deprecation path first: + replacement contract, compatibility adapter, diagnostics, tests, docs, and an + explicitly approved breaking release. ## Why this changed @@ -374,13 +376,15 @@ check the source at `src/plugin-sdk/` or ask in Discord. ## Removal timeline -| When | What happens | -| ---------------------- | ----------------------------------------------------------------------- | -| **Now** | Deprecated surfaces emit runtime warnings | -| **Next major release** | Deprecated surfaces will be removed; plugins still using them will fail | +| When | What happens | +| ---------------------------------- | ---------------------------------------------------------------------------- | +| **Now** | Deprecated surfaces emit runtime warnings and keep working through adapters. | +| **Migration window** | Replacement contracts, diagnostics, tests, and docs stay available together. | +| **Approved breaking release only** | Deprecated surfaces may be removed after the migration window. | -All core plugins have already been migrated. External plugins should migrate -before the next major release. +All core plugins have already been migrated. External plugins should migrate, +but documented external plugins should not break without the compatibility path +above. ## Suppressing the warnings temporarily diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 5ad6e71f26a..5a13f5089ff 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -12,7 +12,7 @@ if (shouldWarnCompatImport) { { code: "OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED", detail: - "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration", + "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat during the documented migration window. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration", }, ); } diff --git a/src/plugins/activation-planner.test.ts b/src/plugins/activation-planner.test.ts index 083a0bbdd5f..92e33182f9f 100644 --- a/src/plugins/activation-planner.test.ts +++ b/src/plugins/activation-planner.test.ts @@ -9,10 +9,14 @@ vi.mock("./manifest-registry.js", () => ({ })); let resolveManifestActivationPluginIds: typeof import("./activation-planner.js").resolveManifestActivationPluginIds; +let resolveManifestActivationPlan: typeof import("./activation-planner.js").resolveManifestActivationPlan; +let PLUGIN_COMPAT_REASON: typeof import("./compat-reasons.js").PLUGIN_COMPAT_REASON; describe("resolveManifestActivationPluginIds", () => { beforeAll(async () => { - ({ resolveManifestActivationPluginIds } = await import("./activation-planner.js")); + ({ resolveManifestActivationPluginIds, resolveManifestActivationPlan } = + await import("./activation-planner.js")); + ({ PLUGIN_COMPAT_REASON } = await import("./compat-reasons.js")); }); beforeEach(() => { @@ -70,6 +74,20 @@ describe("resolveManifestActivationPluginIds", () => { }, origin: "workspace", }, + { + id: "legacy-activation-only", + providers: [], + activation: { + onProviders: ["legacy-provider"], + onChannels: ["legacy-channel"], + onCapabilities: ["tool"], + }, + channels: [], + cliBackends: [], + skills: [], + hooks: [], + origin: "workspace", + }, ], diagnostics: [], }); @@ -168,7 +186,7 @@ describe("resolveManifestActivationPluginIds", () => { capability: "tool", }, }), - ).toEqual(["demo-channel"]); + ).toEqual(["demo-channel", "legacy-activation-only"]); expect( resolveManifestActivationPluginIds({ @@ -191,4 +209,96 @@ describe("resolveManifestActivationPluginIds", () => { }), ).toEqual([]); }); + + it("reports legacy activation field compat reasons without changing plugin-id resolution", () => { + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "command", + command: "demo-tools", + }, + }), + ).toEqual({ + pluginIds: ["demo-channel"], + entries: [ + { + pluginId: "demo-channel", + reasons: ["command:demo-tools"], + compatReasons: [PLUGIN_COMPAT_REASON.legacyActivationField], + }, + ], + compatReasons: { + "demo-channel": [PLUGIN_COMPAT_REASON.legacyActivationField], + }, + }); + + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "provider", + provider: "legacy-provider", + }, + }).compatReasons, + ).toEqual({ + "legacy-activation-only": [PLUGIN_COMPAT_REASON.legacyActivationField], + }); + + expect( + resolveManifestActivationPluginIds({ + trigger: { + kind: "provider", + provider: "legacy-provider", + }, + }), + ).toEqual(["legacy-activation-only"]); + }); + + it("does not report compat reasons for stable ownership metadata", () => { + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "provider", + provider: "openai", + }, + }), + ).toEqual({ + pluginIds: ["openai"], + entries: [ + { + pluginId: "openai", + reasons: ["provider:openai"], + compatReasons: [], + }, + ], + compatReasons: {}, + }); + }); + + it("reports legacy activation capability hints separately from stable capabilities", () => { + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "capability", + capability: "tool", + }, + }), + ).toEqual({ + pluginIds: ["demo-channel", "legacy-activation-only"], + entries: [ + { + pluginId: "demo-channel", + reasons: ["capability:tool"], + compatReasons: [], + }, + { + pluginId: "legacy-activation-only", + reasons: ["capability:tool"], + compatReasons: [PLUGIN_COMPAT_REASON.legacyActivationField], + }, + ], + compatReasons: { + "legacy-activation-only": [PLUGIN_COMPAT_REASON.legacyActivationField], + }, + }); + }); }); diff --git a/src/plugins/activation-planner.ts b/src/plugins/activation-planner.ts index 2206c689a7e..0c27be58c9a 100644 --- a/src/plugins/activation-planner.ts +++ b/src/plugins/activation-planner.ts @@ -1,6 +1,7 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { PLUGIN_COMPAT_REASON, type PluginCompatReason } from "./compat-reasons.js"; import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import type { PluginManifestActivationCapability } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; @@ -14,6 +15,18 @@ export type PluginActivationPlannerTrigger = | { kind: "route"; route: string } | { kind: "capability"; capability: PluginManifestActivationCapability }; +export type PluginActivationPlanEntry = { + pluginId: string; + reasons: string[]; + compatReasons: PluginCompatReason[]; +}; + +export type PluginActivationPlan = { + pluginIds: string[]; + entries: PluginActivationPlanEntry[]; + compatReasons: Record; +}; + export function resolveManifestActivationPluginIds(params: { trigger: PluginActivationPlannerTrigger; config?: OpenClawConfig; @@ -23,101 +36,218 @@ export function resolveManifestActivationPluginIds(params: { origin?: PluginOrigin; onlyPluginIds?: readonly string[]; }): string[] { - const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds)); - - return [ - ...new Set( - loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - cache: params.cache, - }) - .plugins.filter( - (plugin) => - (!params.origin || plugin.origin === params.origin) && - (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) && - matchesManifestActivationTrigger(plugin, params.trigger), - ) - .map((plugin) => plugin.id), - ), - ].toSorted((left, right) => left.localeCompare(right)); + return resolveManifestActivationPlan(params).pluginIds; } -function matchesManifestActivationTrigger( +export function resolveManifestActivationPlan(params: { + trigger: PluginActivationPlannerTrigger; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + cache?: boolean; + origin?: PluginOrigin; + onlyPluginIds?: readonly string[]; +}): PluginActivationPlan { + const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds)); + + const entries = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + }) + .plugins.flatMap((plugin): PluginActivationPlanEntry[] => { + if ( + (params.origin && plugin.origin !== params.origin) || + (onlyPluginIdSet && !onlyPluginIdSet.has(plugin.id)) + ) { + return []; + } + const match = matchManifestActivationTrigger(plugin, params.trigger); + if (!match) { + return []; + } + return [ + { + pluginId: plugin.id, + reasons: [match.reason], + compatReasons: match.compatReason ? [match.compatReason] : [], + }, + ]; + }) + .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)); + + return { + pluginIds: entries.map((entry) => entry.pluginId), + entries, + compatReasons: Object.fromEntries( + entries.flatMap((entry) => + entry.compatReasons.length > 0 ? [[entry.pluginId, entry.compatReasons]] : [], + ), + ), + }; +} + +type ManifestActivationMatch = { + reason: string; + compatReason?: PluginCompatReason; +}; + +function matchManifestActivationTrigger( plugin: PluginManifestRecord, trigger: PluginActivationPlannerTrigger, -): boolean { +): ManifestActivationMatch | null { switch (trigger.kind) { case "command": - return listActivationCommandIds(plugin).includes(normalizeCommandId(trigger.command)); + return matchActivationCommand(plugin, trigger.command); case "provider": - return listActivationProviderIds(plugin).includes(normalizeProviderId(trigger.provider)); + return matchActivationProvider(plugin, trigger.provider); case "agentHarness": - return listActivationAgentHarnessIds(plugin).includes(normalizeCommandId(trigger.runtime)); + return matchLegacyActivationList( + listActivationAgentHarnessIds(plugin), + normalizeCommandId(trigger.runtime), + "agent-harness", + ); case "channel": - return listActivationChannelIds(plugin).includes(normalizeCommandId(trigger.channel)); + return matchActivationChannel(plugin, trigger.channel); case "route": - return listActivationRouteIds(plugin).includes(normalizeCommandId(trigger.route)); + return matchLegacyActivationList( + listActivationRouteIds(plugin), + normalizeCommandId(trigger.route), + "route", + ); case "capability": - return hasActivationCapability(plugin, trigger.capability); + return matchActivationCapability(plugin, trigger.capability); } const unreachableTrigger: never = trigger; return unreachableTrigger; } +function matchLegacyActivationList( + ids: readonly string[], + normalizedId: string, + reasonPrefix: string, +): ManifestActivationMatch | null { + if (!normalizedId || !ids.includes(normalizedId)) { + return null; + } + return { + reason: `${reasonPrefix}:${normalizedId}`, + compatReason: PLUGIN_COMPAT_REASON.legacyActivationField, + }; +} + function listActivationAgentHarnessIds(plugin: PluginManifestRecord): string[] { return [...(plugin.activation?.onAgentHarnesses ?? [])].map(normalizeCommandId).filter(Boolean); } -function listActivationCommandIds(plugin: PluginManifestRecord): string[] { - return [ - ...(plugin.activation?.onCommands ?? []), - ...(plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name), - ] +function matchActivationCommand( + plugin: PluginManifestRecord, + command: string, +): ManifestActivationMatch | null { + const normalizedCommand = normalizeCommandId(command); + if (!normalizedCommand) { + return null; + } + const commandAliases = (plugin.commandAliases ?? []) + .flatMap((alias) => alias.cliCommand ?? alias.name) .map(normalizeCommandId) .filter(Boolean); + if (commandAliases.includes(normalizedCommand)) { + return { reason: `command:${normalizedCommand}` }; + } + return matchLegacyActivationList( + (plugin.activation?.onCommands ?? []).map(normalizeCommandId).filter(Boolean), + normalizedCommand, + "command", + ); } -function listActivationProviderIds(plugin: PluginManifestRecord): string[] { - return [ - ...(plugin.activation?.onProviders ?? []), +function matchActivationProvider( + plugin: PluginManifestRecord, + provider: string, +): ManifestActivationMatch | null { + const normalizedProvider = normalizeProviderId(provider); + if (!normalizedProvider) { + return null; + } + const stableProviderIds = [ ...plugin.providers, - ...(plugin.setup?.providers?.map((provider) => provider.id) ?? []), + ...(plugin.setup?.providers?.map((entry) => entry.id) ?? []), ] .map((value) => normalizeProviderId(value)) .filter(Boolean); + if (stableProviderIds.includes(normalizedProvider)) { + return { reason: `provider:${normalizedProvider}` }; + } + return matchLegacyActivationList( + (plugin.activation?.onProviders ?? []) + .map((value) => normalizeProviderId(value)) + .filter(Boolean), + normalizedProvider, + "provider", + ); } -function listActivationChannelIds(plugin: PluginManifestRecord): string[] { - return [...(plugin.activation?.onChannels ?? []), ...plugin.channels] - .map(normalizeCommandId) - .filter(Boolean); +function matchActivationChannel( + plugin: PluginManifestRecord, + channel: string, +): ManifestActivationMatch | null { + const normalizedChannel = normalizeCommandId(channel); + if (!normalizedChannel) { + return null; + } + const stableChannelIds = plugin.channels.map(normalizeCommandId).filter(Boolean); + if (stableChannelIds.includes(normalizedChannel)) { + return { reason: `channel:${normalizedChannel}` }; + } + return matchLegacyActivationList( + (plugin.activation?.onChannels ?? []).map(normalizeCommandId).filter(Boolean), + normalizedChannel, + "channel", + ); } function listActivationRouteIds(plugin: PluginManifestRecord): string[] { return (plugin.activation?.onRoutes ?? []).map(normalizeCommandId).filter(Boolean); } -function hasActivationCapability( +function matchActivationCapability( plugin: PluginManifestRecord, capability: PluginManifestActivationCapability, -): boolean { - if (plugin.activation?.onCapabilities?.includes(capability)) { - return true; - } +): ManifestActivationMatch | null { switch (capability) { - case "provider": - return listActivationProviderIds(plugin).length > 0; + case "provider": { + const hasProviderOwnership = + plugin.providers.length > 0 || (plugin.setup?.providers?.length ?? 0) > 0; + if (hasProviderOwnership) { + return { reason: "capability:provider" }; + } + break; + } case "channel": - return listActivationChannelIds(plugin).length > 0; + if (plugin.channels.length > 0) { + return { reason: "capability:channel" }; + } + break; case "tool": - return (plugin.contracts?.tools?.length ?? 0) > 0; + if ((plugin.contracts?.tools?.length ?? 0) > 0) { + return { reason: "capability:tool" }; + } + break; case "hook": - return plugin.hooks.length > 0; + if (plugin.hooks.length > 0) { + return { reason: "capability:hook" }; + } + break; } - const unreachableCapability: never = capability; - return unreachableCapability; + if (plugin.activation?.onCapabilities?.includes(capability)) { + return { + reason: `capability:${capability}`, + compatReason: PLUGIN_COMPAT_REASON.legacyActivationField, + }; + } + return null; } function normalizeCommandId(value: string | undefined): string { diff --git a/src/plugins/compat-reasons.ts b/src/plugins/compat-reasons.ts new file mode 100644 index 00000000000..03d4c56b533 --- /dev/null +++ b/src/plugins/compat-reasons.ts @@ -0,0 +1,10 @@ +export const PLUGIN_COMPAT_REASON = { + legacyActivationField: "legacy-activation-field", + legacySetupApi: "legacy-setup-api", + legacyRootSdkImport: "legacy-root-sdk-import", + legacyGlobalRegistry: "legacy-global-registry", + legacyManifestOwnerFallback: "legacy-manifest-owner-fallback", + legacyHookStage: "legacy-hook-stage", +} as const; + +export type PluginCompatReason = (typeof PLUGIN_COMPAT_REASON)[keyof typeof PLUGIN_COMPAT_REASON]; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index eaaa01eed5c..c391b8ef9ec 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -57,19 +57,19 @@ export type PluginManifestActivationCapability = "provider" | "channel" | "tool" export type PluginManifestActivation = { /** - * Provider ids that should activate this plugin when explicitly requested. - * This is metadata only; runtime loading still happens through the loader. + * Legacy provider activation hints. Prefer top-level `providers` and setup + * provider ownership for new manifests. */ onProviders?: string[]; - /** Agent harness runtime ids that should activate this plugin. */ + /** Legacy agent harness runtime activation hints. */ onAgentHarnesses?: string[]; - /** Command ids that should activate this plugin. */ + /** Legacy command activation hints. Prefer command aliases or CLI descriptors. */ onCommands?: string[]; - /** Channel ids that should activate this plugin. */ + /** Legacy channel activation hints. Prefer top-level `channels`. */ onChannels?: string[]; - /** Route kinds that should activate this plugin. */ + /** Legacy route activation hints. */ onRoutes?: string[]; - /** Cheap capability hints used by future activation planning. */ + /** Legacy broad capability hints. Do not add new uses. */ onCapabilities?: PluginManifestActivationCapability[]; };