import { useCallback, useEffect } from 'react' import type { Command } from '../commands.js' import { useNotifications } from '../context/notifications.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../services/analytics/index.js' import { reinitializeLspServerManager } from '../services/lsp/manager.js' import { useAppState, useSetAppState } from '../state/AppState.js' import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' import { count } from '../utils/array.js' import { logForDebugging } from '../utils/debug.js' import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' import { toError } from '../utils/errors.js' import { logError } from '../utils/log.js' import { loadPluginAgents } from '../utils/plugins/loadPluginAgents.js' import { getPluginCommands } from '../utils/plugins/loadPluginCommands.js' import { loadPluginHooks } from '../utils/plugins/loadPluginHooks.js' import { loadPluginLspServers } from '../utils/plugins/lspPluginIntegration.js' import { loadPluginMcpServers } from '../utils/plugins/mcpPluginIntegration.js' import { detectAndUninstallDelistedPlugins } from '../utils/plugins/pluginBlocklist.js' import { getFlaggedPlugins } from '../utils/plugins/pluginFlagging.js' import { loadAllPlugins } from '../utils/plugins/pluginLoader.js' /** * Hook to manage plugin state and synchronize with AppState. * * On mount: loads all plugins, runs delisting enforcement, surfaces flagged- * plugin notifications, populates AppState.plugins. This is the initial * Layer-3 load — subsequent refresh goes through /reload-plugins. * * On needsRefresh: shows a notification directing the user to /reload-plugins. * Does NOT auto-refresh. All Layer-3 swap (commands, agents, hooks, MCP) * goes through refreshActivePlugins() via /reload-plugins for one consistent * mental model. See Outline: declarative-settings-hXHBMDIf4b PR 5c. */ export function useManagePlugins({ enabled = true, }: { enabled?: boolean } = {}) { const setAppState = useSetAppState() const needsRefresh = useAppState(s => s.plugins.needsRefresh) const { addNotification } = useNotifications() // Initial plugin load. Runs once on mount. NOT used for refresh — all // post-mount refresh goes through /reload-plugins → refreshActivePlugins(). // Unlike refreshActivePlugins, this also runs delisting enforcement and // flagged-plugin notifications (session-start concerns), and does NOT bump // mcp.pluginReconnectKey (MCP effects fire on their own mount). const initialPluginLoad = useCallback(async () => { try { // Load all plugins - capture errors array const { enabled, disabled, errors } = await loadAllPlugins() // Detect delisted plugins, auto-uninstall them, and record as flagged. await detectAndUninstallDelistedPlugins() // Notify if there are flagged plugins pending dismissal const flagged = getFlaggedPlugins() if (Object.keys(flagged).length > 0) { addNotification({ key: 'plugin-delisted-flagged', text: 'Plugins flagged. Check /plugins', color: 'warning', priority: 'high', }) } // Load commands, agents, and hooks with individual error handling // Errors are added to the errors array for user visibility in Doctor UI let commands: Command[] = [] let agents: AgentDefinition[] = [] try { commands = await getPluginCommands() } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) errors.push({ type: 'generic-error', source: 'plugin-commands', error: `Failed to load plugin commands: ${errorMessage}`, }) } try { agents = await loadPluginAgents() } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) errors.push({ type: 'generic-error', source: 'plugin-agents', error: `Failed to load plugin agents: ${errorMessage}`, }) } try { await loadPluginHooks() } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) errors.push({ type: 'generic-error', source: 'plugin-hooks', error: `Failed to load plugin hooks: ${errorMessage}`, }) } // Load MCP server configs per plugin to get an accurate count. // LoadedPlugin.mcpServers is not populated by loadAllPlugins — it's a // cache slot that extractMcpServersFromPlugins fills later, which races // with this metric. Calling loadPluginMcpServers directly (as // cli/handlers/plugins.ts does) gives the correct count and also // warms the cache for the MCP connection manager. // // Runs BEFORE setAppState so any errors pushed by these loaders make it // into AppState.plugins.errors (Doctor UI), not just telemetry. const mcpServerCounts = await Promise.all( enabled.map(async p => { if (p.mcpServers) return Object.keys(p.mcpServers).length const servers = await loadPluginMcpServers(p, errors) if (servers) p.mcpServers = servers return servers ? Object.keys(servers).length : 0 }), ) const mcp_count = mcpServerCounts.reduce((sum, n) => sum + n, 0) // LSP: the primary fix for issue #15521 is in refresh.ts (via // performBackgroundPluginInstallations → refreshActivePlugins, which // clears caches first). This reinit is defensive — it reads the same // memoized loadAllPlugins() result as the original init unless a cache // invalidation happened between main.tsx:3203 and REPL mount (e.g. // seed marketplace registration or policySettings hot-reload). const lspServerCounts = await Promise.all( enabled.map(async p => { if (p.lspServers) return Object.keys(p.lspServers).length const servers = await loadPluginLspServers(p, errors) if (servers) p.lspServers = servers return servers ? Object.keys(servers).length : 0 }), ) const lsp_count = lspServerCounts.reduce((sum, n) => sum + n, 0) reinitializeLspServerManager() // Update AppState - merge errors to preserve LSP errors setAppState(prevState => { // Keep existing LSP/non-plugin-loading errors (source 'lsp-manager' or 'plugin:*') const existingLspErrors = prevState.plugins.errors.filter( e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'), ) // Deduplicate: remove existing LSP errors that are also in new errors const newErrorKeys = new Set( errors.map(e => e.type === 'generic-error' ? `generic-error:${e.source}:${e.error}` : `${e.type}:${e.source}`, ), ) const filteredExisting = existingLspErrors.filter(e => { const key = e.type === 'generic-error' ? `generic-error:${e.source}:${e.error}` : `${e.type}:${e.source}` return !newErrorKeys.has(key) }) const mergedErrors = [...filteredExisting, ...errors] return { ...prevState, plugins: { ...prevState.plugins, enabled, disabled, commands, errors: mergedErrors, }, } }) logForDebugging( `Loaded plugins - Enabled: ${enabled.length}, Disabled: ${disabled.length}, Commands: ${commands.length}, Agents: ${agents.length}, Errors: ${errors.length}`, ) // Count component types across enabled plugins const hook_count = enabled.reduce((sum, p) => { if (!p.hooksConfig) return sum return ( sum + Object.values(p.hooksConfig).reduce( (s, matchers) => s + (matchers?.reduce((h, m) => h + m.hooks.length, 0) ?? 0), 0, ) ) }, 0) return { enabled_count: enabled.length, disabled_count: disabled.length, inline_count: count(enabled, p => p.source.endsWith('@inline')), marketplace_count: count(enabled, p => !p.source.endsWith('@inline')), error_count: errors.length, skill_count: commands.length, agent_count: agents.length, hook_count, mcp_count, lsp_count, // Ant-only: which plugins are enabled, to correlate with RSS/FPS. // Kept separate from base metrics so it doesn't flow into // logForDiagnosticsNoPII. ant_enabled_names: process.env.USER_TYPE === 'ant' && enabled.length > 0 ? (enabled .map(p => p.name) .sort() .join( ',', ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) : undefined, } } catch (error) { // Only plugin loading errors should reach here - log for monitoring const errorObj = toError(error) logError(errorObj) logForDebugging(`Error loading plugins: ${error}`) // Set empty state on error, but preserve LSP errors and add the new error setAppState(prevState => { // Keep existing LSP/non-plugin-loading errors const existingLspErrors = prevState.plugins.errors.filter( e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'), ) const newError = { type: 'generic-error' as const, source: 'plugin-system', error: errorObj.message, } return { ...prevState, plugins: { ...prevState.plugins, enabled: [], disabled: [], commands: [], errors: [...existingLspErrors, newError], }, } }) return { enabled_count: 0, disabled_count: 0, inline_count: 0, marketplace_count: 0, error_count: 1, skill_count: 0, agent_count: 0, hook_count: 0, mcp_count: 0, lsp_count: 0, load_failed: true, ant_enabled_names: undefined, } } }, [setAppState, addNotification]) // Load plugins on mount and emit telemetry useEffect(() => { if (!enabled) return void initialPluginLoad().then(metrics => { const { ant_enabled_names, ...baseMetrics } = metrics const allMetrics = { ...baseMetrics, has_custom_plugin_cache_dir: !!process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR, } logEvent('tengu_plugins_loaded', { ...allMetrics, ...(ant_enabled_names !== undefined && { enabled_names: ant_enabled_names, }), }) logForDiagnosticsNoPII('info', 'tengu_plugins_loaded', allMetrics) }) }, [initialPluginLoad, enabled]) // Plugin state changed on disk (background reconcile, /plugin menu, // external settings edit). Show a notification; user runs /reload-plugins // to apply. The previous auto-refresh here had a stale-cache bug (only // cleared loadAllPlugins, downstream memoized loaders returned old data) // and was incomplete (no MCP, no agentDefinitions). /reload-plugins // handles all of that correctly via refreshActivePlugins(). useEffect(() => { if (!enabled || !needsRefresh) return addNotification({ key: 'plugin-reload-pending', text: 'Plugins changed. Run /reload-plugins to activate.', color: 'suggestion', priority: 'low', }) // Do NOT auto-refresh. Do NOT reset needsRefresh — /reload-plugins // consumes it via refreshActivePlugins(). }, [enabled, needsRefresh, addNotification]) }