/ utils / hooks / hookEvents.ts
hookEvents.ts
  1  /**
  2   * Hook event system for broadcasting hook execution events.
  3   *
  4   * This module provides a generic event system that is separate from the
  5   * main message stream. Handlers can register to receive events and decide
  6   * what to do with them (e.g., convert to SDK messages, log, etc.).
  7   */
  8  
  9  import { HOOK_EVENTS } from 'src/entrypoints/sdk/coreTypes.js'
 10  
 11  import { logForDebugging } from '../debug.js'
 12  
 13  /**
 14   * Hook events that are always emitted regardless of the includeHookEvents
 15   * option. These are low-noise lifecycle events that were in the original
 16   * allowlist and are backwards-compatible.
 17   */
 18  const ALWAYS_EMITTED_HOOK_EVENTS = ['SessionStart', 'Setup'] as const
 19  
 20  const MAX_PENDING_EVENTS = 100
 21  
 22  export type HookStartedEvent = {
 23    type: 'started'
 24    hookId: string
 25    hookName: string
 26    hookEvent: string
 27  }
 28  
 29  export type HookProgressEvent = {
 30    type: 'progress'
 31    hookId: string
 32    hookName: string
 33    hookEvent: string
 34    stdout: string
 35    stderr: string
 36    output: string
 37  }
 38  
 39  export type HookResponseEvent = {
 40    type: 'response'
 41    hookId: string
 42    hookName: string
 43    hookEvent: string
 44    output: string
 45    stdout: string
 46    stderr: string
 47    exitCode?: number
 48    outcome: 'success' | 'error' | 'cancelled'
 49  }
 50  
 51  export type HookExecutionEvent =
 52    | HookStartedEvent
 53    | HookProgressEvent
 54    | HookResponseEvent
 55  export type HookEventHandler = (event: HookExecutionEvent) => void
 56  
 57  const pendingEvents: HookExecutionEvent[] = []
 58  let eventHandler: HookEventHandler | null = null
 59  let allHookEventsEnabled = false
 60  
 61  export function registerHookEventHandler(
 62    handler: HookEventHandler | null,
 63  ): void {
 64    eventHandler = handler
 65    if (handler && pendingEvents.length > 0) {
 66      for (const event of pendingEvents.splice(0)) {
 67        handler(event)
 68      }
 69    }
 70  }
 71  
 72  function emit(event: HookExecutionEvent): void {
 73    if (eventHandler) {
 74      eventHandler(event)
 75    } else {
 76      pendingEvents.push(event)
 77      if (pendingEvents.length > MAX_PENDING_EVENTS) {
 78        pendingEvents.shift()
 79      }
 80    }
 81  }
 82  
 83  function shouldEmit(hookEvent: string): boolean {
 84    if ((ALWAYS_EMITTED_HOOK_EVENTS as readonly string[]).includes(hookEvent)) {
 85      return true
 86    }
 87    return (
 88      allHookEventsEnabled &&
 89      (HOOK_EVENTS as readonly string[]).includes(hookEvent)
 90    )
 91  }
 92  
 93  export function emitHookStarted(
 94    hookId: string,
 95    hookName: string,
 96    hookEvent: string,
 97  ): void {
 98    if (!shouldEmit(hookEvent)) return
 99  
100    emit({
101      type: 'started',
102      hookId,
103      hookName,
104      hookEvent,
105    })
106  }
107  
108  export function emitHookProgress(data: {
109    hookId: string
110    hookName: string
111    hookEvent: string
112    stdout: string
113    stderr: string
114    output: string
115  }): void {
116    if (!shouldEmit(data.hookEvent)) return
117  
118    emit({
119      type: 'progress',
120      ...data,
121    })
122  }
123  
124  export function startHookProgressInterval(params: {
125    hookId: string
126    hookName: string
127    hookEvent: string
128    getOutput: () => Promise<{ stdout: string; stderr: string; output: string }>
129    intervalMs?: number
130  }): () => void {
131    if (!shouldEmit(params.hookEvent)) return () => {}
132  
133    let lastEmittedOutput = ''
134    const interval = setInterval(() => {
135      void params.getOutput().then(({ stdout, stderr, output }) => {
136        if (output === lastEmittedOutput) return
137        lastEmittedOutput = output
138        emitHookProgress({
139          hookId: params.hookId,
140          hookName: params.hookName,
141          hookEvent: params.hookEvent,
142          stdout,
143          stderr,
144          output,
145        })
146      })
147    }, params.intervalMs ?? 1000)
148    interval.unref()
149  
150    return () => clearInterval(interval)
151  }
152  
153  export function emitHookResponse(data: {
154    hookId: string
155    hookName: string
156    hookEvent: string
157    output: string
158    stdout: string
159    stderr: string
160    exitCode?: number
161    outcome: 'success' | 'error' | 'cancelled'
162  }): void {
163    // Always log full hook output to debug log for verbose mode debugging
164    const outputToLog = data.stdout || data.stderr || data.output
165    if (outputToLog) {
166      logForDebugging(
167        `Hook ${data.hookName} (${data.hookEvent}) ${data.outcome}:\n${outputToLog}`,
168      )
169    }
170  
171    if (!shouldEmit(data.hookEvent)) return
172  
173    emit({
174      type: 'response',
175      ...data,
176    })
177  }
178  
179  /**
180   * Enable emission of all hook event types (beyond SessionStart and Setup).
181   * Called when the SDK `includeHookEvents` option is set or when running
182   * in CLAUDE_CODE_REMOTE mode.
183   */
184  export function setAllHookEventsEnabled(enabled: boolean): void {
185    allHookEventsEnabled = enabled
186  }
187  
188  export function clearHookEventState(): void {
189    eventHandler = null
190    pendingEvents.length = 0
191    allHookEventsEnabled = false
192  }