source dump of claude code
at main 304 lines 12 kB view raw
1import { useCallback, useEffect } from 'react' 2import type { Command } from '../commands.js' 3import { useNotifications } from '../context/notifications.js' 4import { 5 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 6 logEvent, 7} from '../services/analytics/index.js' 8import { reinitializeLspServerManager } from '../services/lsp/manager.js' 9import { useAppState, useSetAppState } from '../state/AppState.js' 10import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' 11import { count } from '../utils/array.js' 12import { logForDebugging } from '../utils/debug.js' 13import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' 14import { toError } from '../utils/errors.js' 15import { logError } from '../utils/log.js' 16import { loadPluginAgents } from '../utils/plugins/loadPluginAgents.js' 17import { getPluginCommands } from '../utils/plugins/loadPluginCommands.js' 18import { loadPluginHooks } from '../utils/plugins/loadPluginHooks.js' 19import { loadPluginLspServers } from '../utils/plugins/lspPluginIntegration.js' 20import { loadPluginMcpServers } from '../utils/plugins/mcpPluginIntegration.js' 21import { detectAndUninstallDelistedPlugins } from '../utils/plugins/pluginBlocklist.js' 22import { getFlaggedPlugins } from '../utils/plugins/pluginFlagging.js' 23import { loadAllPlugins } from '../utils/plugins/pluginLoader.js' 24 25/** 26 * Hook to manage plugin state and synchronize with AppState. 27 * 28 * On mount: loads all plugins, runs delisting enforcement, surfaces flagged- 29 * plugin notifications, populates AppState.plugins. This is the initial 30 * Layer-3 load — subsequent refresh goes through /reload-plugins. 31 * 32 * On needsRefresh: shows a notification directing the user to /reload-plugins. 33 * Does NOT auto-refresh. All Layer-3 swap (commands, agents, hooks, MCP) 34 * goes through refreshActivePlugins() via /reload-plugins for one consistent 35 * mental model. See Outline: declarative-settings-hXHBMDIf4b PR 5c. 36 */ 37export function useManagePlugins({ 38 enabled = true, 39}: { 40 enabled?: boolean 41} = {}) { 42 const setAppState = useSetAppState() 43 const needsRefresh = useAppState(s => s.plugins.needsRefresh) 44 const { addNotification } = useNotifications() 45 46 // Initial plugin load. Runs once on mount. NOT used for refresh — all 47 // post-mount refresh goes through /reload-plugins → refreshActivePlugins(). 48 // Unlike refreshActivePlugins, this also runs delisting enforcement and 49 // flagged-plugin notifications (session-start concerns), and does NOT bump 50 // mcp.pluginReconnectKey (MCP effects fire on their own mount). 51 const initialPluginLoad = useCallback(async () => { 52 try { 53 // Load all plugins - capture errors array 54 const { enabled, disabled, errors } = await loadAllPlugins() 55 56 // Detect delisted plugins, auto-uninstall them, and record as flagged. 57 await detectAndUninstallDelistedPlugins() 58 59 // Notify if there are flagged plugins pending dismissal 60 const flagged = getFlaggedPlugins() 61 if (Object.keys(flagged).length > 0) { 62 addNotification({ 63 key: 'plugin-delisted-flagged', 64 text: 'Plugins flagged. Check /plugins', 65 color: 'warning', 66 priority: 'high', 67 }) 68 } 69 70 // Load commands, agents, and hooks with individual error handling 71 // Errors are added to the errors array for user visibility in Doctor UI 72 let commands: Command[] = [] 73 let agents: AgentDefinition[] = [] 74 75 try { 76 commands = await getPluginCommands() 77 } catch (error) { 78 const errorMessage = 79 error instanceof Error ? error.message : String(error) 80 errors.push({ 81 type: 'generic-error', 82 source: 'plugin-commands', 83 error: `Failed to load plugin commands: ${errorMessage}`, 84 }) 85 } 86 87 try { 88 agents = await loadPluginAgents() 89 } catch (error) { 90 const errorMessage = 91 error instanceof Error ? error.message : String(error) 92 errors.push({ 93 type: 'generic-error', 94 source: 'plugin-agents', 95 error: `Failed to load plugin agents: ${errorMessage}`, 96 }) 97 } 98 99 try { 100 await loadPluginHooks() 101 } catch (error) { 102 const errorMessage = 103 error instanceof Error ? error.message : String(error) 104 errors.push({ 105 type: 'generic-error', 106 source: 'plugin-hooks', 107 error: `Failed to load plugin hooks: ${errorMessage}`, 108 }) 109 } 110 111 // Load MCP server configs per plugin to get an accurate count. 112 // LoadedPlugin.mcpServers is not populated by loadAllPlugins — it's a 113 // cache slot that extractMcpServersFromPlugins fills later, which races 114 // with this metric. Calling loadPluginMcpServers directly (as 115 // cli/handlers/plugins.ts does) gives the correct count and also 116 // warms the cache for the MCP connection manager. 117 // 118 // Runs BEFORE setAppState so any errors pushed by these loaders make it 119 // into AppState.plugins.errors (Doctor UI), not just telemetry. 120 const mcpServerCounts = await Promise.all( 121 enabled.map(async p => { 122 if (p.mcpServers) return Object.keys(p.mcpServers).length 123 const servers = await loadPluginMcpServers(p, errors) 124 if (servers) p.mcpServers = servers 125 return servers ? Object.keys(servers).length : 0 126 }), 127 ) 128 const mcp_count = mcpServerCounts.reduce((sum, n) => sum + n, 0) 129 130 // LSP: the primary fix for issue #15521 is in refresh.ts (via 131 // performBackgroundPluginInstallations → refreshActivePlugins, which 132 // clears caches first). This reinit is defensive — it reads the same 133 // memoized loadAllPlugins() result as the original init unless a cache 134 // invalidation happened between main.tsx:3203 and REPL mount (e.g. 135 // seed marketplace registration or policySettings hot-reload). 136 const lspServerCounts = await Promise.all( 137 enabled.map(async p => { 138 if (p.lspServers) return Object.keys(p.lspServers).length 139 const servers = await loadPluginLspServers(p, errors) 140 if (servers) p.lspServers = servers 141 return servers ? Object.keys(servers).length : 0 142 }), 143 ) 144 const lsp_count = lspServerCounts.reduce((sum, n) => sum + n, 0) 145 reinitializeLspServerManager() 146 147 // Update AppState - merge errors to preserve LSP errors 148 setAppState(prevState => { 149 // Keep existing LSP/non-plugin-loading errors (source 'lsp-manager' or 'plugin:*') 150 const existingLspErrors = prevState.plugins.errors.filter( 151 e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'), 152 ) 153 // Deduplicate: remove existing LSP errors that are also in new errors 154 const newErrorKeys = new Set( 155 errors.map(e => 156 e.type === 'generic-error' 157 ? `generic-error:${e.source}:${e.error}` 158 : `${e.type}:${e.source}`, 159 ), 160 ) 161 const filteredExisting = existingLspErrors.filter(e => { 162 const key = 163 e.type === 'generic-error' 164 ? `generic-error:${e.source}:${e.error}` 165 : `${e.type}:${e.source}` 166 return !newErrorKeys.has(key) 167 }) 168 const mergedErrors = [...filteredExisting, ...errors] 169 170 return { 171 ...prevState, 172 plugins: { 173 ...prevState.plugins, 174 enabled, 175 disabled, 176 commands, 177 errors: mergedErrors, 178 }, 179 } 180 }) 181 182 logForDebugging( 183 `Loaded plugins - Enabled: ${enabled.length}, Disabled: ${disabled.length}, Commands: ${commands.length}, Agents: ${agents.length}, Errors: ${errors.length}`, 184 ) 185 186 // Count component types across enabled plugins 187 const hook_count = enabled.reduce((sum, p) => { 188 if (!p.hooksConfig) return sum 189 return ( 190 sum + 191 Object.values(p.hooksConfig).reduce( 192 (s, matchers) => 193 s + (matchers?.reduce((h, m) => h + m.hooks.length, 0) ?? 0), 194 0, 195 ) 196 ) 197 }, 0) 198 199 return { 200 enabled_count: enabled.length, 201 disabled_count: disabled.length, 202 inline_count: count(enabled, p => p.source.endsWith('@inline')), 203 marketplace_count: count(enabled, p => !p.source.endsWith('@inline')), 204 error_count: errors.length, 205 skill_count: commands.length, 206 agent_count: agents.length, 207 hook_count, 208 mcp_count, 209 lsp_count, 210 // Ant-only: which plugins are enabled, to correlate with RSS/FPS. 211 // Kept separate from base metrics so it doesn't flow into 212 // logForDiagnosticsNoPII. 213 ant_enabled_names: 214 process.env.USER_TYPE === 'ant' && enabled.length > 0 215 ? (enabled 216 .map(p => p.name) 217 .sort() 218 .join( 219 ',', 220 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 221 : undefined, 222 } 223 } catch (error) { 224 // Only plugin loading errors should reach here - log for monitoring 225 const errorObj = toError(error) 226 logError(errorObj) 227 logForDebugging(`Error loading plugins: ${error}`) 228 // Set empty state on error, but preserve LSP errors and add the new error 229 setAppState(prevState => { 230 // Keep existing LSP/non-plugin-loading errors 231 const existingLspErrors = prevState.plugins.errors.filter( 232 e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'), 233 ) 234 const newError = { 235 type: 'generic-error' as const, 236 source: 'plugin-system', 237 error: errorObj.message, 238 } 239 return { 240 ...prevState, 241 plugins: { 242 ...prevState.plugins, 243 enabled: [], 244 disabled: [], 245 commands: [], 246 errors: [...existingLspErrors, newError], 247 }, 248 } 249 }) 250 251 return { 252 enabled_count: 0, 253 disabled_count: 0, 254 inline_count: 0, 255 marketplace_count: 0, 256 error_count: 1, 257 skill_count: 0, 258 agent_count: 0, 259 hook_count: 0, 260 mcp_count: 0, 261 lsp_count: 0, 262 load_failed: true, 263 ant_enabled_names: undefined, 264 } 265 } 266 }, [setAppState, addNotification]) 267 268 // Load plugins on mount and emit telemetry 269 useEffect(() => { 270 if (!enabled) return 271 void initialPluginLoad().then(metrics => { 272 const { ant_enabled_names, ...baseMetrics } = metrics 273 const allMetrics = { 274 ...baseMetrics, 275 has_custom_plugin_cache_dir: !!process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR, 276 } 277 logEvent('tengu_plugins_loaded', { 278 ...allMetrics, 279 ...(ant_enabled_names !== undefined && { 280 enabled_names: ant_enabled_names, 281 }), 282 }) 283 logForDiagnosticsNoPII('info', 'tengu_plugins_loaded', allMetrics) 284 }) 285 }, [initialPluginLoad, enabled]) 286 287 // Plugin state changed on disk (background reconcile, /plugin menu, 288 // external settings edit). Show a notification; user runs /reload-plugins 289 // to apply. The previous auto-refresh here had a stale-cache bug (only 290 // cleared loadAllPlugins, downstream memoized loaders returned old data) 291 // and was incomplete (no MCP, no agentDefinitions). /reload-plugins 292 // handles all of that correctly via refreshActivePlugins(). 293 useEffect(() => { 294 if (!enabled || !needsRefresh) return 295 addNotification({ 296 key: 'plugin-reload-pending', 297 text: 'Plugins changed. Run /reload-plugins to activate.', 298 color: 'suggestion', 299 priority: 'low', 300 }) 301 // Do NOT auto-refresh. Do NOT reset needsRefresh — /reload-plugins 302 // consumes it via refreshActivePlugins(). 303 }, [enabled, needsRefresh, addNotification]) 304}