chore: initialize recovered claude workspace
This commit is contained in:
309
src/utils/hooks/AsyncHookRegistry.ts
Normal file
309
src/utils/hooks/AsyncHookRegistry.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user