loadPluginHooks.ts
utils/plugins/loadPluginHooks.ts
No strong subsystem tag
288
Lines
10066
Bytes
6
Exports
10
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 288 lines, 10 detected imports, and 6 detected exports.
Important relationships
Detected exports
loadPluginHooksclearPluginHookCachepruneRemovedPluginHooksresetHotReloadStategetPluginAffectingSettingsSnapshotsetupPluginHookHotReload
Keywords
pluginhookssettingshookeventloadpluginhooksenabledpluginmatcherseventmatchersplugins
Detected imports
lodash-es/memoize.jssrc/entrypoints/agentSdkTypes.js../../bootstrap/state.js../../types/plugin.js../debug.js../settings/changeDetector.js../settings/settings.js../settings/types.js../slowOperations.js./pluginLoader.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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()
}
})
}