source dump of claude code
at main 309 lines 8.9 kB view raw
1import type { 2 AsyncHookJSONOutput, 3 HookEvent, 4 SyncHookJSONOutput, 5} from 'src/entrypoints/agentSdkTypes.js' 6import { logForDebugging } from '../debug.js' 7import type { ShellCommand } from '../ShellCommand.js' 8import { invalidateSessionEnvCache } from '../sessionEnvironment.js' 9import { jsonParse, jsonStringify } from '../slowOperations.js' 10import { emitHookResponse, startHookProgressInterval } from './hookEvents.js' 11 12export type PendingAsyncHook = { 13 processId: string 14 hookId: string 15 hookName: string 16 hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' 17 toolName?: string 18 pluginId?: string 19 startTime: number 20 timeout: number 21 command: string 22 responseAttachmentSent: boolean 23 shellCommand?: ShellCommand 24 stopProgressInterval: () => void 25} 26 27// Global registry state 28const pendingHooks = new Map<string, PendingAsyncHook>() 29 30export function registerPendingAsyncHook({ 31 processId, 32 hookId, 33 asyncResponse, 34 hookName, 35 hookEvent, 36 command, 37 shellCommand, 38 toolName, 39 pluginId, 40}: { 41 processId: string 42 hookId: string 43 asyncResponse: AsyncHookJSONOutput 44 hookName: string 45 hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' 46 command: string 47 shellCommand: ShellCommand 48 toolName?: string 49 pluginId?: string 50}): void { 51 const timeout = asyncResponse.asyncTimeout || 15000 // Default 15s 52 logForDebugging( 53 `Hooks: Registering async hook ${processId} (${hookName}) with timeout ${timeout}ms`, 54 ) 55 const stopProgressInterval = startHookProgressInterval({ 56 hookId, 57 hookName, 58 hookEvent, 59 getOutput: async () => { 60 const taskOutput = pendingHooks.get(processId)?.shellCommand?.taskOutput 61 if (!taskOutput) { 62 return { stdout: '', stderr: '', output: '' } 63 } 64 const stdout = await taskOutput.getStdout() 65 const stderr = taskOutput.getStderr() 66 return { stdout, stderr, output: stdout + stderr } 67 }, 68 }) 69 pendingHooks.set(processId, { 70 processId, 71 hookId, 72 hookName, 73 hookEvent, 74 toolName, 75 pluginId, 76 command, 77 startTime: Date.now(), 78 timeout, 79 responseAttachmentSent: false, 80 shellCommand, 81 stopProgressInterval, 82 }) 83} 84 85export function getPendingAsyncHooks(): PendingAsyncHook[] { 86 return Array.from(pendingHooks.values()).filter( 87 hook => !hook.responseAttachmentSent, 88 ) 89} 90 91async function finalizeHook( 92 hook: PendingAsyncHook, 93 exitCode: number, 94 outcome: 'success' | 'error' | 'cancelled', 95): Promise<void> { 96 hook.stopProgressInterval() 97 const taskOutput = hook.shellCommand?.taskOutput 98 const stdout = taskOutput ? await taskOutput.getStdout() : '' 99 const stderr = taskOutput?.getStderr() ?? '' 100 hook.shellCommand?.cleanup() 101 emitHookResponse({ 102 hookId: hook.hookId, 103 hookName: hook.hookName, 104 hookEvent: hook.hookEvent, 105 output: stdout + stderr, 106 stdout, 107 stderr, 108 exitCode, 109 outcome, 110 }) 111} 112 113export async function checkForAsyncHookResponses(): Promise< 114 Array<{ 115 processId: string 116 response: SyncHookJSONOutput 117 hookName: string 118 hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' 119 toolName?: string 120 pluginId?: string 121 stdout: string 122 stderr: string 123 exitCode?: number 124 }> 125> { 126 const responses: { 127 processId: string 128 response: SyncHookJSONOutput 129 hookName: string 130 hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' 131 toolName?: string 132 pluginId?: string 133 stdout: string 134 stderr: string 135 exitCode?: number 136 }[] = [] 137 138 const pendingCount = pendingHooks.size 139 logForDebugging(`Hooks: Found ${pendingCount} total hooks in registry`) 140 141 // Snapshot hooks before processing — we'll mutate the map after. 142 const hooks = Array.from(pendingHooks.values()) 143 144 const settled = await Promise.allSettled( 145 hooks.map(async hook => { 146 const stdout = (await hook.shellCommand?.taskOutput.getStdout()) ?? '' 147 const stderr = hook.shellCommand?.taskOutput.getStderr() ?? '' 148 logForDebugging( 149 `Hooks: Checking hook ${hook.processId} (${hook.hookName}) - attachmentSent: ${hook.responseAttachmentSent}, stdout length: ${stdout.length}`, 150 ) 151 152 if (!hook.shellCommand) { 153 logForDebugging( 154 `Hooks: Hook ${hook.processId} has no shell command, removing from registry`, 155 ) 156 hook.stopProgressInterval() 157 return { type: 'remove' as const, processId: hook.processId } 158 } 159 160 logForDebugging(`Hooks: Hook shell status ${hook.shellCommand.status}`) 161 162 if (hook.shellCommand.status === 'killed') { 163 logForDebugging( 164 `Hooks: Hook ${hook.processId} is ${hook.shellCommand.status}, removing from registry`, 165 ) 166 hook.stopProgressInterval() 167 hook.shellCommand.cleanup() 168 return { type: 'remove' as const, processId: hook.processId } 169 } 170 171 if (hook.shellCommand.status !== 'completed') { 172 return { type: 'skip' as const } 173 } 174 175 if (hook.responseAttachmentSent || !stdout.trim()) { 176 logForDebugging( 177 `Hooks: Skipping hook ${hook.processId} - already delivered/sent or no stdout`, 178 ) 179 hook.stopProgressInterval() 180 return { type: 'remove' as const, processId: hook.processId } 181 } 182 183 const lines = stdout.split('\n') 184 logForDebugging( 185 `Hooks: Processing ${lines.length} lines of stdout for ${hook.processId}`, 186 ) 187 188 const execResult = await hook.shellCommand.result 189 const exitCode = execResult.code 190 191 let response: SyncHookJSONOutput = {} 192 for (const line of lines) { 193 if (line.trim().startsWith('{')) { 194 logForDebugging( 195 `Hooks: Found JSON line: ${line.trim().substring(0, 100)}...`, 196 ) 197 try { 198 const parsed = jsonParse(line.trim()) 199 if (!('async' in parsed)) { 200 logForDebugging( 201 `Hooks: Found sync response from ${hook.processId}: ${jsonStringify(parsed)}`, 202 ) 203 response = parsed 204 break 205 } 206 } catch { 207 logForDebugging( 208 `Hooks: Failed to parse JSON from ${hook.processId}: ${line.trim()}`, 209 ) 210 } 211 } 212 } 213 214 hook.responseAttachmentSent = true 215 await finalizeHook(hook, exitCode, exitCode === 0 ? 'success' : 'error') 216 217 return { 218 type: 'response' as const, 219 processId: hook.processId, 220 isSessionStart: hook.hookEvent === 'SessionStart', 221 payload: { 222 processId: hook.processId, 223 response, 224 hookName: hook.hookName, 225 hookEvent: hook.hookEvent, 226 toolName: hook.toolName, 227 pluginId: hook.pluginId, 228 stdout, 229 stderr, 230 exitCode, 231 }, 232 } 233 }), 234 ) 235 236 // allSettled — isolate failures so one throwing callback doesn't orphan 237 // already-applied side effects (responseAttachmentSent, finalizeHook) from others. 238 let sessionStartCompleted = false 239 for (const s of settled) { 240 if (s.status !== 'fulfilled') { 241 logForDebugging( 242 `Hooks: checkForAsyncHookResponses callback rejected: ${s.reason}`, 243 { level: 'error' }, 244 ) 245 continue 246 } 247 const r = s.value 248 if (r.type === 'remove') { 249 pendingHooks.delete(r.processId) 250 } else if (r.type === 'response') { 251 responses.push(r.payload) 252 pendingHooks.delete(r.processId) 253 if (r.isSessionStart) sessionStartCompleted = true 254 } 255 } 256 257 if (sessionStartCompleted) { 258 logForDebugging( 259 `Invalidating session env cache after SessionStart hook completed`, 260 ) 261 invalidateSessionEnvCache() 262 } 263 264 logForDebugging( 265 `Hooks: checkForNewResponses returning ${responses.length} responses`, 266 ) 267 return responses 268} 269 270export function removeDeliveredAsyncHooks(processIds: string[]): void { 271 for (const processId of processIds) { 272 const hook = pendingHooks.get(processId) 273 if (hook && hook.responseAttachmentSent) { 274 logForDebugging(`Hooks: Removing delivered hook ${processId}`) 275 hook.stopProgressInterval() 276 pendingHooks.delete(processId) 277 } 278 } 279} 280 281export async function finalizePendingAsyncHooks(): Promise<void> { 282 const hooks = Array.from(pendingHooks.values()) 283 await Promise.all( 284 hooks.map(async hook => { 285 if (hook.shellCommand?.status === 'completed') { 286 const result = await hook.shellCommand.result 287 await finalizeHook( 288 hook, 289 result.code, 290 result.code === 0 ? 'success' : 'error', 291 ) 292 } else { 293 if (hook.shellCommand && hook.shellCommand.status !== 'killed') { 294 hook.shellCommand.kill() 295 } 296 await finalizeHook(hook, 1, 'cancelled') 297 } 298 }), 299 ) 300 pendingHooks.clear() 301} 302 303// Test utility function to clear all hooks 304export function clearAllAsyncHooks(): void { 305 for (const hook of pendingHooks.values()) { 306 hook.stopProgressInterval() 307 } 308 pendingHooks.clear() 309}