/** * Plugin Manager Component * * Provides UI for managing installed plugins: * - List installed plugins with state indicators * - Install plugins from local paths * - Enable/disable plugins * - Configure plugin settings * - Uninstall plugins * - Live updates via SSE (plugin:lifecycle events) */ import "./PluginManager.css"; import { useState, useEffect, useCallback, useRef } from "react"; import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw, ExternalLink, Shield } from "lucide-react"; import { fetchPlugins, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin, fetchPluginSetupStatus, installPluginSetup, updatePlugin, rescanPlugin } from "../api"; import { DirectoryPicker } from "./DirectoryPicker"; import type { PluginInstallation, PluginState, PluginSettingSchema } from "@fusion/core"; import type { PluginSetupStatusResponse } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useConfirm } from "../hooks/useConfirm"; import { subscribeSse } from "../sse-bus"; /** Normalized plugin lifecycle payload from SSE plugin:lifecycle events */ interface PluginLifecyclePayload { scope: "global" | "project"; pluginId: string; transition: "installing" | "enabled" | "disabled" | "error" | "state-changed" | "uninstalled" | "settings-updated"; sourceEvent: string; timestamp: string; projectId?: string; enabled: boolean; state: PluginState; version: string; settings: Record; error?: string; } interface PluginManagerProps { addToast: (message: string, type?: ToastType) => void; projectId?: string; } interface BuiltinPlugin { id: string; name: string; description: string; category: "runtime" | "integration"; path?: string; experimental?: boolean; hasSetup?: boolean; } export const BUILTIN_AGENT_BROWSER_PLUGIN_ID = "fusion-plugin-agent-browser"; export const AGENT_BROWSER_SETTINGS_SCHEMA: Record = { enabled: { type: "boolean", label: "Enable Agent Browser", group: "General" }, installChannel: { type: "enum", label: "Install Channel", enumValues: ["stable", "beta", "nightly"], defaultValue: "stable", group: "General", }, commandTimeoutMs: { type: "number", label: "Command Timeout (ms)", defaultValue: 120000, group: "General", }, headlessMode: { type: "boolean", label: "Headless Mode", defaultValue: true, group: "Browser" }, allowedDomains: { type: "array", label: "Allowed Domains", itemType: "string", group: "Browser" }, promptExecutorSystem: { type: "string", label: "Executor System Prompt", multiline: true, group: "Prompt Contributions" }, promptExecutorTask: { type: "string", label: "Executor Task Prompt", multiline: true, group: "Prompt Contributions" }, promptTriage: { type: "string", label: "Triage Prompt", multiline: true, group: "Prompt Contributions" }, promptReviewer: { type: "string", label: "Reviewer Prompt", multiline: true, group: "Prompt Contributions" }, promptHeartbeat: { type: "string", label: "Heartbeat Prompt", multiline: true, group: "Prompt Contributions" }, skillExposure: { type: "enum", label: "Skill Exposure", enumValues: ["none", "selected", "all"], defaultValue: "selected", group: "Skills", }, }; const BUILTIN_PLUGINS: BuiltinPlugin[] = [ { id: "fusion-plugin-hermes-runtime", name: "Hermes Runtime", description: "Runtime provider for Hermes CLI-backed execution.", category: "runtime", path: "./plugins/fusion-plugin-hermes-runtime", experimental: true, }, { id: "fusion-plugin-paperclip-runtime", name: "Paperclip Runtime", description: "Runtime provider for Paperclip agent connections.", category: "runtime", path: "./plugins/fusion-plugin-paperclip-runtime", }, { id: "fusion-plugin-openclaw-runtime", name: "OpenClaw Runtime", description: "Runtime provider for OpenClaw execution.", category: "runtime", path: "./plugins/fusion-plugin-openclaw-runtime", experimental: true, }, { id: "fusion-plugin-droid-runtime", name: "Droid Runtime", description: "Runtime provider for Droid CLI execution.", category: "runtime", path: "./plugins/fusion-plugin-droid-runtime", experimental: true, }, { id: "fusion-plugin-dependency-graph", name: "Dependency Graph", description: "Dashboard plugin for task dependency graph visualization.", category: "integration", path: "./plugins/fusion-plugin-dependency-graph", }, { id: "fusion-plugin-reports", name: "Reports", description: "View report history, compare runs side-by-side, and export standalone HTML summaries.", category: "integration", path: "./plugins/fusion-plugin-reports", }, { id: "fusion-plugin-whatsapp-chat", name: "WhatsApp Chat", description: "Pairs to WhatsApp Web (multi-device) with QR or pairing code, then bridges direct chats to a Fusion agent.", category: "integration", path: "./plugins/fusion-plugin-whatsapp-chat", }, { id: "fusion-plugin-cli-printing-press", name: "CLI Printing Press", description: "Guided wizard for drafting external service CLI definitions.", category: "integration", path: "./plugins/fusion-plugin-cli-printing-press", }, { id: BUILTIN_AGENT_BROWSER_PLUGIN_ID, name: "Agent Browser", description: "Built-in integration metadata. Package install support lands in FN-3101.", category: "integration", hasSetup: true, }, ]; export const STATE_COLORS: Record = { started: "var(--color-success)", loaded: "var(--color-warning)", error: "var(--color-error)", stopped: "var(--color-muted)", installed: "var(--color-info)", }; function resolveSettingsSchema(plugin: PluginInstallation): Record | undefined { const pluginSchema = plugin.settingsSchema; const hasPluginSchema = pluginSchema && Object.keys(pluginSchema).length > 0; if (plugin.id !== BUILTIN_AGENT_BROWSER_PLUGIN_ID) { return hasPluginSchema ? pluginSchema : undefined; } if (!hasPluginSchema) { return AGENT_BROWSER_SETTINGS_SCHEMA; } return { ...AGENT_BROWSER_SETTINGS_SCHEMA, ...pluginSchema, }; } function groupSettingsSchema(settingsSchema: Record) { const grouped = new Map>(); const ungrouped: Array<[string, PluginSettingSchema]> = []; for (const [key, schema] of Object.entries(settingsSchema)) { if (schema.group) { const groupItems = grouped.get(schema.group) ?? []; groupItems.push([key, schema]); grouped.set(schema.group, groupItems); } else { ungrouped.push([key, schema]); } } return { grouped, ungrouped }; } function renderPluginError(plugin: PluginInstallation, className = "plugin-error-text") { if (plugin.state !== "error" || !plugin.error) { return null; } return (

{plugin.error}

); } export function PluginManager({ addToast, projectId }: PluginManagerProps) { const [plugins, setPlugins] = useState([]); const [loading, setLoading] = useState(true); const [showInstall, setShowInstall] = useState(false); const [installPath, setInstallPath] = useState(""); const [installing, setInstalling] = useState(false); const [installAiScanOnLoad, setInstallAiScanOnLoad] = useState(false); const [reloadingPluginId, setReloadingPluginId] = useState(null); const [selectedPlugin, setSelectedPlugin] = useState(null); const [pluginSettings, setPluginSettings] = useState>({}); const [settingsLoading, setSettingsLoading] = useState(false); const [installingBuiltinPluginId, setInstallingBuiltinPluginId] = useState(null); const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState>({}); const [loadingBuiltinSetupId, setLoadingBuiltinSetupId] = useState(null); const [installingBuiltinSetupId, setInstallingBuiltinSetupId] = useState(null); const { confirm } = useConfirm(); const loadPlugins = useCallback(async () => { try { setLoading(true); const data = await fetchPlugins(projectId); setPlugins(data); } catch (err) { addToast(`Failed to load plugins: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setLoading(false); } }, [projectId, addToast]); useEffect(() => { loadPlugins(); }, [loadPlugins]); useEffect(() => { const installedBuiltinsWithSetup = BUILTIN_PLUGINS.filter((builtinPlugin) => ( builtinPlugin.hasSetup && plugins.some((plugin) => plugin.id === builtinPlugin.id) )); if (installedBuiltinsWithSetup.length === 0) { return; } let cancelled = false; void Promise.all(installedBuiltinsWithSetup.map(async (builtinPlugin) => { try { const response = await fetchPluginSetupStatus(builtinPlugin.id, projectId); if (cancelled) { return; } setBuiltinSetupStatusById((prev) => ({ ...prev, [builtinPlugin.id]: response })); } catch { if (cancelled) { return; } setBuiltinSetupStatusById((prev) => ({ ...prev, [builtinPlugin.id]: { hasSetup: true, setupCheckDeferred: true, deferredReason: "plugin-not-started", pluginState: "installed", }, })); } })); return () => { cancelled = true; }; }, [plugins, projectId]); // SSE live updates for plugin lifecycle events const pluginsRef = useRef([]); pluginsRef.current = plugins; useEffect(() => { const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const handlePluginLifecycle = (e: MessageEvent) => { try { const payload: PluginLifecyclePayload = JSON.parse(e.data); if (payload.scope === "project") { if ((payload.projectId ?? projectId) !== projectId) { return; } } switch (payload.transition) { case "installing": case "enabled": case "disabled": case "settings-updated": // Update existing plugin or add if new setPlugins((prev) => { const existingIndex = prev.findIndex((p) => p.id === payload.pluginId); if (existingIndex >= 0) { // Update existing plugin const updated = [...prev]; updated[existingIndex] = { ...updated[existingIndex], enabled: payload.enabled, state: payload.state, settings: payload.settings, error: payload.error, }; return updated; } else { // New plugin added via another session — refetch to get full data void loadPlugins(); return prev; } }); break; case "state-changed": setPlugins((prev) => { const existingIndex = prev.findIndex((p) => p.id === payload.pluginId); if (existingIndex >= 0) { const updated = [...prev]; updated[existingIndex] = { ...updated[existingIndex], state: payload.state, error: payload.error, }; return updated; } return prev; }); break; case "uninstalled": // Remove plugin from list setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId)); break; case "error": // Update plugin state to error setPlugins((prev) => { const existingIndex = prev.findIndex((p) => p.id === payload.pluginId); if (existingIndex >= 0) { const updated = [...prev]; updated[existingIndex] = { ...updated[existingIndex], state: payload.state, error: payload.error, }; return updated; } return prev; }); break; } } catch { // Ignore parse errors } }; return subscribeSse(`/api/events${query}`, { events: { "plugin:lifecycle": handlePluginLifecycle }, onReconnect: () => { // Re-sync plugin list after a forced reconnect — any events that // occurred while disconnected would otherwise be missed. void loadPlugins(); }, }); }, [projectId, loadPlugins]); const handleInstall = async () => { if (!installPath.trim()) { addToast("Please enter a plugin path", "error"); return; } try { setInstalling(true); await installPlugin({ path: installPath, ...(installAiScanOnLoad ? { aiScanOnLoad: true } : {}) }, projectId); addToast("Plugin installed globally", "success"); setShowInstall(false); setInstallPath(""); setInstallAiScanOnLoad(false); await loadPlugins(); } catch (err) { addToast(`Failed to install plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setInstalling(false); } }; const handleInstallBuiltinPlugin = async (plugin: BuiltinPlugin) => { if (!plugin.path) { addToast(`${plugin.name} is built in and does not have an installable package yet`, "warning"); return; } try { setInstallingBuiltinPluginId(plugin.id); await installPlugin({ path: plugin.path }, projectId); addToast(`${plugin.name} installed globally`, "success"); await loadPlugins(); } catch (err) { addToast(`Failed to install ${plugin.name}: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setInstallingBuiltinPluginId(null); } }; const handleInstallBuiltinSetup = async (plugin: BuiltinPlugin) => { try { setInstallingBuiltinSetupId(plugin.id); const result = await installPluginSetup(plugin.id, projectId); if (!result.success) { addToast(`Failed to install ${plugin.name} setup: ${result.error ?? "unknown error"}`, "error"); return; } addToast(`${plugin.name} setup installed`, "success"); setLoadingBuiltinSetupId(plugin.id); const setupStatus = await fetchPluginSetupStatus(plugin.id, projectId); setBuiltinSetupStatusById((prev) => ({ ...prev, [plugin.id]: setupStatus })); } catch (err) { addToast(`Failed to install ${plugin.name} setup: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setInstallingBuiltinSetupId(null); setLoadingBuiltinSetupId(null); } }; const handleEnable = async (plugin: PluginInstallation) => { try { const enabledPlugin = await enablePlugin(plugin.id, projectId); if (enabledPlugin.state === "error") { addToast(`Failed to enable ${plugin.name}: ${enabledPlugin.error ?? "Unknown error"}`, "error"); await loadPlugins(); return; } addToast(`${plugin.name} enabled for this project`, "success"); await loadPlugins(); } catch (err) { addToast(`Failed to enable plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const handleDisable = async (plugin: PluginInstallation) => { try { await disablePlugin(plugin.id, projectId); addToast(`${plugin.name} disabled for this project`, "success"); await loadPlugins(); } catch (err) { addToast(`Failed to disable plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const handleReload = async (plugin: PluginInstallation) => { try { setReloadingPluginId(plugin.id); await reloadPlugin(plugin.id, projectId); addToast(`${plugin.name} reloaded`, "success"); await loadPlugins(); } catch (err) { addToast(`Failed to reload plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setReloadingPluginId(null); } }; const handleUninstall = async (plugin: PluginInstallation) => { const shouldUninstall = await confirm({ title: "Uninstall Plugin Globally", message: `Are you sure you want to uninstall "${plugin.name}" globally (all projects)?`, danger: true, }); if (!shouldUninstall) { return; } try { await uninstallPlugin(plugin.id, projectId); addToast(`${plugin.name} uninstalled globally`, "success"); await loadPlugins(); setSelectedPlugin(null); } catch (err) { addToast(`Failed to uninstall plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const handleToggleAiScanOnLoad = async (plugin: PluginInstallation, aiScanOnLoad: boolean) => { try { await updatePlugin(plugin.id, { aiScanOnLoad }, projectId); addToast(`AI scan on load ${aiScanOnLoad ? "enabled" : "disabled"}`, "success"); await loadPlugins(); } catch (err) { addToast(`Failed to update plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const handleRescan = async (plugin: PluginInstallation) => { try { await rescanPlugin(plugin.id, projectId); addToast(`${plugin.name} rescanned`, "success"); await loadPlugins(); } catch (err) { addToast(`Failed to rescan plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const handleSelectPlugin = async (plugin: PluginInstallation) => { setSelectedPlugin(plugin); try { setSettingsLoading(true); const settings = await fetchPluginSettings(plugin.id, projectId); setPluginSettings(settings); } catch { setPluginSettings({}); } finally { setSettingsLoading(false); } }; const handleSaveSettings = async () => { if (!selectedPlugin) return; try { await updatePluginSettings(selectedPlugin.id, pluginSettings, projectId); addToast("Settings saved", "success"); } catch (err) { addToast(`Failed to save settings: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; // Plugin detail view if (selectedPlugin) { return (

{selectedPlugin.name}

{renderPluginError(selectedPlugin, "plugin-error-text plugin-error-text--detail")}
{selectedPlugin.state}
{selectedPlugin.description && (

{selectedPlugin.description}

)} {selectedPlugin.author && (

Author: {selectedPlugin.author}

)} {selectedPlugin.homepage && (

Homepage: {selectedPlugin.homepage}

)}

Version: {selectedPlugin.version}

Security Scan

Turning this on only updates configuration. Use Rescan and Reload to run it now.

{selectedPlugin.lastSecurityScan ? (
{selectedPlugin.lastSecurityScan.verdict} {selectedPlugin.lastSecurityScan.scannedAt}

{selectedPlugin.lastSecurityScan.summary}

Findings ({selectedPlugin.lastSecurityScan.findings.length})
    {selectedPlugin.lastSecurityScan.findings.map((finding, index) => (
  • {finding.severity} {finding.category} — {finding.file}: {finding.reason}
  • ))}
) : (

No security scan has been run yet.

)}
Settings
{settingsLoading ? (

Loading...

) : (() => { const effectiveSettingsSchema = resolveSettingsSchema(selectedPlugin); return effectiveSettingsSchema && Object.keys(effectiveSettingsSchema).length > 0 ? (
{(() => { const { grouped, ungrouped } = groupSettingsSchema(effectiveSettingsSchema); const sections: Array<{ title: string | null; entries: Array<[string, PluginSettingSchema]> }> = []; if (ungrouped.length > 0) { sections.push({ title: null, entries: ungrouped }); } for (const [groupName, entries] of grouped.entries()) { sections.push({ title: groupName, entries }); } return sections.map((section) => (
{section.title && (
{section.title}
)} {section.entries.map(([key, schema]) => { const helpId = `setting-${key}-help`; return (
{schema.type === "string" && !schema.multiline && ( setPluginSettings({ ...pluginSettings, [key]: e.target.value })} placeholder={schema.description} aria-describedby={schema.description && !schema.required ? helpId : undefined} /> )} {schema.type === "string" && schema.multiline && (