Files
agent-ui/server/routes/claude-permission.ts
josedario87 816a8d9abe feat: Rich hook forwarding, permission bridge, and toast notifications
Replace hardcoded PowerShell status hooks with stdin-forwarding hooks
that send full Claude Code hook data (tool_input, tool_response, prompt,
session_id, model, etc.) to /api/claude-hook endpoint.

- PowerShell hooks read stdin JSON and POST to /api/claude-hook
- Server derives status for backward-compat FAB animations
- Server extracts assistant_response from transcript on Stop events
- New /api/claude-permission endpoint with Promise-based allow/deny flow
- HookNotifications.vue: toast system showing session, prompt, tool use,
  tool results, notifications, and final assistant response
- WebSocket broadcast for claude-hook and claude-permission message types
2026-02-15 16:16:59 -06:00

112 lines
3.4 KiB
TypeScript

import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_TERMINAL } from '../config'
interface PermissionPayload {
hook_event_name?: string
session_id?: string
tool_name?: string
tool_input?: unknown
cwd?: string
[key: string]: unknown
}
interface PendingPermission {
resolve: (decision: string) => void
timer: ReturnType<typeof setTimeout>
payload: PermissionPayload
}
// Map of requestId -> pending permission promise resolver
const pendingPermissions = new Map<string, PendingPermission>()
const PERMISSION_TIMEOUT_MS = 60_000
export async function handleClaudePermission(req: Request): Promise<Response | null> {
if (req.method !== 'POST') return null
try {
const body = await req.json() as PermissionPayload
const requestId = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
// Broadcast permission request to UI via WebSocket
try {
await fetch(`http://localhost:${PORT_TERMINAL}/claude-permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, ...body })
})
} catch (e) {
console.error('[claude-permission] Failed to broadcast to terminal server:', e)
}
// Also broadcast claude-status for backward compat (animations)
try {
await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'permissionRequest', tool: body.tool_name })
})
} catch (e) {
console.error('[claude-permission] Failed to forward status:', e)
}
// Wait for UI decision (or timeout)
const decision = await new Promise<string>((resolve) => {
const timer = setTimeout(() => {
pendingPermissions.delete(requestId)
resolve('ask')
}, PERMISSION_TIMEOUT_MS)
pendingPermissions.set(requestId, { resolve, timer, payload: body })
})
return jsonResponse({ decision, requestId })
} catch (e) {
return errorResponse('Invalid JSON body', 400)
}
}
export async function handleClaudePermissionRespond(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 = pendingPermissions.get(body.requestId)
if (!pending) {
return errorResponse('Permission request not found or already expired', 404)
}
// Resolve the pending promise
clearTimeout(pending.timer)
pendingPermissions.delete(body.requestId)
pending.resolve(body.decision)
return jsonResponse({ success: true, requestId: body.requestId, decision: body.decision })
} catch (e) {
return errorResponse('Invalid JSON body', 400)
}
}
// List pending permissions (useful for UI to recover state)
export async function handleClaudePermissionList(req: Request): Promise<Response | null> {
if (req.method !== 'GET') return null
const pending = Array.from(pendingPermissions.entries()).map(([id, p]) => ({
requestId: id,
tool_name: p.payload.tool_name,
tool_input: p.payload.tool_input,
session_id: p.payload.session_id
}))
return jsonResponse({ pending })
}