init claude-code
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user