Download src/utils/plugins/loadPluginHooks.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 10.1 kB
-
https://huggingface.co/chenbhao/codev/resolve/714ab7d15ffd78992a4c3d4e2bf153c2f62f4841/src/utils/plugins/loadPluginHooks.ts
- Command line
-
hf download hf://chenbhao/codev@714ab7d15ffd78992a4c3d4e2bf153c2f62f4841/src/utils/plugins/loadPluginHooks.ts
-
curl -L -o loadPluginHooks.ts https://huggingface.co/chenbhao/codev/resolve/714ab7d15ffd78992a4c3d4e2bf153c2f62f4841/src/utils/plugins/loadPluginHooks.ts
10.1 kB
| import memoize from 'lodash-es/memoize.js' | |
| import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' | |
| import { | |
| clearRegisteredPluginHooks, | |
| getRegisteredHooks, | |
| registerHookCallbacks, | |
| } from '../../bootstrap/state.js' | |
| import type { LoadedPlugin } from '../../types/plugin.js' | |
| import { logForDebugging } from '../debug.js' | |
| import { settingsChangeDetector } from '../settings/changeDetector.js' | |
| import { | |
| getSettings_DEPRECATED, | |
| getSettingsForSource, | |
| } from '../settings/settings.js' | |
| import type { PluginHookMatcher } from '../settings/types.js' | |
| import { jsonStringify } from '../slowOperations.js' | |
| import { clearPluginCache, loadAllPluginsCacheOnly } from './pluginLoader.js' | |
| // Track if hot reload subscription is set up | |
| let hotReloadSubscribed = false | |
| // Snapshot of enabledPlugins for change detection in hot reload | |
| let lastPluginSettingsSnapshot: string | undefined | |
| /** | |
| * Convert plugin hooks configuration to native matchers with plugin context | |
| */ | |
| function convertPluginHooksToMatchers( | |
| plugin: LoadedPlugin, | |
| ): Record<HookEvent, PluginHookMatcher[]> { | |
| const pluginMatchers: Record<HookEvent, PluginHookMatcher[]> = { | |
| PreToolUse: [], | |
| PostToolUse: [], | |
| PostToolUseFailure: [], | |
| PermissionDenied: [], | |
| Notification: [], | |
| UserPromptSubmit: [], | |
| SessionStart: [], | |
| SessionEnd: [], | |
| Stop: [], | |
| StopFailure: [], | |
| SubagentStart: [], | |
| SubagentStop: [], | |
| PreCompact: [], | |
| PostCompact: [], | |
| PermissionRequest: [], | |
| Setup: [], | |
| TeammateIdle: [], | |
| TaskCreated: [], | |
| TaskCompleted: [], | |
| Elicitation: [], | |
| ElicitationResult: [], | |
| ConfigChange: [], | |
| WorktreeCreate: [], | |
| WorktreeRemove: [], | |
| InstructionsLoaded: [], | |
| CwdChanged: [], | |
| FileChanged: [], | |
| } | |
| if (!plugin.hooksConfig) { | |
| return pluginMatchers | |
| } | |
| // Process each hook event - pass through all hook types with plugin context | |
| for (const [event, matchers] of Object.entries(plugin.hooksConfig)) { | |
| const hookEvent = event as HookEvent | |
| if (!pluginMatchers[hookEvent]) { | |
| continue | |
| } | |
| for (const matcher of matchers) { | |
| if (matcher.hooks.length > 0) { | |
| pluginMatchers[hookEvent].push({ | |
| matcher: matcher.matcher, | |
| hooks: matcher.hooks, | |
| pluginRoot: plugin.path, | |
| pluginName: plugin.name, | |
| pluginId: plugin.source, | |
| }) | |
| } | |
| } | |
| } | |
| return pluginMatchers | |
| } | |
| /** | |
| * Load and register hooks from all enabled plugins | |
| */ | |
| export const loadPluginHooks = memoize(async (): Promise<void> => { | |
| const { enabled } = await loadAllPluginsCacheOnly() | |
| const allPluginHooks: Record<HookEvent, PluginHookMatcher[]> = { | |
| PreToolUse: [], | |
| PostToolUse: [], | |
| PostToolUseFailure: [], | |
| PermissionDenied: [], | |
| Notification: [], | |
| UserPromptSubmit: [], | |
| SessionStart: [], | |
| SessionEnd: [], | |
| Stop: [], | |
| StopFailure: [], | |
| SubagentStart: [], | |
| SubagentStop: [], | |
| PreCompact: [], | |
| PostCompact: [], | |
| PermissionRequest: [], | |
| Setup: [], | |
| TeammateIdle: [], | |
| TaskCreated: [], | |
| TaskCompleted: [], | |
| Elicitation: [], | |
| ElicitationResult: [], | |
| ConfigChange: [], | |
| WorktreeCreate: [], | |
| WorktreeRemove: [], | |
| InstructionsLoaded: [], | |
| CwdChanged: [], | |
| FileChanged: [], | |
| } | |
| // Process each enabled plugin | |
| for (const plugin of enabled) { | |
| if (!plugin.hooksConfig) { | |
| continue | |
| } | |
| logForDebugging(`Loading hooks from plugin: ${plugin.name}`) | |
| const pluginMatchers = convertPluginHooksToMatchers(plugin) | |
| // Merge plugin hooks into the main collection | |
| for (const event of Object.keys(pluginMatchers) as HookEvent[]) { | |
| allPluginHooks[event].push(...pluginMatchers[event]) | |
| } | |
| } | |
| // Clear-then-register as an atomic pair. Previously the clear lived in | |
| // clearPluginHookCache(), which meant any clearAllCaches() call (from | |
| // /plugins UI, pluginInstallationHelpers, thinkback, etc.) wiped plugin | |
| // hooks from STATE.registeredHooks and left them wiped until someone | |
| // happened to call loadPluginHooks() again. SessionStart explicitly awaits | |
| // loadPluginHooks() before firing so it always re-registered; Stop has no | |
| // such guard, so plugin Stop hooks silently never fired after any plugin | |
| // management operation (gh-29767). Doing the clear here makes the swap | |
| // atomic — old hooks stay valid until this point, new hooks take over. | |
| clearRegisteredPluginHooks() | |
| registerHookCallbacks(allPluginHooks) | |
| const totalHooks = Object.values(allPluginHooks).reduce( | |
| (sum, matchers) => sum + matchers.reduce((s, m) => s + m.hooks.length, 0), | |
| 0, | |
| ) | |
| logForDebugging( | |
| `Registered ${totalHooks} hooks from ${enabled.length} plugins`, | |
| ) | |
| }) | |
| export function clearPluginHookCache(): void { | |
| // Only invalidate the memoize — do NOT wipe STATE.registeredHooks here. | |
| // Wiping here left plugin hooks dead between clearAllCaches() and the next | |
| // loadPluginHooks() call, which for Stop hooks might never happen | |
| // (gh-29767). The clear now lives inside loadPluginHooks() as an atomic | |
| // clear-then-register, so old hooks stay valid until the fresh load swaps | |
| // them out. | |
| loadPluginHooks.cache?.clear?.() | |
| } | |
| /** | |
| * Remove hooks from plugins no longer in the enabled set, without adding | |
| * hooks from newly-enabled plugins. Called from clearAllCaches() so | |
| * uninstalled/disabled plugins stop firing hooks immediately (gh-36995), | |
| * while newly-enabled plugins wait for /reload-plugins — consistent with | |
| * how commands/agents/MCP behave. | |
| * | |
| * The full swap (clear + register all) still happens via loadPluginHooks(), | |
| * which /reload-plugins awaits. | |
| */ | |
| export async function pruneRemovedPluginHooks(): Promise<void> { | |
| // Early return when nothing to prune — avoids seeding the loadAllPluginsCacheOnly | |
| // memoize in test/preload.ts beforeEach (which clears registeredHooks). | |
| if (!getRegisteredHooks()) return | |
| const { enabled } = await loadAllPluginsCacheOnly() | |
| const enabledRoots = new Set(enabled.map(p => p.path)) | |
| // Re-read after the await: a concurrent loadPluginHooks() (hot-reload) | |
| // could have swapped STATE.registeredHooks during the gap. Holding the | |
| // pre-await reference would compute survivors from stale data. | |
| const current = getRegisteredHooks() | |
| if (!current) return | |
| // Collect plugin hooks whose pluginRoot is still enabled, then swap via | |
| // the existing clear+register pair (same atomic-pair pattern as | |
| // loadPluginHooks above). Callback hooks are preserved by | |
| // clearRegisteredPluginHooks; we only need to re-register survivors. | |
| const survivors: Partial<Record<HookEvent, PluginHookMatcher[]>> = {} | |
| for (const [event, matchers] of Object.entries(current)) { | |
| const kept = matchers.filter( | |
| (m): m is PluginHookMatcher => | |
| 'pluginRoot' in m && enabledRoots.has(m.pluginRoot), | |
| ) | |
| if (kept.length > 0) survivors[event as HookEvent] = kept | |
| } | |
| clearRegisteredPluginHooks() | |
| registerHookCallbacks(survivors) | |
| } | |
| /** | |
| * Reset hot reload subscription state. Only for testing. | |
| */ | |
| export function resetHotReloadState(): void { | |
| hotReloadSubscribed = false | |
| lastPluginSettingsSnapshot = undefined | |
| } | |
| /** | |
| * Build a stable string snapshot of the settings that feed into | |
| * `loadAllPluginsCacheOnly()` for change detection. Sorts keys so comparison is | |
| * deterministic regardless of insertion order. | |
| * | |
| * Hashes FOUR fields — not just enabledPlugins — because the memoized | |
| * loadAllPluginsCacheOnly() also reads strictKnownMarketplaces, blockedMarketplaces | |
| * (pluginLoader.ts:1933 via getBlockedMarketplaces), and | |
| * extraKnownMarketplaces. If remote managed settings set only one of | |
| * these (no enabledPlugins), a snapshot keyed only on enabledPlugins | |
| * would never diff, the listener would skip, and the memoized result | |
| * would retain the pre-remote marketplace allow/blocklist. | |
| * See #23085 / #23152 poisoned-cache discussion (Slack C09N89L3VNJ). | |
| */ | |
| // Exported for testing — the listener at setupPluginHookHotReload uses this | |
| // for change detection; tests verify it diffs on the fields that matter. | |
| export function getPluginAffectingSettingsSnapshot(): string { | |
| const merged = getSettings_DEPRECATED() | |
| const policy = getSettingsForSource('policySettings') | |
| // Key-sort the two Record fields so insertion order doesn't flap the hash. | |
| // Array fields (strictKnownMarketplaces, blockedMarketplaces) have | |
| // schema-stable order. | |
| const sortKeys = <T extends Record<string, unknown>>(o: T | undefined) => | |
| o ? Object.fromEntries(Object.entries(o).sort()) : {} | |
| return jsonStringify({ | |
| enabledPlugins: sortKeys(merged.enabledPlugins), | |
| extraKnownMarketplaces: sortKeys(merged.extraKnownMarketplaces), | |
| strictKnownMarketplaces: policy?.strictKnownMarketplaces ?? [], | |
| blockedMarketplaces: policy?.blockedMarketplaces ?? [], | |
| }) | |
| } | |
| /** | |
| * Set up hot reload for plugin hooks when remote settings change. | |
| * When policySettings changes (e.g., from remote managed settings), | |
| * compares the plugin-affecting settings snapshot and only reloads if it | |
| * actually changed. | |
| */ | |
| export function setupPluginHookHotReload(): void { | |
| if (hotReloadSubscribed) { | |
| return | |
| } | |
| hotReloadSubscribed = true | |
| // Capture the initial snapshot so the first policySettings change can compare | |
| lastPluginSettingsSnapshot = getPluginAffectingSettingsSnapshot() | |
| settingsChangeDetector.subscribe(source => { | |
| if (source === 'policySettings') { | |
| const newSnapshot = getPluginAffectingSettingsSnapshot() | |
| if (newSnapshot === lastPluginSettingsSnapshot) { | |
| logForDebugging( | |
| 'Plugin hooks: skipping reload, plugin-affecting settings unchanged', | |
| ) | |
| return | |
| } | |
| lastPluginSettingsSnapshot = newSnapshot | |
| logForDebugging( | |
| 'Plugin hooks: reloading due to plugin-affecting settings change', | |
| ) | |
| // Clear all plugin-related caches | |
| clearPluginCache('loadPluginHooks: plugin-affecting settings changed') | |
| clearPluginHookCache() | |
| // Reload hooks (fire-and-forget, don't block) | |
| void loadPluginHooks() | |
| } | |
| }) | |
| } | |