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
This commit is contained in:
330
server/routes/hooks-approval.ts
Normal file
330
server/routes/hooks-approval.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
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 })
|
||||
}
|
||||
Reference in New Issue
Block a user