forked from 777genius/claude-code-source-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathloadPluginHooks.ts
More file actions
288 lines (261 loc) · 9.83 KB
/
Copy pathloadPluginHooks.ts
File metadata and controls
288 lines (261 loc) · 9.83 KB
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
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()
}
})
}