import { jsonResponse, errorResponse } from '../utils/cors' import { PORT_GIT, PORT_TERMINAL } from '../config' // ── Types ── interface PermissionPayload { hook_event_name?: string session_id?: string tool_name?: string tool_input?: unknown agent_name?: string cwd?: string [key: string]: unknown } interface StopPayload { hook_event_name?: string session_id?: string permission_mode?: string stop_hook_active?: boolean transcript_messages?: unknown[] [key: string]: unknown } interface PendingRequest { resolve: (result: T | null) => void timer: ReturnType payload: Record type: 'permission' | 'plan' createdAt: number } // ── State ── const pendingRequests = new Map() const TIMEOUT_MS = 120_000 // ── Broadcast ── // API server (4101) and sync server (4105) are separate Bun processes. // broadcastFn works when called from the sync server process (same module instance). // From the API server process, we fall back to HTTP POST → sync server /broadcast endpoint. let broadcastFn: ((message: string) => void) | null = null export function setHooksApprovalBroadcast(fn: (message: string) => void) { broadcastFn = fn console.log('[HooksApproval] broadcastFn SET (same process)') } function broadcastMessage(message: string) { if (broadcastFn) { console.log('[HooksApproval] Broadcasting via direct fn') broadcastFn(message) return } // Cross-process fallback: POST to sync server /broadcast endpoint console.log('[HooksApproval] Broadcasting via HTTP to sync server') fetch(`http://localhost:${PORT_GIT}/broadcast`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: message }).then(() => { console.log('[HooksApproval] Broadcast via /broadcast OK') }).catch(e => { console.error('[HooksApproval] Broadcast failed:', e.message) }) } function generateId(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } // Notify terminal server (4103) about approval lifecycle → broadcasts state patches to all clients function notifyAddApproval(agent: string, approval: Record) { fetch(`http://localhost:${PORT_TERMINAL}/add-approval`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent, approval }) }).catch(e => console.error('[HooksApproval] Failed to notify add-approval:', e.message)) } function notifyResolveApproval(requestId: string, decision: string) { fetch(`http://localhost:${PORT_TERMINAL}/resolve-approval`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requestId, decision }) }).catch(e => console.error('[HooksApproval] Failed to notify resolve-approval:', e.message)) } // ── Permission (PreToolUse hook) ── export async function handleHooksApprovalPermission(req: Request): Promise { if (req.method !== 'POST') return null try { const body = await req.json() as PermissionPayload const requestId = generateId('haperm') console.log(`[HooksApproval] Permission request ${requestId}: tool=${body.tool_name} agent=${body.agent_name} session=${body.session_id}`) // Broadcast to UI broadcastMessage(JSON.stringify({ type: 'hooks-approval-permission', requestId, tool_name: body.tool_name, tool_input: body.tool_input, agent_name: body.agent_name, session_id: body.session_id, cwd: body.cwd, timestamp: Date.now() })) // Track in centralized session state → broadcasts patch to all clients notifyAddApproval(body.agent_name || 'claude', { requestId, type: 'permission', toolName: body.tool_name, toolInput: body.tool_input, cwd: body.cwd, timestamp: Date.now() }) // Long-poll: wait for UI decision or timeout const result = await new Promise((resolve) => { const timer = setTimeout(() => { pendingRequests.delete(requestId) resolve(null) }, TIMEOUT_MS) pendingRequests.set(requestId, { resolve, timer, payload: body as Record, type: 'permission', createdAt: Date.now() }) }) // null = timeout, let Claude Code handle normally if (result === null) { console.log(`[HooksApproval] Permission ${requestId} timed out`) return jsonResponse({}) } console.log(`[HooksApproval] Permission ${requestId} resolved:`, JSON.stringify(result)) return jsonResponse(result) } catch (e) { console.error(`[HooksApproval] Permission error:`, e) return errorResponse('Invalid JSON body', 400) } } // ── Plan (Stop hook) ── export async function handleHooksApprovalPlan(req: Request): Promise { if (req.method !== 'POST') return null try { const body = await req.json() as StopPayload const requestId = generateId('haplan') console.log(`[HooksApproval] Plan request ${requestId}: session=${body.session_id} mode=${body.permission_mode}`) // Extract last assistant message for display let lastAssistantText = '' if (Array.isArray(body.transcript_messages)) { for (let i = body.transcript_messages.length - 1; i >= 0; i--) { const msg = body.transcript_messages[i] as any if (msg?.role === 'assistant') { if (typeof msg.content === 'string') { lastAssistantText = msg.content } else if (Array.isArray(msg.content)) { lastAssistantText = msg.content .filter((b: any) => b.type === 'text') .map((b: any) => b.text) .join('\n') } break } } } // Broadcast to UI broadcastMessage(JSON.stringify({ type: 'hooks-approval-plan', requestId, session_id: body.session_id, permission_mode: body.permission_mode, lastAssistantText, timestamp: Date.now() })) // Track in centralized session state → broadcasts patch to all clients notifyAddApproval(body.agent_name || 'claude', { requestId, type: 'plan', lastAssistantText, timestamp: Date.now() }) // Long-poll const result = await new Promise((resolve) => { const timer = setTimeout(() => { pendingRequests.delete(requestId) resolve(null) }, TIMEOUT_MS) pendingRequests.set(requestId, { resolve, timer, payload: body as Record, type: 'plan', createdAt: Date.now() }) }) if (result === null) { console.log(`[HooksApproval] Plan ${requestId} timed out`) return jsonResponse({}) } console.log(`[HooksApproval] Plan ${requestId} resolved:`, JSON.stringify(result)) return jsonResponse(result) } catch (e) { console.error(`[HooksApproval] Plan error:`, e) return errorResponse('Invalid JSON body', 400) } } // ── Respond to permission ── export async function handleHooksApprovalRespond(req: Request): Promise { if (req.method !== 'POST') return null try { const body = await req.json() as { requestId: string; decision: string; reason?: string } if (!body.requestId || !body.decision) { return errorResponse('Missing requestId or decision', 400) } if (!['allow', 'allowAlways', 'deny'].includes(body.decision)) { return errorResponse('Decision must be "allow", "allowAlways", or "deny"', 400) } const pending = pendingRequests.get(body.requestId) if (!pending || pending.type !== 'permission') { console.warn(`[HooksApproval] Respond permission: ${body.requestId} not found (expired?)`) return errorResponse('Permission request not found or expired', 404) } console.log(`[HooksApproval] Responding permission ${body.requestId}: ${body.decision}${body.reason ? ' reason=' + body.reason : ''}`) clearTimeout(pending.timer) pendingRequests.delete(body.requestId) // Notify all clients that this approval was resolved notifyResolveApproval(body.requestId, body.decision) // Build hookSpecificOutput per PermissionRequest docs let hookOutput: Record if (body.decision === 'allow') { hookOutput = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow' } } } } else if (body.decision === 'allowAlways') { // Allow + add tool to allowed permissions for session const toolName = pending.payload.tool_name as string || '*' hookOutput = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow' }, allowedPermissions: [{ tool_name: toolName }] } } } else { // deny — with optional custom message hookOutput = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny', message: body.reason || 'Denied via UI' } } } } pending.resolve(hookOutput) return jsonResponse({ success: true, requestId: body.requestId, decision: body.decision }) } catch (e) { return errorResponse('Invalid JSON body', 400) } } // ── Respond to plan ── export async function handleHooksApprovalRespondPlan(req: Request): Promise { if (req.method !== 'POST') return null try { const body = await req.json() as { requestId: string; decision: 'approve' | 'reject' | 'edit'; reason?: string } if (!body.requestId || !body.decision) { return errorResponse('Missing requestId or decision', 400) } if (!['approve', 'reject', 'edit'].includes(body.decision)) { return errorResponse('Decision must be "approve", "reject", or "edit"', 400) } const pending = pendingRequests.get(body.requestId) if (!pending || pending.type !== 'plan') { console.warn(`[HooksApproval] Respond plan: ${body.requestId} not found (expired?)`) return errorResponse('Plan request not found or expired', 404) } console.log(`[HooksApproval] Responding plan ${body.requestId}: ${body.decision}`) clearTimeout(pending.timer) pendingRequests.delete(body.requestId) // Notify all clients that this approval was resolved notifyResolveApproval(body.requestId, body.decision) // Build Stop hook output per docs: // "approve" plan = "block" stop (so Claude continues to implement) // "reject" plan = let Claude stop (empty response) // "edit" = "block" stop with user instructions as reason let stopResult: Record if (body.decision === 'approve') { stopResult = { decision: 'block', reason: 'Plan approved via UI. Proceed with implementation.' } } else if (body.decision === 'reject') { // Let Claude stop — empty response means no blocking stopResult = {} } else { // edit = block stop + supply user instructions stopResult = { decision: 'block', reason: body.reason || 'Continue with modifications.' } } pending.resolve(stopResult) return jsonResponse({ success: true, requestId: body.requestId, decision: body.decision }) } catch (e) { return errorResponse('Invalid JSON body', 400) } } // ── Ignore (remove from UI state only, don't respond to Claude Code) ── export async function handleHooksApprovalIgnore(req: Request): Promise { if (req.method !== 'POST') return null try { const body = await req.json() as { requestId: string } if (!body.requestId) { return errorResponse('Missing requestId', 400) } console.log(`[HooksApproval] Ignoring ${body.requestId} (removing from UI state only)`) // Remove from session state → broadcasts patch to all clients // The long-poll promise stays alive and will timeout naturally (120s), // at which point Claude Code gets {} and handles it with default behavior. notifyResolveApproval(body.requestId, 'ignore') return jsonResponse({ success: true, requestId: body.requestId }) } catch (e) { return errorResponse('Invalid JSON body', 400) } } // ── List pending (for state recovery) ── export async function handleHooksApprovalList(req: Request): Promise { if (req.method !== 'GET') return null const permissions: unknown[] = [] const plans: unknown[] = [] for (const [id, p] of pendingRequests) { const base = { requestId: id, session_id: p.payload.session_id, createdAt: p.createdAt } if (p.type === 'permission') { permissions.push({ ...base, tool_name: p.payload.tool_name, tool_input: p.payload.tool_input, agent_name: p.payload.agent_name }) } else { plans.push({ ...base, permission_mode: p.payload.permission_mode, lastAssistantText: '' // Not stored, UI gets it from WS broadcast }) } } return jsonResponse({ permissions, plans }) }