source dump of claude code
at main 287 lines 10 kB view raw
1import memoize from 'lodash-es/memoize.js' 2import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' 3import { 4 clearRegisteredPluginHooks, 5 getRegisteredHooks, 6 registerHookCallbacks, 7} from '../../bootstrap/state.js' 8import type { LoadedPlugin } from '../../types/plugin.js' 9import { logForDebugging } from '../debug.js' 10import { settingsChangeDetector } from '../settings/changeDetector.js' 11import { 12 getSettings_DEPRECATED, 13 getSettingsForSource, 14} from '../settings/settings.js' 15import type { PluginHookMatcher } from '../settings/types.js' 16import { jsonStringify } from '../slowOperations.js' 17import { clearPluginCache, loadAllPluginsCacheOnly } from './pluginLoader.js' 18 19// Track if hot reload subscription is set up 20let hotReloadSubscribed = false 21 22// Snapshot of enabledPlugins for change detection in hot reload 23let lastPluginSettingsSnapshot: string | undefined 24 25/** 26 * Convert plugin hooks configuration to native matchers with plugin context 27 */ 28function convertPluginHooksToMatchers( 29 plugin: LoadedPlugin, 30): Record<HookEvent, PluginHookMatcher[]> { 31 const pluginMatchers: Record<HookEvent, PluginHookMatcher[]> = { 32 PreToolUse: [], 33 PostToolUse: [], 34 PostToolUseFailure: [], 35 PermissionDenied: [], 36 Notification: [], 37 UserPromptSubmit: [], 38 SessionStart: [], 39 SessionEnd: [], 40 Stop: [], 41 StopFailure: [], 42 SubagentStart: [], 43 SubagentStop: [], 44 PreCompact: [], 45 PostCompact: [], 46 PermissionRequest: [], 47 Setup: [], 48 TeammateIdle: [], 49 TaskCreated: [], 50 TaskCompleted: [], 51 Elicitation: [], 52 ElicitationResult: [], 53 ConfigChange: [], 54 WorktreeCreate: [], 55 WorktreeRemove: [], 56 InstructionsLoaded: [], 57 CwdChanged: [], 58 FileChanged: [], 59 } 60 61 if (!plugin.hooksConfig) { 62 return pluginMatchers 63 } 64 65 // Process each hook event - pass through all hook types with plugin context 66 for (const [event, matchers] of Object.entries(plugin.hooksConfig)) { 67 const hookEvent = event as HookEvent 68 if (!pluginMatchers[hookEvent]) { 69 continue 70 } 71 72 for (const matcher of matchers) { 73 if (matcher.hooks.length > 0) { 74 pluginMatchers[hookEvent].push({ 75 matcher: matcher.matcher, 76 hooks: matcher.hooks, 77 pluginRoot: plugin.path, 78 pluginName: plugin.name, 79 pluginId: plugin.source, 80 }) 81 } 82 } 83 } 84 85 return pluginMatchers 86} 87 88/** 89 * Load and register hooks from all enabled plugins 90 */ 91export const loadPluginHooks = memoize(async (): Promise<void> => { 92 const { enabled } = await loadAllPluginsCacheOnly() 93 const allPluginHooks: Record<HookEvent, PluginHookMatcher[]> = { 94 PreToolUse: [], 95 PostToolUse: [], 96 PostToolUseFailure: [], 97 PermissionDenied: [], 98 Notification: [], 99 UserPromptSubmit: [], 100 SessionStart: [], 101 SessionEnd: [], 102 Stop: [], 103 StopFailure: [], 104 SubagentStart: [], 105 SubagentStop: [], 106 PreCompact: [], 107 PostCompact: [], 108 PermissionRequest: [], 109 Setup: [], 110 TeammateIdle: [], 111 TaskCreated: [], 112 TaskCompleted: [], 113 Elicitation: [], 114 ElicitationResult: [], 115 ConfigChange: [], 116 WorktreeCreate: [], 117 WorktreeRemove: [], 118 InstructionsLoaded: [], 119 CwdChanged: [], 120 FileChanged: [], 121 } 122 123 // Process each enabled plugin 124 for (const plugin of enabled) { 125 if (!plugin.hooksConfig) { 126 continue 127 } 128 129 logForDebugging(`Loading hooks from plugin: ${plugin.name}`) 130 const pluginMatchers = convertPluginHooksToMatchers(plugin) 131 132 // Merge plugin hooks into the main collection 133 for (const event of Object.keys(pluginMatchers) as HookEvent[]) { 134 allPluginHooks[event].push(...pluginMatchers[event]) 135 } 136 } 137 138 // Clear-then-register as an atomic pair. Previously the clear lived in 139 // clearPluginHookCache(), which meant any clearAllCaches() call (from 140 // /plugins UI, pluginInstallationHelpers, thinkback, etc.) wiped plugin 141 // hooks from STATE.registeredHooks and left them wiped until someone 142 // happened to call loadPluginHooks() again. SessionStart explicitly awaits 143 // loadPluginHooks() before firing so it always re-registered; Stop has no 144 // such guard, so plugin Stop hooks silently never fired after any plugin 145 // management operation (gh-29767). Doing the clear here makes the swap 146 // atomic — old hooks stay valid until this point, new hooks take over. 147 clearRegisteredPluginHooks() 148 registerHookCallbacks(allPluginHooks) 149 150 const totalHooks = Object.values(allPluginHooks).reduce( 151 (sum, matchers) => sum + matchers.reduce((s, m) => s + m.hooks.length, 0), 152 0, 153 ) 154 logForDebugging( 155 `Registered ${totalHooks} hooks from ${enabled.length} plugins`, 156 ) 157}) 158 159export function clearPluginHookCache(): void { 160 // Only invalidate the memoize — do NOT wipe STATE.registeredHooks here. 161 // Wiping here left plugin hooks dead between clearAllCaches() and the next 162 // loadPluginHooks() call, which for Stop hooks might never happen 163 // (gh-29767). The clear now lives inside loadPluginHooks() as an atomic 164 // clear-then-register, so old hooks stay valid until the fresh load swaps 165 // them out. 166 loadPluginHooks.cache?.clear?.() 167} 168 169/** 170 * Remove hooks from plugins no longer in the enabled set, without adding 171 * hooks from newly-enabled plugins. Called from clearAllCaches() so 172 * uninstalled/disabled plugins stop firing hooks immediately (gh-36995), 173 * while newly-enabled plugins wait for /reload-plugins — consistent with 174 * how commands/agents/MCP behave. 175 * 176 * The full swap (clear + register all) still happens via loadPluginHooks(), 177 * which /reload-plugins awaits. 178 */ 179export async function pruneRemovedPluginHooks(): Promise<void> { 180 // Early return when nothing to prune — avoids seeding the loadAllPluginsCacheOnly 181 // memoize in test/preload.ts beforeEach (which clears registeredHooks). 182 if (!getRegisteredHooks()) return 183 const { enabled } = await loadAllPluginsCacheOnly() 184 const enabledRoots = new Set(enabled.map(p => p.path)) 185 186 // Re-read after the await: a concurrent loadPluginHooks() (hot-reload) 187 // could have swapped STATE.registeredHooks during the gap. Holding the 188 // pre-await reference would compute survivors from stale data. 189 const current = getRegisteredHooks() 190 if (!current) return 191 192 // Collect plugin hooks whose pluginRoot is still enabled, then swap via 193 // the existing clear+register pair (same atomic-pair pattern as 194 // loadPluginHooks above). Callback hooks are preserved by 195 // clearRegisteredPluginHooks; we only need to re-register survivors. 196 const survivors: Partial<Record<HookEvent, PluginHookMatcher[]>> = {} 197 for (const [event, matchers] of Object.entries(current)) { 198 const kept = matchers.filter( 199 (m): m is PluginHookMatcher => 200 'pluginRoot' in m && enabledRoots.has(m.pluginRoot), 201 ) 202 if (kept.length > 0) survivors[event as HookEvent] = kept 203 } 204 205 clearRegisteredPluginHooks() 206 registerHookCallbacks(survivors) 207} 208 209/** 210 * Reset hot reload subscription state. Only for testing. 211 */ 212export function resetHotReloadState(): void { 213 hotReloadSubscribed = false 214 lastPluginSettingsSnapshot = undefined 215} 216 217/** 218 * Build a stable string snapshot of the settings that feed into 219 * `loadAllPluginsCacheOnly()` for change detection. Sorts keys so comparison is 220 * deterministic regardless of insertion order. 221 * 222 * Hashes FOUR fields — not just enabledPlugins — because the memoized 223 * loadAllPluginsCacheOnly() also reads strictKnownMarketplaces, blockedMarketplaces 224 * (pluginLoader.ts:1933 via getBlockedMarketplaces), and 225 * extraKnownMarketplaces. If remote managed settings set only one of 226 * these (no enabledPlugins), a snapshot keyed only on enabledPlugins 227 * would never diff, the listener would skip, and the memoized result 228 * would retain the pre-remote marketplace allow/blocklist. 229 * See #23085 / #23152 poisoned-cache discussion (Slack C09N89L3VNJ). 230 */ 231// Exported for testing — the listener at setupPluginHookHotReload uses this 232// for change detection; tests verify it diffs on the fields that matter. 233export function getPluginAffectingSettingsSnapshot(): string { 234 const merged = getSettings_DEPRECATED() 235 const policy = getSettingsForSource('policySettings') 236 // Key-sort the two Record fields so insertion order doesn't flap the hash. 237 // Array fields (strictKnownMarketplaces, blockedMarketplaces) have 238 // schema-stable order. 239 const sortKeys = <T extends Record<string, unknown>>(o: T | undefined) => 240 o ? Object.fromEntries(Object.entries(o).sort()) : {} 241 return jsonStringify({ 242 enabledPlugins: sortKeys(merged.enabledPlugins), 243 extraKnownMarketplaces: sortKeys(merged.extraKnownMarketplaces), 244 strictKnownMarketplaces: policy?.strictKnownMarketplaces ?? [], 245 blockedMarketplaces: policy?.blockedMarketplaces ?? [], 246 }) 247} 248 249/** 250 * Set up hot reload for plugin hooks when remote settings change. 251 * When policySettings changes (e.g., from remote managed settings), 252 * compares the plugin-affecting settings snapshot and only reloads if it 253 * actually changed. 254 */ 255export function setupPluginHookHotReload(): void { 256 if (hotReloadSubscribed) { 257 return 258 } 259 hotReloadSubscribed = true 260 261 // Capture the initial snapshot so the first policySettings change can compare 262 lastPluginSettingsSnapshot = getPluginAffectingSettingsSnapshot() 263 264 settingsChangeDetector.subscribe(source => { 265 if (source === 'policySettings') { 266 const newSnapshot = getPluginAffectingSettingsSnapshot() 267 if (newSnapshot === lastPluginSettingsSnapshot) { 268 logForDebugging( 269 'Plugin hooks: skipping reload, plugin-affecting settings unchanged', 270 ) 271 return 272 } 273 274 lastPluginSettingsSnapshot = newSnapshot 275 logForDebugging( 276 'Plugin hooks: reloading due to plugin-affecting settings change', 277 ) 278 279 // Clear all plugin-related caches 280 clearPluginCache('loadPluginHooks: plugin-affecting settings changed') 281 clearPluginHookCache() 282 283 // Reload hooks (fire-and-forget, don't block) 284 void loadPluginHooks() 285 } 286 }) 287}