/ utils / plugins / refresh.ts
refresh.ts
  1  /**
  2   * Layer-3 refresh primitive: swap active plugin components in the running session.
  3   *
  4   * Three-layer model (see reconciler.ts for Layer-2):
  5   * - Layer 1: intent (settings)
  6   * - Layer 2: materialization (~/.claude/plugins/) — reconcileMarketplaces()
  7   * - Layer 3: active components (AppState) — this file
  8   *
  9   * Called from:
 10   * - /reload-plugins command (interactive, user-initiated)
 11   * - print.ts refreshPluginState() (headless, auto before first query with SYNC_PLUGIN_INSTALL)
 12   * - performBackgroundPluginInstallations() (background, auto after new marketplace install)
 13   *
 14   * NOT called from:
 15   * - useManagePlugins needsRefresh effect — interactive mode shows a notification;
 16   *   user explicitly runs /reload-plugins (PR 5c)
 17   * - /plugin menu — sets needsRefresh, user runs /reload-plugins (PR 5b)
 18   */
 19  
 20  import { getOriginalCwd } from '../../bootstrap/state.js'
 21  import type { Command } from '../../commands.js'
 22  import { reinitializeLspServerManager } from '../../services/lsp/manager.js'
 23  import type { AppState } from '../../state/AppState.js'
 24  import type { AgentDefinitionsResult } from '../../tools/AgentTool/loadAgentsDir.js'
 25  import { getAgentDefinitionsWithOverrides } from '../../tools/AgentTool/loadAgentsDir.js'
 26  import type { PluginError } from '../../types/plugin.js'
 27  import { logForDebugging } from '../debug.js'
 28  import { errorMessage } from '../errors.js'
 29  import { logError } from '../log.js'
 30  import { clearAllCaches } from './cacheUtils.js'
 31  import { getPluginCommands } from './loadPluginCommands.js'
 32  import { loadPluginHooks } from './loadPluginHooks.js'
 33  import { loadPluginLspServers } from './lspPluginIntegration.js'
 34  import { loadPluginMcpServers } from './mcpPluginIntegration.js'
 35  import { clearPluginCacheExclusions } from './orphanedPluginFilter.js'
 36  import { loadAllPlugins } from './pluginLoader.js'
 37  
 38  type SetAppState = (updater: (prev: AppState) => AppState) => void
 39  
 40  export type RefreshActivePluginsResult = {
 41    enabled_count: number
 42    disabled_count: number
 43    command_count: number
 44    agent_count: number
 45    hook_count: number
 46    mcp_count: number
 47    /** LSP servers provided by enabled plugins. reinitializeLspServerManager()
 48     * is called unconditionally so the manager picks these up (no-op if
 49     * manager was never initialized). */
 50    lsp_count: number
 51    error_count: number
 52    /** The refreshed agent definitions, for callers (e.g. print.ts) that also
 53     * maintain a local mutable reference outside AppState. */
 54    agentDefinitions: AgentDefinitionsResult
 55    /** The refreshed plugin commands, same rationale as agentDefinitions. */
 56    pluginCommands: Command[]
 57  }
 58  
 59  /**
 60   * Refresh all active plugin components: commands, agents, hooks, MCP-reconnect
 61   * trigger, AppState plugin arrays. Clears ALL plugin caches (unlike the old
 62   * needsRefresh path which only cleared loadAllPlugins and returned stale data
 63   * from downstream memoized loaders).
 64   *
 65   * Consumes plugins.needsRefresh (sets to false).
 66   * Increments mcp.pluginReconnectKey so useManageMCPConnections effects re-run
 67   * and pick up new plugin MCP servers.
 68   *
 69   * LSP: if plugins now contribute LSP servers, reinitializeLspServerManager()
 70   * re-reads config. Servers are lazy-started so this is just config parsing.
 71   */
 72  export async function refreshActivePlugins(
 73    setAppState: SetAppState,
 74  ): Promise<RefreshActivePluginsResult> {
 75    logForDebugging('refreshActivePlugins: clearing all plugin caches')
 76    clearAllCaches()
 77    // Orphan exclusions are session-frozen by default, but /reload-plugins is
 78    // an explicit "disk changed, re-read it" signal — recompute them too.
 79    clearPluginCacheExclusions()
 80  
 81    // Sequence the full load before cache-only consumers. Before #23693 all
 82    // three shared loadAllPlugins()'s memoize promise so Promise.all was a
 83    // no-op race. After #23693 getPluginCommands/getAgentDefinitions call
 84    // loadAllPluginsCacheOnly (separate memoize) — racing them means they
 85    // read installed_plugins.json before loadAllPlugins() has cloned+cached
 86    // the plugin, returning plugin-cache-miss. loadAllPlugins warms the
 87    // cache-only memoize on completion, so the awaits below are ~free.
 88    const pluginResult = await loadAllPlugins()
 89    const [pluginCommands, agentDefinitions] = await Promise.all([
 90      getPluginCommands(),
 91      getAgentDefinitionsWithOverrides(getOriginalCwd()),
 92    ])
 93  
 94    const { enabled, disabled, errors } = pluginResult
 95  
 96    // Populate mcpServers/lspServers on each enabled plugin. These are lazy
 97    // cache slots NOT filled by loadAllPlugins() — they're written later by
 98    // extractMcpServersFromPlugins/getPluginLspServers, which races with this.
 99    // Loading here gives accurate metrics AND warms the cache slots so the MCP
100    // connection manager (triggered by pluginReconnectKey bump) sees the servers
101    // without re-parsing manifests. Errors are pushed to the shared errors array.
102    const [mcpCounts, lspCounts] = await Promise.all([
103      Promise.all(
104        enabled.map(async p => {
105          if (p.mcpServers) return Object.keys(p.mcpServers).length
106          const servers = await loadPluginMcpServers(p, errors)
107          if (servers) p.mcpServers = servers
108          return servers ? Object.keys(servers).length : 0
109        }),
110      ),
111      Promise.all(
112        enabled.map(async p => {
113          if (p.lspServers) return Object.keys(p.lspServers).length
114          const servers = await loadPluginLspServers(p, errors)
115          if (servers) p.lspServers = servers
116          return servers ? Object.keys(servers).length : 0
117        }),
118      ),
119    ])
120    const mcp_count = mcpCounts.reduce((sum, n) => sum + n, 0)
121    const lsp_count = lspCounts.reduce((sum, n) => sum + n, 0)
122  
123    setAppState(prev => ({
124      ...prev,
125      plugins: {
126        ...prev.plugins,
127        enabled,
128        disabled,
129        commands: pluginCommands,
130        errors: mergePluginErrors(prev.plugins.errors, errors),
131        needsRefresh: false,
132      },
133      agentDefinitions,
134      mcp: {
135        ...prev.mcp,
136        pluginReconnectKey: prev.mcp.pluginReconnectKey + 1,
137      },
138    }))
139  
140    // Re-initialize LSP manager so newly-loaded plugin LSP servers are picked
141    // up. No-op if LSP was never initialized (headless subcommand path).
142    // Unconditional so removing the last LSP plugin also clears stale config.
143    // Fixes issue #15521: LSP manager previously read a stale memoized
144    // loadAllPlugins() result from before marketplaces were reconciled.
145    reinitializeLspServerManager()
146  
147    // clearAllCaches() prunes removed-plugin hooks; this does the FULL swap
148    // (adds hooks from newly-enabled plugins too). Catching here so
149    // hook_load_failed can feed error_count; a failure doesn't lose the
150    // plugin/command/agent data above (hooks go to STATE.registeredHooks, not
151    // AppState).
152    let hook_load_failed = false
153    try {
154      await loadPluginHooks()
155    } catch (e) {
156      hook_load_failed = true
157      logError(e)
158      logForDebugging(
159        `refreshActivePlugins: loadPluginHooks failed: ${errorMessage(e)}`,
160      )
161    }
162  
163    const hook_count = enabled.reduce((sum, p) => {
164      if (!p.hooksConfig) return sum
165      return (
166        sum +
167        Object.values(p.hooksConfig).reduce(
168          (s, matchers) =>
169            s + (matchers?.reduce((h, m) => h + m.hooks.length, 0) ?? 0),
170          0,
171        )
172      )
173    }, 0)
174  
175    logForDebugging(
176      `refreshActivePlugins: ${enabled.length} enabled, ${pluginCommands.length} commands, ${agentDefinitions.allAgents.length} agents, ${hook_count} hooks, ${mcp_count} MCP, ${lsp_count} LSP`,
177    )
178  
179    return {
180      enabled_count: enabled.length,
181      disabled_count: disabled.length,
182      command_count: pluginCommands.length,
183      agent_count: agentDefinitions.allAgents.length,
184      hook_count,
185      mcp_count,
186      lsp_count,
187      error_count: errors.length + (hook_load_failed ? 1 : 0),
188      agentDefinitions,
189      pluginCommands,
190    }
191  }
192  
193  /**
194   * Merge fresh plugin-load errors with existing errors, preserving LSP and
195   * plugin-component errors that were recorded by other systems and
196   * deduplicating. Same logic as refreshPlugins()/updatePluginState(), extracted
197   * so refresh.ts doesn't leave those errors stranded.
198   */
199  function mergePluginErrors(
200    existing: PluginError[],
201    fresh: PluginError[],
202  ): PluginError[] {
203    const preserved = existing.filter(
204      e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'),
205    )
206    const freshKeys = new Set(fresh.map(errorKey))
207    const deduped = preserved.filter(e => !freshKeys.has(errorKey(e)))
208    return [...deduped, ...fresh]
209  }
210  
211  function errorKey(e: PluginError): string {
212    return e.type === 'generic-error'
213      ? `generic-error:${e.source}:${e.error}`
214      : `${e.type}:${e.source}`
215  }