import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' import type { AppState } from 'src/state/AppState.js' import type { Message } from 'src/types/message.js' import { logForDebugging } from '../debug.js' import type { AggregatedHookResult } from '../hooks.js' import type { HookCommand } from '../settings/types.js' import { isHookEqual } from './hooksSettings.js' type OnHookSuccess = ( hook: HookCommand | FunctionHook, result: AggregatedHookResult, ) => void /** Function hook callback - returns true if check passes, false to block */ export type FunctionHookCallback = ( messages: Message[], signal?: AbortSignal, ) => boolean | Promise /** * Function hook type with callback embedded. * Session-scoped only, cannot be persisted to settings.json. */ export type FunctionHook = { type: 'function' id?: string // Optional unique ID for removal timeout?: number callback: FunctionHookCallback errorMessage: string statusMessage?: string } type SessionHookMatcher = { matcher: string skillRoot?: string hooks: Array<{ hook: HookCommand | FunctionHook onHookSuccess?: OnHookSuccess }> } export type SessionStore = { hooks: { [event in HookEvent]?: SessionHookMatcher[] } } /** * Map (not Record) so .set/.delete don't change the container's identity. * Mutator functions mutate the Map and return prev unchanged, letting * store.ts's Object.is(next, prev) check short-circuit and skip listener * notification. Session hooks are ephemeral per-agent runtime callbacks, * never reactively read (only getAppState() snapshots in the query loop). * Same pattern as agentControllers on LocalWorkflowTaskState. * * This matters under high-concurrency workflows: parallel() with N * schema-mode agents fires N addFunctionHook calls in one synchronous * tick. With a Record + spread, each call cost O(N) to copy the growing * map (O(N²) total) plus fired all ~30 store listeners. With Map: .set() * is O(1), return prev means zero listener fires. */ export type SessionHooksState = Map /** * Add a command or prompt hook to the session. * Session hooks are temporary, in-memory only, and cleared when session ends. */ export function addSessionHook( setAppState: (updater: (prev: AppState) => AppState) => void, sessionId: string, event: HookEvent, matcher: string, hook: HookCommand, onHookSuccess?: OnHookSuccess, skillRoot?: string, ): void { addHookToSession( setAppState, sessionId, event, matcher, hook, onHookSuccess, skillRoot, ) } /** * Add a function hook to the session. * Function hooks execute TypeScript callbacks in-memory for validation. * @returns The hook ID (for removal) */ export function addFunctionHook( setAppState: (updater: (prev: AppState) => AppState) => void, sessionId: string, event: HookEvent, matcher: string, callback: FunctionHookCallback, errorMessage: string, options?: { timeout?: number id?: string }, ): string { const id = options?.id || `function-hook-${Date.now()}-${Math.random()}` const hook: FunctionHook = { type: 'function', id, timeout: options?.timeout || 5000, callback, errorMessage, } addHookToSession(setAppState, sessionId, event, matcher, hook) return id } /** * Remove a function hook by ID from the session. */ export function removeFunctionHook( setAppState: (updater: (prev: AppState) => AppState) => void, sessionId: string, event: HookEvent, hookId: string, ): void { setAppState(prev => { const store = prev.sessionHooks.get(sessionId) if (!store) { return prev } const eventMatchers = store.hooks[event] || [] // Remove the hook with matching ID from all matchers const updatedMatchers = eventMatchers .map(matcher => { const updatedHooks = matcher.hooks.filter(h => { if (h.hook.type !== 'function') return true return h.hook.id !== hookId }) return updatedHooks.length > 0 ? { ...matcher, hooks: updatedHooks } : null }) .filter((m): m is SessionHookMatcher => m !== null) const newHooks = updatedMatchers.length > 0 ? { ...store.hooks, [event]: updatedMatchers } : Object.fromEntries( Object.entries(store.hooks).filter(([e]) => e !== event), ) prev.sessionHooks.set(sessionId, { hooks: newHooks }) return prev }) logForDebugging( `Removed function hook ${hookId} for event ${event} in session ${sessionId}`, ) } /** * Internal helper to add a hook to session state */ function addHookToSession( setAppState: (updater: (prev: AppState) => AppState) => void, sessionId: string, event: HookEvent, matcher: string, hook: HookCommand | FunctionHook, onHookSuccess?: OnHookSuccess, skillRoot?: string, ): void { setAppState(prev => { const store = prev.sessionHooks.get(sessionId) ?? { hooks: {} } const eventMatchers = store.hooks[event] || [] // Find existing matcher or create new one const existingMatcherIndex = eventMatchers.findIndex( m => m.matcher === matcher && m.skillRoot === skillRoot, ) let updatedMatchers: SessionHookMatcher[] if (existingMatcherIndex >= 0) { // Add to existing matcher updatedMatchers = [...eventMatchers] const existingMatcher = updatedMatchers[existingMatcherIndex]! updatedMatchers[existingMatcherIndex] = { matcher: existingMatcher.matcher, skillRoot: existingMatcher.skillRoot, hooks: [...existingMatcher.hooks, { hook, onHookSuccess }], } } else { // Create new matcher updatedMatchers = [ ...eventMatchers, { matcher, skillRoot, hooks: [{ hook, onHookSuccess }], }, ] } const newHooks = { ...store.hooks, [event]: updatedMatchers } prev.sessionHooks.set(sessionId, { hooks: newHooks }) return prev }) logForDebugging( `Added session hook for event ${event} in session ${sessionId}`, ) } /** * Remove a specific hook from the session * @param setAppState The function to update the app state * @param sessionId The session ID * @param event The hook event * @param hook The hook command to remove */ export function removeSessionHook( setAppState: (updater: (prev: AppState) => AppState) => void, sessionId: string, event: HookEvent, hook: HookCommand, ): void { setAppState(prev => { const store = prev.sessionHooks.get(sessionId) if (!store) { return prev } const eventMatchers = store.hooks[event] || [] // Remove the hook from all matchers const updatedMatchers = eventMatchers .map(matcher => { const updatedHooks = matcher.hooks.filter( h => !isHookEqual(h.hook, hook), ) return updatedHooks.length > 0 ? { ...matcher, hooks: updatedHooks } : null }) .filter((m): m is SessionHookMatcher => m !== null) const newHooks = updatedMatchers.length > 0 ? { ...store.hooks, [event]: updatedMatchers } : { ...store.hooks } if (updatedMatchers.length === 0) { delete newHooks[event] } prev.sessionHooks.set(sessionId, { ...store, hooks: newHooks }) return prev }) logForDebugging( `Removed session hook for event ${event} in session ${sessionId}`, ) } // Extended hook matcher that includes optional skillRoot for skill-scoped hooks export type SessionDerivedHookMatcher = { matcher: string hooks: HookCommand[] skillRoot?: string } /** * Convert session hook matchers to regular hook matchers * @param sessionMatchers The session hook matchers to convert * @returns Regular hook matchers (with optional skillRoot preserved) */ function convertToHookMatchers( sessionMatchers: SessionHookMatcher[], ): SessionDerivedHookMatcher[] { return sessionMatchers.map(sm => ({ matcher: sm.matcher, skillRoot: sm.skillRoot, // Filter out function hooks - they can't be persisted to HookMatcher format hooks: sm.hooks .map(h => h.hook) .filter((h): h is HookCommand => h.type !== 'function'), })) } /** * Get all session hooks for a specific event (excluding function hooks) * @param appState The app state * @param sessionId The session ID * @param event Optional event to filter by * @returns Hook matchers for the event, or all hooks if no event specified */ export function getSessionHooks( appState: AppState, sessionId: string, event?: HookEvent, ): Map { const store = appState.sessionHooks.get(sessionId) if (!store) { return new Map() } const result = new Map() if (event) { const sessionMatchers = store.hooks[event] if (sessionMatchers) { result.set(event, convertToHookMatchers(sessionMatchers)) } return result } for (const evt of HOOK_EVENTS) { const sessionMatchers = store.hooks[evt] if (sessionMatchers) { result.set(evt, convertToHookMatchers(sessionMatchers)) } } return result } type FunctionHookMatcher = { matcher: string hooks: FunctionHook[] } /** * Get all session function hooks for a specific event * Function hooks are kept separate because they can't be persisted to HookMatcher format. * @param appState The app state * @param sessionId The session ID * @param event Optional event to filter by * @returns Function hook matchers for the event */ export function getSessionFunctionHooks( appState: AppState, sessionId: string, event?: HookEvent, ): Map { const store = appState.sessionHooks.get(sessionId) if (!store) { return new Map() } const result = new Map() const extractFunctionHooks = ( sessionMatchers: SessionHookMatcher[], ): FunctionHookMatcher[] => { return sessionMatchers .map(sm => ({ matcher: sm.matcher, hooks: sm.hooks .map(h => h.hook) .filter((h): h is FunctionHook => h.type === 'function'), })) .filter(m => m.hooks.length > 0) } if (event) { const sessionMatchers = store.hooks[event] if (sessionMatchers) { const functionMatchers = extractFunctionHooks(sessionMatchers) if (functionMatchers.length > 0) { result.set(event, functionMatchers) } } return result } for (const evt of HOOK_EVENTS) { const sessionMatchers = store.hooks[evt] if (sessionMatchers) { const functionMatchers = extractFunctionHooks(sessionMatchers) if (functionMatchers.length > 0) { result.set(evt, functionMatchers) } } } return result } /** * Get the full hook entry (including callbacks) for a specific session hook */ export function getSessionHookCallback( appState: AppState, sessionId: string, event: HookEvent, matcher: string, hook: HookCommand | FunctionHook, ): | { hook: HookCommand | FunctionHook onHookSuccess?: OnHookSuccess } | undefined { const store = appState.sessionHooks.get(sessionId) if (!store) { return undefined } const eventMatchers = store.hooks[event] if (!eventMatchers) { return undefined } // Find the hook in the matchers for (const matcherEntry of eventMatchers) { if (matcherEntry.matcher === matcher || matcher === '') { const hookEntry = matcherEntry.hooks.find(h => isHookEqual(h.hook, hook)) if (hookEntry) { return hookEntry } } } return undefined } /** * Clear all session hooks for a specific session * @param setAppState The function to update the app state * @param sessionId The session ID */ export function clearSessionHooks( setAppState: (updater: (prev: AppState) => AppState) => void, sessionId: string, ): void { setAppState(prev => { prev.sessionHooks.delete(sessionId) return prev }) logForDebugging(`Cleared all session hooks for session ${sessionId}`) }