import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { normalizePluginsConfig } from "./config-state.js"; import { loadOpenClawPlugins } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import type { PluginRegistry } from "./registry.js"; import type { PluginDiagnostic, PluginHookName } from "./types.js"; export type PluginStatusReport = PluginRegistry & { workspaceDir?: string; }; export type PluginCapabilityKind = | "text-inference" | "speech" | "media-understanding" | "image-generation" | "web-search" | "channel"; export type PluginInspectShape = | "hook-only" | "plain-capability" | "hybrid-capability" | "non-capability"; export type PluginCompatibilityNotice = { pluginId: string; code: "legacy-before-agent-start" | "hook-only"; severity: "warn" | "info"; message: string; }; export type PluginCompatibilitySummary = { noticeCount: number; pluginCount: number; }; export type PluginInspectReport = { workspaceDir?: string; plugin: PluginRegistry["plugins"][number]; shape: PluginInspectShape; capabilityMode: "none" | "plain" | "hybrid"; capabilityCount: number; capabilities: Array<{ kind: PluginCapabilityKind; ids: string[]; }>; typedHooks: Array<{ name: PluginHookName; priority?: number; }>; customHooks: Array<{ name: string; events: string[]; }>; tools: Array<{ names: string[]; optional: boolean; }>; commands: string[]; cliCommands: string[]; services: string[]; gatewayMethods: string[]; mcpServers: Array<{ name: string; hasStdioTransport: boolean; }>; lspServers: Array<{ name: string; hasStdioTransport: boolean; }>; httpRouteCount: number; bundleCapabilities: string[]; diagnostics: PluginDiagnostic[]; policy: { allowPromptInjection?: boolean; allowModelOverride?: boolean; allowedModels: string[]; hasAllowedModelsConfig: boolean; }; usesLegacyBeforeAgentStart: boolean; compatibility: PluginCompatibilityNotice[]; }; function buildCompatibilityNoticesForInspect( inspect: Pick, ): PluginCompatibilityNotice[] { const warnings: PluginCompatibilityNotice[] = []; if (inspect.usesLegacyBeforeAgentStart) { warnings.push({ pluginId: inspect.plugin.id, code: "legacy-before-agent-start", severity: "warn", message: "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }); } if (inspect.shape === "hook-only") { warnings.push({ pluginId: inspect.plugin.id, code: "hook-only", severity: "info", message: "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", }); } return warnings; } const log = createSubsystemLogger("plugins"); export function buildPluginStatusReport(params?: { config?: ReturnType; workspaceDir?: string; /** Use an explicit env when plugin roots should resolve independently from process.env. */ env?: NodeJS.ProcessEnv; }): PluginStatusReport { const config = params?.config ?? loadConfig(); const workspaceDir = params?.workspaceDir ? params.workspaceDir : (resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)) ?? resolveDefaultAgentWorkspaceDir()); const registry = loadOpenClawPlugins({ config, workspaceDir, env: params?.env, logger: createPluginLoaderLogger(log), }); return { workspaceDir, ...registry, }; } function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) { return [ { kind: "text-inference" as const, ids: plugin.providerIds }, { kind: "speech" as const, ids: plugin.speechProviderIds }, { kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds }, { kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds }, { kind: "web-search" as const, ids: plugin.webSearchProviderIds }, { kind: "channel" as const, ids: plugin.channelIds }, ].filter((entry) => entry.ids.length > 0); } function deriveInspectShape(params: { capabilityCount: number; typedHookCount: number; customHookCount: number; toolCount: number; commandCount: number; cliCount: number; serviceCount: number; gatewayMethodCount: number; httpRouteCount: number; }): PluginInspectShape { if (params.capabilityCount > 1) { return "hybrid-capability"; } if (params.capabilityCount === 1) { return "plain-capability"; } const hasOnlyHooks = params.typedHookCount + params.customHookCount > 0 && params.toolCount === 0 && params.commandCount === 0 && params.cliCount === 0 && params.serviceCount === 0 && params.gatewayMethodCount === 0 && params.httpRouteCount === 0; if (hasOnlyHooks) { return "hook-only"; } return "non-capability"; } export function buildPluginInspectReport(params: { id: string; config?: ReturnType; workspaceDir?: string; env?: NodeJS.ProcessEnv; report?: PluginStatusReport; }): PluginInspectReport | null { const config = params.config ?? loadConfig(); const report = params.report ?? buildPluginStatusReport({ config, workspaceDir: params.workspaceDir, env: params.env, }); const plugin = report.plugins.find((entry) => entry.id === params.id || entry.name === params.id); if (!plugin) { return null; } const capabilities = buildCapabilityEntries(plugin); const typedHooks = report.typedHooks .filter((entry) => entry.pluginId === plugin.id) .map((entry) => ({ name: entry.hookName, priority: entry.priority, })) .toSorted((a, b) => a.name.localeCompare(b.name)); const customHooks = report.hooks .filter((entry) => entry.pluginId === plugin.id) .map((entry) => ({ name: entry.entry.hook.name, events: [...entry.events].toSorted(), })) .toSorted((a, b) => a.name.localeCompare(b.name)); const tools = report.tools .filter((entry) => entry.pluginId === plugin.id) .map((entry) => ({ names: [...entry.names], optional: entry.optional, })); const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id); const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id]; const capabilityCount = capabilities.length; const shape = deriveInspectShape({ capabilityCount, typedHookCount: typedHooks.length, customHookCount: customHooks.length, toolCount: tools.length, commandCount: plugin.commands.length, cliCount: plugin.cliCommands.length, serviceCount: plugin.services.length, gatewayMethodCount: plugin.gatewayMethods.length, httpRouteCount: plugin.httpRoutes, }); // Populate MCP server info for bundle-format plugins with a known rootDir. let mcpServers: PluginInspectReport["mcpServers"] = []; if (plugin.format === "bundle" && plugin.bundleFormat && plugin.rootDir) { const mcpSupport = inspectBundleMcpRuntimeSupport({ pluginId: plugin.id, rootDir: plugin.rootDir, bundleFormat: plugin.bundleFormat, }); mcpServers = [ ...mcpSupport.supportedServerNames.map((name) => ({ name, hasStdioTransport: true, })), ...mcpSupport.unsupportedServerNames.map((name) => ({ name, hasStdioTransport: false, })), ]; } // Populate LSP server info for bundle-format plugins with a known rootDir. let lspServers: PluginInspectReport["lspServers"] = []; if (plugin.format === "bundle" && plugin.bundleFormat && plugin.rootDir) { const lspSupport = inspectBundleLspRuntimeSupport({ pluginId: plugin.id, rootDir: plugin.rootDir, bundleFormat: plugin.bundleFormat, }); lspServers = [ ...lspSupport.supportedServerNames.map((name) => ({ name, hasStdioTransport: true, })), ...lspSupport.unsupportedServerNames.map((name) => ({ name, hasStdioTransport: false, })), ]; } const usesLegacyBeforeAgentStart = typedHooks.some( (entry) => entry.name === "before_agent_start", ); const compatibility = buildCompatibilityNoticesForInspect({ plugin, shape, usesLegacyBeforeAgentStart, }); return { workspaceDir: report.workspaceDir, plugin, shape, capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid", capabilityCount, capabilities, typedHooks, customHooks, tools, commands: [...plugin.commands], cliCommands: [...plugin.cliCommands], services: [...plugin.services], gatewayMethods: [...plugin.gatewayMethods], mcpServers, lspServers, httpRouteCount: plugin.httpRoutes, bundleCapabilities: plugin.bundleCapabilities ?? [], diagnostics, policy: { allowPromptInjection: policyEntry?.hooks?.allowPromptInjection, allowModelOverride: policyEntry?.subagent?.allowModelOverride, allowedModels: [...(policyEntry?.subagent?.allowedModels ?? [])], hasAllowedModelsConfig: policyEntry?.subagent?.hasAllowedModelsConfig === true, }, usesLegacyBeforeAgentStart, compatibility, }; } export function buildAllPluginInspectReports(params?: { config?: ReturnType; workspaceDir?: string; env?: NodeJS.ProcessEnv; report?: PluginStatusReport; }): PluginInspectReport[] { const config = params?.config ?? loadConfig(); const report = params?.report ?? buildPluginStatusReport({ config, workspaceDir: params?.workspaceDir, env: params?.env, }); return report.plugins .map((plugin) => buildPluginInspectReport({ id: plugin.id, config, report, }), ) .filter((entry): entry is PluginInspectReport => entry !== null); } export function buildPluginCompatibilityWarnings(params?: { config?: ReturnType; workspaceDir?: string; env?: NodeJS.ProcessEnv; report?: PluginStatusReport; }): string[] { return buildPluginCompatibilityNotices(params).map(formatPluginCompatibilityNotice); } export function buildPluginCompatibilityNotices(params?: { config?: ReturnType; workspaceDir?: string; env?: NodeJS.ProcessEnv; report?: PluginStatusReport; }): PluginCompatibilityNotice[] { return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility); } export function formatPluginCompatibilityNotice(notice: PluginCompatibilityNotice): string { return `${notice.pluginId} ${notice.message}`; } export function summarizePluginCompatibility( notices: PluginCompatibilityNotice[], ): PluginCompatibilitySummary { return { noticeCount: notices.length, pluginCount: new Set(notices.map((notice) => notice.pluginId)).size, }; }