/ utils / hooks / hooksConfigManager.ts
hooksConfigManager.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
  3  import { getRegisteredHooks } from '../../bootstrap/state.js'
  4  import type { AppState } from '../../state/AppState.js'
  5  import {
  6    getAllHooks,
  7    type IndividualHookConfig,
  8    sortMatchersByPriority,
  9  } from './hooksSettings.js'
 10  
 11  export type MatcherMetadata = {
 12    fieldToMatch: string
 13    values: string[]
 14  }
 15  
 16  export type HookEventMetadata = {
 17    summary: string
 18    description: string
 19    matcherMetadata?: MatcherMetadata
 20  }
 21  
 22  // Hook event metadata configuration.
 23  // Resolver uses sorted-joined string key so that callers passing a fresh
 24  // toolNames array each render (e.g. HooksConfigMenu) hit the cache instead
 25  // of leaking a new entry per call.
 26  export const getHookEventMetadata = memoize(
 27    function (toolNames: string[]): Record<HookEvent, HookEventMetadata> {
 28      return {
 29        PreToolUse: {
 30          summary: 'Before tool execution',
 31          description:
 32            'Input to command is JSON of tool call arguments.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and block tool call\nOther exit codes - show stderr to user only but continue with tool call',
 33          matcherMetadata: {
 34            fieldToMatch: 'tool_name',
 35            values: toolNames,
 36          },
 37        },
 38        PostToolUse: {
 39          summary: 'After tool execution',
 40          description:
 41            'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only',
 42          matcherMetadata: {
 43            fieldToMatch: 'tool_name',
 44            values: toolNames,
 45          },
 46        },
 47        PostToolUseFailure: {
 48          summary: 'After tool execution fails',
 49          description:
 50            'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only',
 51          matcherMetadata: {
 52            fieldToMatch: 'tool_name',
 53            values: toolNames,
 54          },
 55        },
 56        PermissionDenied: {
 57          summary: 'After auto mode classifier denies a tool call',
 58          description:
 59            'Input to command is JSON with tool_name, tool_input, tool_use_id, and reason.\nReturn {"hookSpecificOutput":{"hookEventName":"PermissionDenied","retry":true}} to tell the model it may retry.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nOther exit codes - show stderr to user only',
 60          matcherMetadata: {
 61            fieldToMatch: 'tool_name',
 62            values: toolNames,
 63          },
 64        },
 65        Notification: {
 66          summary: 'When notifications are sent',
 67          description:
 68            'Input to command is JSON with notification message and type.\nExit code 0 - stdout/stderr not shown\nOther exit codes - show stderr to user only',
 69          matcherMetadata: {
 70            fieldToMatch: 'notification_type',
 71            values: [
 72              'permission_prompt',
 73              'idle_prompt',
 74              'auth_success',
 75              'elicitation_dialog',
 76              'elicitation_complete',
 77              'elicitation_response',
 78            ],
 79          },
 80        },
 81        UserPromptSubmit: {
 82          summary: 'When the user submits a prompt',
 83          description:
 84            'Input to command is JSON with original user prompt text.\nExit code 0 - stdout shown to Claude\nExit code 2 - block processing, erase original prompt, and show stderr to user only\nOther exit codes - show stderr to user only',
 85        },
 86        SessionStart: {
 87          summary: 'When a new session is started',
 88          description:
 89            'Input to command is JSON with session start source.\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only',
 90          matcherMetadata: {
 91            fieldToMatch: 'source',
 92            values: ['startup', 'resume', 'clear', 'compact'],
 93          },
 94        },
 95        Stop: {
 96          summary: 'Right before Claude concludes its response',
 97          description:
 98            'Exit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and continue conversation\nOther exit codes - show stderr to user only',
 99        },
100        StopFailure: {
101          summary: 'When the turn ends due to an API error',
102          description:
103            'Fires instead of Stop when an API error (rate limit, auth failure, etc.) ended the turn. Fire-and-forget — hook output and exit codes are ignored.',
104          matcherMetadata: {
105            fieldToMatch: 'error',
106            values: [
107              'rate_limit',
108              'authentication_failed',
109              'billing_error',
110              'invalid_request',
111              'server_error',
112              'max_output_tokens',
113              'unknown',
114            ],
115          },
116        },
117        SubagentStart: {
118          summary: 'When a subagent (Agent tool call) is started',
119          description:
120            'Input to command is JSON with agent_id and agent_type.\nExit code 0 - stdout shown to subagent\nBlocking errors are ignored\nOther exit codes - show stderr to user only',
121          matcherMetadata: {
122            fieldToMatch: 'agent_type',
123            values: [], // Will be populated with available agent types
124          },
125        },
126        SubagentStop: {
127          summary:
128            'Right before a subagent (Agent tool call) concludes its response',
129          description:
130            'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to subagent and continue having it run\nOther exit codes - show stderr to user only',
131          matcherMetadata: {
132            fieldToMatch: 'agent_type',
133            values: [], // Will be populated with available agent types
134          },
135        },
136        PreCompact: {
137          summary: 'Before conversation compaction',
138          description:
139            'Input to command is JSON with compaction details.\nExit code 0 - stdout appended as custom compact instructions\nExit code 2 - block compaction\nOther exit codes - show stderr to user only but continue with compaction',
140          matcherMetadata: {
141            fieldToMatch: 'trigger',
142            values: ['manual', 'auto'],
143          },
144        },
145        PostCompact: {
146          summary: 'After conversation compaction',
147          description:
148            'Input to command is JSON with compaction details and the summary.\nExit code 0 - stdout shown to user\nOther exit codes - show stderr to user only',
149          matcherMetadata: {
150            fieldToMatch: 'trigger',
151            values: ['manual', 'auto'],
152          },
153        },
154        SessionEnd: {
155          summary: 'When a session is ending',
156          description:
157            'Input to command is JSON with session end reason.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only',
158          matcherMetadata: {
159            fieldToMatch: 'reason',
160            values: ['clear', 'logout', 'prompt_input_exit', 'other'],
161          },
162        },
163        PermissionRequest: {
164          summary: 'When a permission dialog is displayed',
165          description:
166            'Input to command is JSON with tool_name, tool_input, and tool_use_id.\nOutput JSON with hookSpecificOutput containing decision to allow or deny.\nExit code 0 - use hook decision if provided\nOther exit codes - show stderr to user only',
167          matcherMetadata: {
168            fieldToMatch: 'tool_name',
169            values: toolNames,
170          },
171        },
172        Setup: {
173          summary: 'Repo setup hooks for init and maintenance',
174          description:
175            'Input to command is JSON with trigger (init or maintenance).\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only',
176          matcherMetadata: {
177            fieldToMatch: 'trigger',
178            values: ['init', 'maintenance'],
179          },
180        },
181        TeammateIdle: {
182          summary: 'When a teammate is about to go idle',
183          description:
184            'Input to command is JSON with teammate_name and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to teammate and prevent idle (teammate continues working)\nOther exit codes - show stderr to user only',
185        },
186        TaskCreated: {
187          summary: 'When a task is being created',
188          description:
189            'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task creation\nOther exit codes - show stderr to user only',
190        },
191        TaskCompleted: {
192          summary: 'When a task is being marked as completed',
193          description:
194            'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task completion\nOther exit codes - show stderr to user only',
195        },
196        Elicitation: {
197          summary: 'When an MCP server requests user input (elicitation)',
198          description:
199            'Input to command is JSON with mcp_server_name, message, and requested_schema.\nOutput JSON with hookSpecificOutput containing action (accept/decline/cancel) and optional content.\nExit code 0 - use hook response if provided\nExit code 2 - deny the elicitation\nOther exit codes - show stderr to user only',
200          matcherMetadata: {
201            fieldToMatch: 'mcp_server_name',
202            values: [],
203          },
204        },
205        ElicitationResult: {
206          summary: 'After a user responds to an MCP elicitation',
207          description:
208            'Input to command is JSON with mcp_server_name, action, content, mode, and elicitation_id.\nOutput JSON with hookSpecificOutput containing optional action and content to override the response.\nExit code 0 - use hook response if provided\nExit code 2 - block the response (action becomes decline)\nOther exit codes - show stderr to user only',
209          matcherMetadata: {
210            fieldToMatch: 'mcp_server_name',
211            values: [],
212          },
213        },
214        ConfigChange: {
215          summary: 'When configuration files change during a session',
216          description:
217            'Input to command is JSON with source (user_settings, project_settings, local_settings, policy_settings, skills) and file_path.\nExit code 0 - allow the change\nExit code 2 - block the change from being applied to the session\nOther exit codes - show stderr to user only',
218          matcherMetadata: {
219            fieldToMatch: 'source',
220            values: [
221              'user_settings',
222              'project_settings',
223              'local_settings',
224              'policy_settings',
225              'skills',
226            ],
227          },
228        },
229        InstructionsLoaded: {
230          summary: 'When an instruction file (CLAUDE.md or rule) is loaded',
231          description:
232            'Input to command is JSON with file_path, memory_type (User, Project, Local, Managed), load_reason (session_start, nested_traversal, path_glob_match, include, compact), globs (optional — the paths: frontmatter patterns that matched), trigger_file_path (optional — the file Claude touched that caused the load), and parent_file_path (optional — the file that @-included this one).\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only\nThis hook is observability-only and does not support blocking.',
233          matcherMetadata: {
234            fieldToMatch: 'load_reason',
235            values: [
236              'session_start',
237              'nested_traversal',
238              'path_glob_match',
239              'include',
240              'compact',
241            ],
242          },
243        },
244        WorktreeCreate: {
245          summary: 'Create an isolated worktree for VCS-agnostic isolation',
246          description:
247            'Input to command is JSON with name (suggested worktree slug).\nStdout should contain the absolute path to the created worktree directory.\nExit code 0 - worktree created successfully\nOther exit codes - worktree creation failed',
248        },
249        WorktreeRemove: {
250          summary: 'Remove a previously created worktree',
251          description:
252            'Input to command is JSON with worktree_path (absolute path to worktree).\nExit code 0 - worktree removed successfully\nOther exit codes - show stderr to user only',
253        },
254        CwdChanged: {
255          summary: 'After the working directory changes',
256          description:
257            'Input to command is JSON with old_cwd and new_cwd.\nCLAUDE_ENV_FILE is set — write bash exports there to apply env to subsequent BashTool commands.\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to register with the FileChanged watcher.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only',
258        },
259        FileChanged: {
260          summary: 'When a watched file changes',
261          description:
262            'Input to command is JSON with file_path and event (change, add, unlink).\nCLAUDE_ENV_FILE is set — write bash exports there to apply env to subsequent BashTool commands.\nThe matcher field specifies filenames to watch in the current directory (e.g. ".envrc|.env").\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to dynamically update the watch list.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only',
263        },
264      }
265    },
266    toolNames => toolNames.slice().sort().join(','),
267  )
268  
269  // Group hooks by event and matcher
270  export function groupHooksByEventAndMatcher(
271    appState: AppState,
272    toolNames: string[],
273  ): Record<HookEvent, Record<string, IndividualHookConfig[]>> {
274    const grouped: Record<HookEvent, Record<string, IndividualHookConfig[]>> = {
275      PreToolUse: {},
276      PostToolUse: {},
277      PostToolUseFailure: {},
278      PermissionDenied: {},
279      Notification: {},
280      UserPromptSubmit: {},
281      SessionStart: {},
282      SessionEnd: {},
283      Stop: {},
284      StopFailure: {},
285      SubagentStart: {},
286      SubagentStop: {},
287      PreCompact: {},
288      PostCompact: {},
289      PermissionRequest: {},
290      Setup: {},
291      TeammateIdle: {},
292      TaskCreated: {},
293      TaskCompleted: {},
294      Elicitation: {},
295      ElicitationResult: {},
296      ConfigChange: {},
297      WorktreeCreate: {},
298      WorktreeRemove: {},
299      InstructionsLoaded: {},
300      CwdChanged: {},
301      FileChanged: {},
302    }
303  
304    const metadata = getHookEventMetadata(toolNames)
305  
306    // Include hooks from settings files
307    getAllHooks(appState).forEach(hook => {
308      const eventGroup = grouped[hook.event]
309      if (eventGroup) {
310        // For events without matchers, use empty string as key
311        const matcherKey =
312          metadata[hook.event].matcherMetadata !== undefined
313            ? hook.matcher || ''
314            : ''
315        if (!eventGroup[matcherKey]) {
316          eventGroup[matcherKey] = []
317        }
318        eventGroup[matcherKey].push(hook)
319      }
320    })
321  
322    // Include registered hooks (e.g., plugin hooks)
323    const registeredHooks = getRegisteredHooks()
324    if (registeredHooks) {
325      for (const [event, matchers] of Object.entries(registeredHooks)) {
326        const hookEvent = event as HookEvent
327        const eventGroup = grouped[hookEvent]
328        if (!eventGroup) continue
329  
330        for (const matcher of matchers) {
331          const matcherKey = matcher.matcher || ''
332  
333          // Only PluginHookMatcher has pluginRoot; HookCallbackMatcher (internal
334          // callbacks like attributionHooks, sessionFileAccessHooks) does not.
335          if ('pluginRoot' in matcher) {
336            eventGroup[matcherKey] ??= []
337            for (const hook of matcher.hooks) {
338              eventGroup[matcherKey].push({
339                event: hookEvent,
340                config: hook,
341                matcher: matcher.matcher,
342                source: 'pluginHook',
343                pluginName: matcher.pluginId,
344              })
345            }
346          } else if (process.env.USER_TYPE === 'ant') {
347            eventGroup[matcherKey] ??= []
348            for (const _hook of matcher.hooks) {
349              eventGroup[matcherKey].push({
350                event: hookEvent,
351                config: {
352                  type: 'command',
353                  command: '[ANT-ONLY] Built-in Hook',
354                },
355                matcher: matcher.matcher,
356                source: 'builtinHook',
357              })
358            }
359          }
360        }
361      }
362    }
363  
364    return grouped
365  }
366  
367  // Get sorted matchers for a specific event
368  export function getSortedMatchersForEvent(
369    hooksByEventAndMatcher: Record<
370      HookEvent,
371      Record<string, IndividualHookConfig[]>
372    >,
373    event: HookEvent,
374  ): string[] {
375    const matchers = Object.keys(hooksByEventAndMatcher[event] || {})
376    return sortMatchersByPriority(matchers, hooksByEventAndMatcher, event)
377  }
378  
379  // Get hooks for a specific event and matcher
380  export function getHooksForMatcher(
381    hooksByEventAndMatcher: Record<
382      HookEvent,
383      Record<string, IndividualHookConfig[]>
384    >,
385    event: HookEvent,
386    matcher: string | null,
387  ): IndividualHookConfig[] {
388    // For events without matchers, hooks are stored with empty string as key
389    // because the record keys must be strings.
390    const matcherKey = matcher ?? ''
391    return hooksByEventAndMatcher[event]?.[matcherKey] ?? []
392  }
393  
394  // Get metadata for a specific event's matcher
395  export function getMatcherMetadata(
396    event: HookEvent,
397    toolNames: string[],
398  ): MatcherMetadata | undefined {
399    return getHookEventMetadata(toolNames)[event].matcherMetadata
400  }