File size: 8,537 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | /**
* 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<RefreshActivePluginsResult> {
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}`
}
|