/ utils / plugins / loadPluginHooks.ts
loadPluginHooks.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
  3  import {
  4    clearRegisteredPluginHooks,
  5    getRegisteredHooks,
  6    registerHookCallbacks,
  7  } from '../../bootstrap/state.js'
  8  import type { LoadedPlugin } from '../../types/plugin.js'
  9  import { logForDebugging } from '../debug.js'
 10  import { settingsChangeDetector } from '../settings/changeDetector.js'
 11  import {
 12    getSettings_DEPRECATED,
 13    getSettingsForSource,
 14  } from '../settings/settings.js'
 15  import type { PluginHookMatcher } from '../settings/types.js'
 16  import { jsonStringify } from '../slowOperations.js'
 17  import { clearPluginCache, loadAllPluginsCacheOnly } from './pluginLoader.js'
 18  
 19  // Track if hot reload subscription is set up
 20  let hotReloadSubscribed = false
 21  
 22  // Snapshot of enabledPlugins for change detection in hot reload
 23  let lastPluginSettingsSnapshot: string | undefined
 24  
 25  /**
 26   * Convert plugin hooks configuration to native matchers with plugin context
 27   */
 28  function convertPluginHooksToMatchers(
 29    plugin: LoadedPlugin,
 30  ): Record<HookEvent, PluginHookMatcher[]> {
 31    const pluginMatchers: Record<HookEvent, PluginHookMatcher[]> = {
 32      PreToolUse: [],
 33      PostToolUse: [],
 34      PostToolUseFailure: [],
 35      PermissionDenied: [],
 36      Notification: [],
 37      UserPromptSubmit: [],
 38      SessionStart: [],
 39      SessionEnd: [],
 40      Stop: [],
 41      StopFailure: [],
 42      SubagentStart: [],
 43      SubagentStop: [],
 44      PreCompact: [],
 45      PostCompact: [],
 46      PermissionRequest: [],
 47      Setup: [],
 48      TeammateIdle: [],
 49      TaskCreated: [],
 50      TaskCompleted: [],
 51      Elicitation: [],
 52      ElicitationResult: [],
 53      ConfigChange: [],
 54      WorktreeCreate: [],
 55      WorktreeRemove: [],
 56      InstructionsLoaded: [],
 57      CwdChanged: [],
 58      FileChanged: [],
 59    }
 60  
 61    if (!plugin.hooksConfig) {
 62      return pluginMatchers
 63    }
 64  
 65    // Process each hook event - pass through all hook types with plugin context
 66    for (const [event, matchers] of Object.entries(plugin.hooksConfig)) {
 67      const hookEvent = event as HookEvent
 68      if (!pluginMatchers[hookEvent]) {
 69        continue
 70      }
 71  
 72      for (const matcher of matchers) {
 73        if (matcher.hooks.length > 0) {
 74          pluginMatchers[hookEvent].push({
 75            matcher: matcher.matcher,
 76            hooks: matcher.hooks,
 77            pluginRoot: plugin.path,
 78            pluginName: plugin.name,
 79            pluginId: plugin.source,
 80          })
 81        }
 82      }
 83    }
 84  
 85    return pluginMatchers
 86  }
 87  
 88  /**
 89   * Load and register hooks from all enabled plugins
 90   */
 91  export const loadPluginHooks = memoize(async (): Promise<void> => {
 92    const { enabled } = await loadAllPluginsCacheOnly()
 93    const allPluginHooks: Record<HookEvent, PluginHookMatcher[]> = {
 94      PreToolUse: [],
 95      PostToolUse: [],
 96      PostToolUseFailure: [],
 97      PermissionDenied: [],
 98      Notification: [],
 99      UserPromptSubmit: [],
100      SessionStart: [],
101      SessionEnd: [],
102      Stop: [],
103      StopFailure: [],
104      SubagentStart: [],
105      SubagentStop: [],
106      PreCompact: [],
107      PostCompact: [],
108      PermissionRequest: [],
109      Setup: [],
110      TeammateIdle: [],
111      TaskCreated: [],
112      TaskCompleted: [],
113      Elicitation: [],
114      ElicitationResult: [],
115      ConfigChange: [],
116      WorktreeCreate: [],
117      WorktreeRemove: [],
118      InstructionsLoaded: [],
119      CwdChanged: [],
120      FileChanged: [],
121    }
122  
123    // Process each enabled plugin
124    for (const plugin of enabled) {
125      if (!plugin.hooksConfig) {
126        continue
127      }
128  
129      logForDebugging(`Loading hooks from plugin: ${plugin.name}`)
130      const pluginMatchers = convertPluginHooksToMatchers(plugin)
131  
132      // Merge plugin hooks into the main collection
133      for (const event of Object.keys(pluginMatchers) as HookEvent[]) {
134        allPluginHooks[event].push(...pluginMatchers[event])
135      }
136    }
137  
138    // Clear-then-register as an atomic pair. Previously the clear lived in
139    // clearPluginHookCache(), which meant any clearAllCaches() call (from
140    // /plugins UI, pluginInstallationHelpers, thinkback, etc.) wiped plugin
141    // hooks from STATE.registeredHooks and left them wiped until someone
142    // happened to call loadPluginHooks() again. SessionStart explicitly awaits
143    // loadPluginHooks() before firing so it always re-registered; Stop has no
144    // such guard, so plugin Stop hooks silently never fired after any plugin
145    // management operation (gh-29767). Doing the clear here makes the swap
146    // atomic — old hooks stay valid until this point, new hooks take over.
147    clearRegisteredPluginHooks()
148    registerHookCallbacks(allPluginHooks)
149  
150    const totalHooks = Object.values(allPluginHooks).reduce(
151      (sum, matchers) => sum + matchers.reduce((s, m) => s + m.hooks.length, 0),
152      0,
153    )
154    logForDebugging(
155      `Registered ${totalHooks} hooks from ${enabled.length} plugins`,
156    )
157  })
158  
159  export function clearPluginHookCache(): void {
160    // Only invalidate the memoize — do NOT wipe STATE.registeredHooks here.
161    // Wiping here left plugin hooks dead between clearAllCaches() and the next
162    // loadPluginHooks() call, which for Stop hooks might never happen
163    // (gh-29767). The clear now lives inside loadPluginHooks() as an atomic
164    // clear-then-register, so old hooks stay valid until the fresh load swaps
165    // them out.
166    loadPluginHooks.cache?.clear?.()
167  }
168  
169  /**
170   * Remove hooks from plugins no longer in the enabled set, without adding
171   * hooks from newly-enabled plugins. Called from clearAllCaches() so
172   * uninstalled/disabled plugins stop firing hooks immediately (gh-36995),
173   * while newly-enabled plugins wait for /reload-plugins — consistent with
174   * how commands/agents/MCP behave.
175   *
176   * The full swap (clear + register all) still happens via loadPluginHooks(),
177   * which /reload-plugins awaits.
178   */
179  export async function pruneRemovedPluginHooks(): Promise<void> {
180    // Early return when nothing to prune — avoids seeding the loadAllPluginsCacheOnly
181    // memoize in test/preload.ts beforeEach (which clears registeredHooks).
182    if (!getRegisteredHooks()) return
183    const { enabled } = await loadAllPluginsCacheOnly()
184    const enabledRoots = new Set(enabled.map(p => p.path))
185  
186    // Re-read after the await: a concurrent loadPluginHooks() (hot-reload)
187    // could have swapped STATE.registeredHooks during the gap. Holding the
188    // pre-await reference would compute survivors from stale data.
189    const current = getRegisteredHooks()
190    if (!current) return
191  
192    // Collect plugin hooks whose pluginRoot is still enabled, then swap via
193    // the existing clear+register pair (same atomic-pair pattern as
194    // loadPluginHooks above). Callback hooks are preserved by
195    // clearRegisteredPluginHooks; we only need to re-register survivors.
196    const survivors: Partial<Record<HookEvent, PluginHookMatcher[]>> = {}
197    for (const [event, matchers] of Object.entries(current)) {
198      const kept = matchers.filter(
199        (m): m is PluginHookMatcher =>
200          'pluginRoot' in m && enabledRoots.has(m.pluginRoot),
201      )
202      if (kept.length > 0) survivors[event as HookEvent] = kept
203    }
204  
205    clearRegisteredPluginHooks()
206    registerHookCallbacks(survivors)
207  }
208  
209  /**
210   * Reset hot reload subscription state. Only for testing.
211   */
212  export function resetHotReloadState(): void {
213    hotReloadSubscribed = false
214    lastPluginSettingsSnapshot = undefined
215  }
216  
217  /**
218   * Build a stable string snapshot of the settings that feed into
219   * `loadAllPluginsCacheOnly()` for change detection. Sorts keys so comparison is
220   * deterministic regardless of insertion order.
221   *
222   * Hashes FOUR fields — not just enabledPlugins — because the memoized
223   * loadAllPluginsCacheOnly() also reads strictKnownMarketplaces, blockedMarketplaces
224   * (pluginLoader.ts:1933 via getBlockedMarketplaces), and
225   * extraKnownMarketplaces. If remote managed settings set only one of
226   * these (no enabledPlugins), a snapshot keyed only on enabledPlugins
227   * would never diff, the listener would skip, and the memoized result
228   * would retain the pre-remote marketplace allow/blocklist.
229   * See #23085 / #23152 poisoned-cache discussion (Slack C09N89L3VNJ).
230   */
231  // Exported for testing — the listener at setupPluginHookHotReload uses this
232  // for change detection; tests verify it diffs on the fields that matter.
233  export function getPluginAffectingSettingsSnapshot(): string {
234    const merged = getSettings_DEPRECATED()
235    const policy = getSettingsForSource('policySettings')
236    // Key-sort the two Record fields so insertion order doesn't flap the hash.
237    // Array fields (strictKnownMarketplaces, blockedMarketplaces) have
238    // schema-stable order.
239    const sortKeys = <T extends Record<string, unknown>>(o: T | undefined) =>
240      o ? Object.fromEntries(Object.entries(o).sort()) : {}
241    return jsonStringify({
242      enabledPlugins: sortKeys(merged.enabledPlugins),
243      extraKnownMarketplaces: sortKeys(merged.extraKnownMarketplaces),
244      strictKnownMarketplaces: policy?.strictKnownMarketplaces ?? [],
245      blockedMarketplaces: policy?.blockedMarketplaces ?? [],
246    })
247  }
248  
249  /**
250   * Set up hot reload for plugin hooks when remote settings change.
251   * When policySettings changes (e.g., from remote managed settings),
252   * compares the plugin-affecting settings snapshot and only reloads if it
253   * actually changed.
254   */
255  export function setupPluginHookHotReload(): void {
256    if (hotReloadSubscribed) {
257      return
258    }
259    hotReloadSubscribed = true
260  
261    // Capture the initial snapshot so the first policySettings change can compare
262    lastPluginSettingsSnapshot = getPluginAffectingSettingsSnapshot()
263  
264    settingsChangeDetector.subscribe(source => {
265      if (source === 'policySettings') {
266        const newSnapshot = getPluginAffectingSettingsSnapshot()
267        if (newSnapshot === lastPluginSettingsSnapshot) {
268          logForDebugging(
269            'Plugin hooks: skipping reload, plugin-affecting settings unchanged',
270          )
271          return
272        }
273  
274        lastPluginSettingsSnapshot = newSnapshot
275        logForDebugging(
276          'Plugin hooks: reloading due to plugin-affecting settings change',
277        )
278  
279        // Clear all plugin-related caches
280        clearPluginCache('loadPluginHooks: plugin-affecting settings changed')
281        clearPluginHookCache()
282  
283        // Reload hooks (fire-and-forget, don't block)
284        void loadPluginHooks()
285      }
286    })
287  }