310 lines
8.7 KiB
TypeScript
310 lines
8.7 KiB
TypeScript
import type {
|
|
AsyncHookJSONOutput,
|
|
HookEvent,
|
|
SyncHookJSONOutput,
|
|
} from 'src/entrypoints/agentSdkTypes.js'
|
|
import { logForDebugging } from '../debug.js'
|
|
import type { ShellCommand } from '../ShellCommand.js'
|
|
import { invalidateSessionEnvCache } from '../sessionEnvironment.js'
|
|
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
|
import { emitHookResponse, startHookProgressInterval } from './hookEvents.js'
|
|
|
|
export type PendingAsyncHook = {
|
|
processId: string
|
|
hookId: string
|
|
hookName: string
|
|
hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion'
|
|
toolName?: string
|
|
pluginId?: string
|
|
startTime: number
|
|
timeout: number
|
|
command: string
|
|
responseAttachmentSent: boolean
|
|
shellCommand?: ShellCommand
|
|
stopProgressInterval: () => void
|
|
}
|
|
|
|
// Global registry state
|
|
const pendingHooks = new Map<string, PendingAsyncHook>()
|
|
|
|
export function registerPendingAsyncHook({
|
|
processId,
|
|
hookId,
|
|
asyncResponse,
|
|
hookName,
|
|
hookEvent,
|
|
command,
|
|
shellCommand,
|
|
toolName,
|
|
pluginId,
|
|
}: {
|
|
processId: string
|
|
hookId: string
|
|
asyncResponse: AsyncHookJSONOutput
|
|
hookName: string
|
|
hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion'
|
|
command: string
|
|
shellCommand: ShellCommand
|
|
toolName?: string
|
|
pluginId?: string
|
|
}): void {
|
|
const timeout = asyncResponse.asyncTimeout || 15000 // Default 15s
|
|
logForDebugging(
|
|
`Hooks: Registering async hook ${processId} (${hookName}) with timeout ${timeout}ms`,
|
|
)
|
|
const stopProgressInterval = startHookProgressInterval({
|
|
hookId,
|
|
hookName,
|
|
hookEvent,
|
|
getOutput: async () => {
|
|
const taskOutput = pendingHooks.get(processId)?.shellCommand?.taskOutput
|
|
if (!taskOutput) {
|
|
return { stdout: '', stderr: '', output: '' }
|
|
}
|
|
const stdout = await taskOutput.getStdout()
|
|
const stderr = taskOutput.getStderr()
|
|
return { stdout, stderr, output: stdout + stderr }
|
|
},
|
|
})
|
|
pendingHooks.set(processId, {
|
|
processId,
|
|
hookId,
|
|
hookName,
|
|
hookEvent,
|
|
toolName,
|
|
pluginId,
|
|
command,
|
|
startTime: Date.now(),
|
|
timeout,
|
|
responseAttachmentSent: false,
|
|
shellCommand,
|
|
stopProgressInterval,
|
|
})
|
|
}
|
|
|
|
export function getPendingAsyncHooks(): PendingAsyncHook[] {
|
|
return Array.from(pendingHooks.values()).filter(
|
|
hook => !hook.responseAttachmentSent,
|
|
)
|
|
}
|
|
|
|
async function finalizeHook(
|
|
hook: PendingAsyncHook,
|
|
exitCode: number,
|
|
outcome: 'success' | 'error' | 'cancelled',
|
|
): Promise<void> {
|
|
hook.stopProgressInterval()
|
|
const taskOutput = hook.shellCommand?.taskOutput
|
|
const stdout = taskOutput ? await taskOutput.getStdout() : ''
|
|
const stderr = taskOutput?.getStderr() ?? ''
|
|
hook.shellCommand?.cleanup()
|
|
emitHookResponse({
|
|
hookId: hook.hookId,
|
|
hookName: hook.hookName,
|
|
hookEvent: hook.hookEvent,
|
|
output: stdout + stderr,
|
|
stdout,
|
|
stderr,
|
|
exitCode,
|
|
outcome,
|
|
})
|
|
}
|
|
|
|
export async function checkForAsyncHookResponses(): Promise<
|
|
Array<{
|
|
processId: string
|
|
response: SyncHookJSONOutput
|
|
hookName: string
|
|
hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion'
|
|
toolName?: string
|
|
pluginId?: string
|
|
stdout: string
|
|
stderr: string
|
|
exitCode?: number
|
|
}>
|
|
> {
|
|
const responses: {
|
|
processId: string
|
|
response: SyncHookJSONOutput
|
|
hookName: string
|
|
hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion'
|
|
toolName?: string
|
|
pluginId?: string
|
|
stdout: string
|
|
stderr: string
|
|
exitCode?: number
|
|
}[] = []
|
|
|
|
const pendingCount = pendingHooks.size
|
|
logForDebugging(`Hooks: Found ${pendingCount} total hooks in registry`)
|
|
|
|
// Snapshot hooks before processing — we'll mutate the map after.
|
|
const hooks = Array.from(pendingHooks.values())
|
|
|
|
const settled = await Promise.allSettled(
|
|
hooks.map(async hook => {
|
|
const stdout = (await hook.shellCommand?.taskOutput.getStdout()) ?? ''
|
|
const stderr = hook.shellCommand?.taskOutput.getStderr() ?? ''
|
|
logForDebugging(
|
|
`Hooks: Checking hook ${hook.processId} (${hook.hookName}) - attachmentSent: ${hook.responseAttachmentSent}, stdout length: ${stdout.length}`,
|
|
)
|
|
|
|
if (!hook.shellCommand) {
|
|
logForDebugging(
|
|
`Hooks: Hook ${hook.processId} has no shell command, removing from registry`,
|
|
)
|
|
hook.stopProgressInterval()
|
|
return { type: 'remove' as const, processId: hook.processId }
|
|
}
|
|
|
|
logForDebugging(`Hooks: Hook shell status ${hook.shellCommand.status}`)
|
|
|
|
if (hook.shellCommand.status === 'killed') {
|
|
logForDebugging(
|
|
`Hooks: Hook ${hook.processId} is ${hook.shellCommand.status}, removing from registry`,
|
|
)
|
|
hook.stopProgressInterval()
|
|
hook.shellCommand.cleanup()
|
|
return { type: 'remove' as const, processId: hook.processId }
|
|
}
|
|
|
|
if (hook.shellCommand.status !== 'completed') {
|
|
return { type: 'skip' as const }
|
|
}
|
|
|
|
if (hook.responseAttachmentSent || !stdout.trim()) {
|
|
logForDebugging(
|
|
`Hooks: Skipping hook ${hook.processId} - already delivered/sent or no stdout`,
|
|
)
|
|
hook.stopProgressInterval()
|
|
return { type: 'remove' as const, processId: hook.processId }
|
|
}
|
|
|
|
const lines = stdout.split('\n')
|
|
logForDebugging(
|
|
`Hooks: Processing ${lines.length} lines of stdout for ${hook.processId}`,
|
|
)
|
|
|
|
const execResult = await hook.shellCommand.result
|
|
const exitCode = execResult.code
|
|
|
|
let response: SyncHookJSONOutput = {}
|
|
for (const line of lines) {
|
|
if (line.trim().startsWith('{')) {
|
|
logForDebugging(
|
|
`Hooks: Found JSON line: ${line.trim().substring(0, 100)}...`,
|
|
)
|
|
try {
|
|
const parsed = jsonParse(line.trim())
|
|
if (!('async' in parsed)) {
|
|
logForDebugging(
|
|
`Hooks: Found sync response from ${hook.processId}: ${jsonStringify(parsed)}`,
|
|
)
|
|
response = parsed
|
|
break
|
|
}
|
|
} catch {
|
|
logForDebugging(
|
|
`Hooks: Failed to parse JSON from ${hook.processId}: ${line.trim()}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
hook.responseAttachmentSent = true
|
|
await finalizeHook(hook, exitCode, exitCode === 0 ? 'success' : 'error')
|
|
|
|
return {
|
|
type: 'response' as const,
|
|
processId: hook.processId,
|
|
isSessionStart: hook.hookEvent === 'SessionStart',
|
|
payload: {
|
|
processId: hook.processId,
|
|
response,
|
|
hookName: hook.hookName,
|
|
hookEvent: hook.hookEvent,
|
|
toolName: hook.toolName,
|
|
pluginId: hook.pluginId,
|
|
stdout,
|
|
stderr,
|
|
exitCode,
|
|
},
|
|
}
|
|
}),
|
|
)
|
|
|
|
// allSettled — isolate failures so one throwing callback doesn't orphan
|
|
// already-applied side effects (responseAttachmentSent, finalizeHook) from others.
|
|
let sessionStartCompleted = false
|
|
for (const s of settled) {
|
|
if (s.status !== 'fulfilled') {
|
|
logForDebugging(
|
|
`Hooks: checkForAsyncHookResponses callback rejected: ${s.reason}`,
|
|
{ level: 'error' },
|
|
)
|
|
continue
|
|
}
|
|
const r = s.value
|
|
if (r.type === 'remove') {
|
|
pendingHooks.delete(r.processId)
|
|
} else if (r.type === 'response') {
|
|
responses.push(r.payload)
|
|
pendingHooks.delete(r.processId)
|
|
if (r.isSessionStart) sessionStartCompleted = true
|
|
}
|
|
}
|
|
|
|
if (sessionStartCompleted) {
|
|
logForDebugging(
|
|
`Invalidating session env cache after SessionStart hook completed`,
|
|
)
|
|
invalidateSessionEnvCache()
|
|
}
|
|
|
|
logForDebugging(
|
|
`Hooks: checkForNewResponses returning ${responses.length} responses`,
|
|
)
|
|
return responses
|
|
}
|
|
|
|
export function removeDeliveredAsyncHooks(processIds: string[]): void {
|
|
for (const processId of processIds) {
|
|
const hook = pendingHooks.get(processId)
|
|
if (hook && hook.responseAttachmentSent) {
|
|
logForDebugging(`Hooks: Removing delivered hook ${processId}`)
|
|
hook.stopProgressInterval()
|
|
pendingHooks.delete(processId)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function finalizePendingAsyncHooks(): Promise<void> {
|
|
const hooks = Array.from(pendingHooks.values())
|
|
await Promise.all(
|
|
hooks.map(async hook => {
|
|
if (hook.shellCommand?.status === 'completed') {
|
|
const result = await hook.shellCommand.result
|
|
await finalizeHook(
|
|
hook,
|
|
result.code,
|
|
result.code === 0 ? 'success' : 'error',
|
|
)
|
|
} else {
|
|
if (hook.shellCommand && hook.shellCommand.status !== 'killed') {
|
|
hook.shellCommand.kill()
|
|
}
|
|
await finalizeHook(hook, 1, 'cancelled')
|
|
}
|
|
}),
|
|
)
|
|
pendingHooks.clear()
|
|
}
|
|
|
|
// Test utility function to clear all hooks
|
|
export function clearAllAsyncHooks(): void {
|
|
for (const hook of pendingHooks.values()) {
|
|
hook.stopProgressInterval()
|
|
}
|
|
pendingHooks.clear()
|
|
}
|