/** * Layer-3 refresh primitive: swap active plugin components in the running session. * * Three-layer model (see reconciler.ts for Layer-2): * - Layer 1: intent (settings) * - Layer 2: materialization (~/.claude/plugins/) — reconcileMarketplaces() * - Layer 3: active components (AppState) — this file * * Called from: * - /reload-plugins command (interactive, user-initiated) * - print.ts refreshPluginState() (headless, auto before first query with SYNC_PLUGIN_INSTALL) * - performBackgroundPluginInstallations() (background, auto after new marketplace install) * * NOT called from: * - useManagePlugins needsRefresh effect — interactive mode shows a notification; * user explicitly runs /reload-plugins (PR 5c) * - /plugin menu — sets needsRefresh, user runs /reload-plugins (PR 5b) */ import { getOriginalCwd } from '../../bootstrap/state.js' import type { Command } from '../../commands.js' import { reinitializeLspServerManager } from '../../services/lsp/manager.js' import type { AppState } from '../../state/AppState.js' import type { AgentDefinitionsResult } from '../../tools/AgentTool/loadAgentsDir.js' import { getAgentDefinitionsWithOverrides } from '../../tools/AgentTool/loadAgentsDir.js' import type { PluginError } from '../../types/plugin.js' import { logForDebugging } from '../debug.js' import { errorMessage } from '../errors.js' import { logError } from '../log.js' import { clearAllCaches } from './cacheUtils.js' import { getPluginCommands } from './loadPluginCommands.js' import { loadPluginHooks } from './loadPluginHooks.js' import { loadPluginLspServers } from './lspPluginIntegration.js' import { loadPluginMcpServers } from './mcpPluginIntegration.js' import { clearPluginCacheExclusions } from './orphanedPluginFilter.js' import { loadAllPlugins } from './pluginLoader.js' type SetAppState = (updater: (prev: AppState) => AppState) => void export type RefreshActivePluginsResult = { enabled_count: number disabled_count: number command_count: number agent_count: number hook_count: number mcp_count: number /** LSP servers provided by enabled plugins. reinitializeLspServerManager() * is called unconditionally so the manager picks these up (no-op if * manager was never initialized). */ lsp_count: number error_count: number /** The refreshed agent definitions, for callers (e.g. print.ts) that also * maintain a local mutable reference outside AppState. */ agentDefinitions: AgentDefinitionsResult /** The refreshed plugin commands, same rationale as agentDefinitions. */ pluginCommands: Command[] } /** * Refresh all active plugin components: commands, agents, hooks, MCP-reconnect * trigger, AppState plugin arrays. Clears ALL plugin caches (unlike the old * needsRefresh path which only cleared loadAllPlugins and returned stale data * from downstream memoized loaders). * * Consumes plugins.needsRefresh (sets to false). * Increments mcp.pluginReconnectKey so useManageMCPConnections effects re-run * and pick up new plugin MCP servers. * * LSP: if plugins now contribute LSP servers, reinitializeLspServerManager() * re-reads config. Servers are lazy-started so this is just config parsing. */ export async function refreshActivePlugins( setAppState: SetAppState, ): Promise { logForDebugging('refreshActivePlugins: clearing all plugin caches') clearAllCaches() // Orphan exclusions are session-frozen by default, but /reload-plugins is // an explicit "disk changed, re-read it" signal — recompute them too. clearPluginCacheExclusions() // Sequence the full load before cache-only consumers. Before #23693 all // three shared loadAllPlugins()'s memoize promise so Promise.all was a // no-op race. After #23693 getPluginCommands/getAgentDefinitions call // loadAllPluginsCacheOnly (separate memoize) — racing them means they // read installed_plugins.json before loadAllPlugins() has cloned+cached // the plugin, returning plugin-cache-miss. loadAllPlugins warms the // cache-only memoize on completion, so the awaits below are ~free. const pluginResult = await loadAllPlugins() const [pluginCommands, agentDefinitions] = await Promise.all([ getPluginCommands(), getAgentDefinitionsWithOverrides(getOriginalCwd()), ]) const { enabled, disabled, errors } = pluginResult // Populate mcpServers/lspServers on each enabled plugin. These are lazy // cache slots NOT filled by loadAllPlugins() — they're written later by // extractMcpServersFromPlugins/getPluginLspServers, which races with this. // Loading here gives accurate metrics AND warms the cache slots so the MCP // connection manager (triggered by pluginReconnectKey bump) sees the servers // without re-parsing manifests. Errors are pushed to the shared errors array. const [mcpCounts, lspCounts] = await Promise.all([ 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 }), ), 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 mcp_count = mcpCounts.reduce((sum, n) => sum + n, 0) const lsp_count = lspCounts.reduce((sum, n) => sum + n, 0) setAppState(prev => ({ ...prev, plugins: { ...prev.plugins, enabled, disabled, commands: pluginCommands, errors: mergePluginErrors(prev.plugins.errors, errors), needsRefresh: false, }, agentDefinitions, mcp: { ...prev.mcp, pluginReconnectKey: prev.mcp.pluginReconnectKey + 1, }, })) // Re-initialize LSP manager so newly-loaded plugin LSP servers are picked // up. No-op if LSP was never initialized (headless subcommand path). // Unconditional so removing the last LSP plugin also clears stale config. // Fixes issue #15521: LSP manager previously read a stale memoized // loadAllPlugins() result from before marketplaces were reconciled. reinitializeLspServerManager() // clearAllCaches() prunes removed-plugin hooks; this does the FULL swap // (adds hooks from newly-enabled plugins too). Catching here so // hook_load_failed can feed error_count; a failure doesn't lose the // plugin/command/agent data above (hooks go to STATE.registeredHooks, not // AppState). let hook_load_failed = false try { await loadPluginHooks() } catch (e) { hook_load_failed = true logError(e) logForDebugging( `refreshActivePlugins: loadPluginHooks failed: ${errorMessage(e)}`, ) } 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) logForDebugging( `refreshActivePlugins: ${enabled.length} enabled, ${pluginCommands.length} commands, ${agentDefinitions.allAgents.length} agents, ${hook_count} hooks, ${mcp_count} MCP, ${lsp_count} LSP`, ) return { enabled_count: enabled.length, disabled_count: disabled.length, command_count: pluginCommands.length, agent_count: agentDefinitions.allAgents.length, hook_count, mcp_count, lsp_count, error_count: errors.length + (hook_load_failed ? 1 : 0), agentDefinitions, pluginCommands, } } /** * Merge fresh plugin-load errors with existing errors, preserving LSP and * plugin-component errors that were recorded by other systems and * deduplicating. Same logic as refreshPlugins()/updatePluginState(), extracted * so refresh.ts doesn't leave those errors stranded. */ function mergePluginErrors( existing: PluginError[], fresh: PluginError[], ): PluginError[] { const preserved = existing.filter( e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'), ) const freshKeys = new Set(fresh.map(errorKey)) const deduped = preserved.filter(e => !freshKeys.has(errorKey(e))) return [...deduped, ...fresh] } function errorKey(e: PluginError): string { return e.type === 'generic-error' ? `generic-error:${e.source}:${e.error}` : `${e.type}:${e.source}` }