From acae0b60c2b1457bbba58a65da533915328d325c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:27:45 -0700 Subject: [PATCH] perf(plugins): lazy-load channel setup entrypoints --- docs/tools/plugin.md | 11 +-- extensions/discord/package.json | 3 +- extensions/discord/setup-entry.ts | 3 + extensions/imessage/package.json | 3 +- extensions/imessage/setup-entry.ts | 3 + extensions/signal/package.json | 3 +- extensions/signal/setup-entry.ts | 3 + extensions/slack/package.json | 3 +- extensions/slack/setup-entry.ts | 3 + extensions/telegram/package.json | 3 +- extensions/telegram/setup-entry.ts | 3 + extensions/whatsapp/package.json | 3 +- extensions/whatsapp/setup-entry.ts | 3 + src/commands/onboard-channels.ts | 59 +++++++++------- src/commands/onboarding/registry.ts | 74 ++++++++------------ src/plugins/loader.test.ts | 101 ++++++++++++++++++++++++++++ src/plugins/loader.ts | 33 ++++++++- src/plugins/registry.ts | 2 +- src/plugins/types.ts | 2 +- 19 files changed, 230 insertions(+), 88 deletions(-) create mode 100644 extensions/discord/setup-entry.ts create mode 100644 extensions/imessage/setup-entry.ts create mode 100644 extensions/signal/setup-entry.ts create mode 100644 extensions/slack/setup-entry.ts create mode 100644 extensions/telegram/setup-entry.ts create mode 100644 extensions/whatsapp/setup-entry.ts diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 91613cbe731..3987ff6a7eb 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -769,10 +769,11 @@ Security note: `openclaw plugins install` installs plugin dependencies with trees "pure JS/TS" and avoid packages that require `postinstall` builds. Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. -When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, it -loads `setupEntry` instead of the full plugin entry. This keeps startup and -onboarding lighter when your main plugin entry also wires tools, hooks, or -other runtime-only code. +When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, or +when a channel plugin is enabled but still unconfigured, it loads `setupEntry` +instead of the full plugin entry. This keeps startup and onboarding lighter +when your main plugin entry also wires tools, hooks, or other runtime-only +code. ### Channel catalog metadata @@ -1663,7 +1664,7 @@ Recommended packaging: Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. -- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled channel onboarding/setup. +- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel onboarding/setup. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. diff --git a/extensions/discord/package.json b/extensions/discord/package.json index a85eb37b85f..43e00315f28 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -6,6 +6,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts new file mode 100644 index 00000000000..56673347d64 --- /dev/null +++ b/extensions/discord/setup-entry.ts @@ -0,0 +1,3 @@ +import { discordPlugin } from "./src/channel.js"; + +export default { plugin: discordPlugin }; diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index c0988ee601c..591deea559b 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/imessage/setup-entry.ts b/extensions/imessage/setup-entry.ts new file mode 100644 index 00000000000..4b0cc6203e2 --- /dev/null +++ b/extensions/imessage/setup-entry.ts @@ -0,0 +1,3 @@ +import { imessagePlugin } from "./src/channel.js"; + +export default { plugin: imessagePlugin }; diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 67d6eae6506..f63128914c9 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/signal/setup-entry.ts b/extensions/signal/setup-entry.ts new file mode 100644 index 00000000000..afe80451845 --- /dev/null +++ b/extensions/signal/setup-entry.ts @@ -0,0 +1,3 @@ +import { signalPlugin } from "./src/channel.js"; + +export default { plugin: signalPlugin }; diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 183cdce7ad4..51439a37170 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/slack/setup-entry.ts b/extensions/slack/setup-entry.ts new file mode 100644 index 00000000000..d219e597148 --- /dev/null +++ b/extensions/slack/setup-entry.ts @@ -0,0 +1,3 @@ +import { slackPlugin } from "./src/channel.js"; + +export default { plugin: slackPlugin }; diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 92054ca01a3..deed30477a9 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts new file mode 100644 index 00000000000..b5e7fc8c073 --- /dev/null +++ b/extensions/telegram/setup-entry.ts @@ -0,0 +1,3 @@ +import { telegramPlugin } from "./src/channel.js"; + +export default { plugin: telegramPlugin }; diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index ec73a1b0613..356b2e3894b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts new file mode 100644 index 00000000000..0dd48c5b785 --- /dev/null +++ b/extensions/whatsapp/setup-entry.ts @@ -0,0 +1,3 @@ +import { whatsappPlugin } from "./src/channel.js"; + +export default { plugin: whatsappPlugin }; diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index cdb987914bc..cd269ac2cf9 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -5,7 +5,6 @@ import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../channels/plugins/setup-wizard.js"; import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, @@ -28,8 +27,8 @@ import { loadOnboardingPluginRegistrySnapshotForChannel, } from "./onboarding/plugin-install.js"; import { - getChannelOnboardingAdapter, - listChannelOnboardingAdapters, + loadBundledChannelOnboardingPlugin, + resolveChannelOnboardingAdapterForPlugin, } from "./onboarding/registry.js"; import type { ChannelOnboardingAdapter, @@ -121,7 +120,8 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; - installedPlugins?: ReturnType; + installedPlugins?: ChannelPlugin[]; + resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); @@ -134,14 +134,24 @@ async function collectChannelStatus(params: { }).plugins.flatMap((plugin) => plugin.channels), ); const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id)); + const resolveAdapter = + params.resolveAdapter ?? + ((channel: ChannelChoice) => + resolveChannelOnboardingAdapterForPlugin( + installedPlugins.find((plugin) => plugin.id === channel), + )); const statusEntries = await Promise.all( - listChannelOnboardingAdapters().map((adapter) => - adapter.getStatus({ + installedPlugins.flatMap((plugin) => { + const adapter = resolveAdapter(plugin.id); + if (!adapter) { + return []; + } + return adapter.getStatus({ cfg: params.cfg, options: params.options, accountOverrides: params.accountOverrides, - }), - ), + }); + }), ); const statusByChannel = new Map(statusEntries.map((entry) => [entry.channel, entry])); const fallbackStatuses = listChatChannels() @@ -270,7 +280,7 @@ async function maybeConfigureDmPolicies(params: { resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const { selection, prompter, accountIdsByChannel } = params; - const resolve = params.resolveAdapter ?? getChannelOnboardingAdapter; + const resolve = params.resolveAdapter; const dmPolicies = selection .map((channel) => resolve(channel)?.dmPolicy) .filter(Boolean) as ChannelOnboardingDmPolicy[]; @@ -362,10 +372,10 @@ export async function setupChannels( } return Array.from(merged.values()); }; - const loadScopedChannelPlugin = ( + const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, - ): ChannelPlugin | undefined => { + ): Promise => { const existing = getVisibleChannelPlugin(channel); if (existing) { return existing; @@ -382,22 +392,20 @@ export async function setupChannels( snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin; if (plugin) { rememberScopedPlugin(plugin); + return plugin; } - return plugin; + const bundledPlugin = await loadBundledChannelOnboardingPlugin(channel); + if (bundledPlugin) { + rememberScopedPlugin(bundledPlugin); + } + return bundledPlugin; }; const getVisibleOnboardingAdapter = (channel: ChannelChoice) => { - const adapter = getChannelOnboardingAdapter(channel); - if (adapter) { - return adapter; - } const scopedPlugin = scopedPluginsById.get(channel); - if (!scopedPlugin?.setupWizard) { - return undefined; + if (scopedPlugin) { + return resolveChannelOnboardingAdapterForPlugin(scopedPlugin); } - return buildChannelOnboardingAdapterFromSetupWizard({ - plugin: scopedPlugin, - wizard: scopedPlugin.setupWizard, - }); + return resolveChannelOnboardingAdapterForPlugin(getChannelSetupPlugin(channel)); }; const preloadConfiguredExternalPlugins = () => { // Keep onboarding memory bounded by snapshot-loading only configured external plugins. @@ -412,7 +420,7 @@ export async function setupChannels( if (!explicitlyEnabled && !isChannelConfigured(next, channel)) { continue; } - loadScopedChannelPlugin(channel, entry.pluginId); + void loadScopedChannelPlugin(channel, entry.pluginId); } }; if (options?.whatsappAccountId?.trim()) { @@ -426,6 +434,7 @@ export async function setupChannels( options, accountOverrides, installedPlugins: listVisibleInstalledPlugins(), + resolveAdapter: getVisibleOnboardingAdapter, }); if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); @@ -586,8 +595,8 @@ export async function setupChannels( ); return false; } + const plugin = await loadScopedChannelPlugin(channel); const adapter = getVisibleOnboardingAdapter(channel); - const plugin = loadScopedChannelPlugin(channel); if (!plugin) { if (adapter) { await prompter.note( @@ -752,7 +761,7 @@ export async function setupChannels( if (!result.installed) { return; } - loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); + await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); } else { const enabled = await enableBundledPluginForSetup(channel); diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 99009ee8fac..01bc0deeb7a 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,54 +1,15 @@ -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; -import { signalPlugin } from "../../../extensions/signal/src/channel.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; -const telegramOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: telegramPlugin, - wizard: telegramPlugin.setupWizard!, -}); -const discordOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: discordPlugin, - wizard: discordPlugin.setupWizard!, -}); -const slackOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: slackPlugin, - wizard: slackPlugin.setupWizard!, -}); -const signalOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: signalPlugin, - wizard: signalPlugin.setupWizard!, -}); -const imessageOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: imessagePlugin, - wizard: imessagePlugin.setupWizard!, -}); -const whatsappOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: whatsappPlugin, - wizard: whatsappPlugin.setupWizard!, -}); - -const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ - telegramOnboardingAdapter, - whatsappOnboardingAdapter, - discordOnboardingAdapter, - slackOnboardingAdapter, - signalOnboardingAdapter, - imessageOnboardingAdapter, -]; - const setupWizardAdapters = new WeakMap(); -function resolveChannelOnboardingAdapter( - plugin: ReturnType[number], +export function resolveChannelOnboardingAdapterForPlugin( + plugin?: ChannelPlugin, ): ChannelOnboardingAdapter | undefined { - if (plugin.setupWizard) { + if (plugin?.setupWizard) { const cached = setupWizardAdapters.get(plugin); if (cached) { return cached; @@ -64,11 +25,9 @@ function resolveChannelOnboardingAdapter( } const CHANNEL_ONBOARDING_ADAPTERS = () => { - const adapters = new Map( - BUILTIN_ONBOARDING_ADAPTERS.map((adapter) => [adapter.channel, adapter] as const), - ); + const adapters = new Map(); for (const plugin of listChannelSetupPlugins()) { - const adapter = resolveChannelOnboardingAdapter(plugin); + const adapter = resolveChannelOnboardingAdapterForPlugin(plugin); if (!adapter) { continue; } @@ -87,6 +46,27 @@ export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] { return Array.from(CHANNEL_ONBOARDING_ADAPTERS().values()); } +export async function loadBundledChannelOnboardingPlugin( + channel: ChannelChoice, +): Promise { + switch (channel) { + case "discord": + return (await import("../../../extensions/discord/setup-entry.js")).default.plugin; + case "imessage": + return (await import("../../../extensions/imessage/setup-entry.js")).default.plugin; + case "signal": + return (await import("../../../extensions/signal/setup-entry.js")).default.plugin; + case "slack": + return (await import("../../../extensions/slack/setup-entry.js")).default.plugin; + case "telegram": + return (await import("../../../extensions/telegram/setup-entry.js")).default.plugin; + case "whatsapp": + return (await import("../../../extensions/whatsapp/setup-entry.js")).default.plugin; + default: + return undefined; + } +} + // Legacy aliases (pre-rename). export const getProviderOnboardingAdapter = getChannelOnboardingAdapter; export const listProviderOnboardingAdapters = listChannelOnboardingAdapters; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index fb6805667cb..45710ef08bf 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1885,6 +1885,107 @@ module.exports = { expect(setupRegistry.channels).toHaveLength(0); }); + it("uses package setupEntry for enabled but unconfigured channel loads", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-runtime-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-runtime-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-runtime-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-runtime-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-runtime-test", + meta: { + id: "setup-runtime-test", + label: "Setup Runtime Test", + selectionLabel: "Setup Runtime Test", + docsPath: "/channels/setup-runtime-test", + blurb: "full entry should not run while unconfigured", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-runtime-test", + meta: { + id: "setup-runtime-test", + label: "Setup Runtime Test", + selectionLabel: "Setup Runtime Test", + docsPath: "/channels/setup-runtime-test", + blurb: "setup runtime", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-test"], + }, + }, + }); + + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + expect(registry.channelSetups).toHaveLength(1); + expect(registry.channels).toHaveLength(1); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 40fd3e36cfd..a58d0a640a2 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -5,6 +5,7 @@ import { createJiti } from "jiti"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; @@ -357,6 +358,20 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { }; } +function shouldLoadChannelPluginInSetupRuntime(params: { + manifestChannels: string[]; + setupSource?: string; + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): boolean { + if (!params.setupSource || params.manifestChannels.length === 0) { + return false; + } + return !params.manifestChannels.some((channelId) => + isChannelConfigured(params.cfg, channelId, params.env), + ); +} + function createPluginRecord(params: { id: string; name?: string; @@ -924,7 +939,15 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }; const registrationMode = enableState.enabled - ? "full" + ? !validateOnly && + shouldLoadChannelPluginInSetupRuntime({ + manifestChannels: manifestRecord.channels, + setupSource: manifestRecord.setupSource, + cfg, + env, + }) + ? "setup-runtime" + : "full" : includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0 ? "setup-only" : null; @@ -994,7 +1017,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const pluginRoot = safeRealpathOrResolve(candidate.rootDir); const loadSource = - registrationMode === "setup-only" && manifestRecord.setupSource + (registrationMode === "setup-only" || registrationMode === "setup-runtime") && + manifestRecord.setupSource ? manifestRecord.setupSource : candidate.source; const opened = openBoundaryFileSync({ @@ -1029,7 +1053,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - if (registrationMode === "setup-only" && manifestRecord.setupSource) { + if ( + (registrationMode === "setup-only" || registrationMode === "setup-runtime") && + manifestRecord.setupSource + ) { const setupRegistration = resolveSetupChannelRegistration(mod); if (setupRegistration.plugin) { if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 42e9c236909..9b450af26e7 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -481,7 +481,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id); - if (mode === "full" && existingRuntime) { + if (mode !== "setup-only" && existingRuntime) { pushDiagnostic({ level: "error", pluginId: record.id, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 9ad44fff40d..3b133642313 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -956,7 +956,7 @@ export type OpenClawPluginModule = | OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void | Promise); -export type PluginRegistrationMode = "full" | "setup-only"; +export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime"; export type OpenClawPluginApi = { id: string;