Files
agent-ui/server/routes/hooks-approval.ts
josedario87 9bd6123f97 feat: Add transcript-debug page with multi-agent support, hooks approval, and message selection
- Transcript debug: JSONL viewer, parsed chat view, realtime WebSocket updates, session selector
- Multi-agent: ejecutor, nucleo000, and claude (global ~/.claude/projects/) with agent switcher
- Hooks approval: permission/plan request forwarding via PowerShell hooks, long-poll API, UI modals
- Chat features: session ID copy, select mode with checkboxes, multi-select copy, select all/deselect all
- File watchers for all agent transcript directories with polling fallback on Windows
2026-02-18 23:55:09 -06:00

331 lines
10 KiB
TypeScript

import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_GIT } 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<T = unknown> {
resolve: (result: T | null) => void
timer: ReturnType<typeof setTimeout>
payload: Record<string, unknown>
type: 'permission' | 'plan'
createdAt: number
}
// ── State ──
const pendingRequests = new Map<string, PendingRequest>()
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)}`
}
// ── Permission (PreToolUse hook) ──
export async function handleHooksApprovalPermission(req: Request): Promise<Response | null> {
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()
}))
// Long-poll: wait for UI decision or timeout
const result = await new Promise<unknown>((resolve) => {
const timer = setTimeout(() => {
pendingRequests.delete(requestId)
resolve(null)
}, TIMEOUT_MS)
pendingRequests.set(requestId, {
resolve,
timer,
payload: body as Record<string, unknown>,
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<Response | null> {
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()
}))
// Long-poll
const result = await new Promise<unknown>((resolve) => {
const timer = setTimeout(() => {
pendingRequests.delete(requestId)
resolve(null)
}, TIMEOUT_MS)
pendingRequests.set(requestId, {
resolve,
timer,
payload: body as Record<string, unknown>,
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<Response | null> {
if (req.method !== 'POST') return null
try {
const body = await req.json() as { requestId: string; decision: 'allow' | 'deny' }
if (!body.requestId || !body.decision) {
return errorResponse('Missing requestId or decision', 400)
}
if (!['allow', 'deny'].includes(body.decision)) {
return errorResponse('Decision must be "allow" 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}`)
clearTimeout(pending.timer)
pendingRequests.delete(body.requestId)
// Build hookSpecificOutput per PermissionRequest docs:
// hookSpecificOutput.decision.behavior = "allow" | "deny"
const hookOutput: Record<string, unknown> = body.decision === 'allow'
? {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: {
behavior: 'allow'
}
}
}
: {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: {
behavior: 'deny',
message: '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<Response | null> {
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)
// 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<string, unknown>
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)
}
}
// ── List pending (for state recovery) ──
export async function handleHooksApprovalList(req: Request): Promise<Response | null> {
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 })
}