File size: 10,066 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 | 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()
}
})
}
|