π File detail
utils/plugins/refresh.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes RefreshActivePluginsResult and refreshActivePlugins β mainly functions, hooks, or classes. It composes internal code from bootstrap, commands, services, state, and tools (relative imports). What the file header says: 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) β thi.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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)
π€ Exports (heuristic)
RefreshActivePluginsResultrefreshActivePlugins
π₯οΈ Source preview
/**
* 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}`
}