- Add SessionStateManager (server/services/session-state.ts) as source of truth for agent status, tools, approvals, and notifications - Integrate into terminal server with state patches broadcast via WS - Add /add-approval and /resolve-approval endpoints so approval lifecycle is tracked centrally and broadcast to all clients - Add permissionMode field to AgentSessionState - Frontend store (session-state.ts) + WS service (session-state-ws.ts) consume snapshots and patches from terminal server (4103) - Rewrite useGlobalApproval to derive pending approvals from centralized state — resolving on one client now clears all others - Migrate useTranscriptDebug: processing, hookMeta, serverRegistry now derived from session state store; remove 5s registry polling - hooks-approval.ts notifies terminal server on add/resolve
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
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<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)}`
|
|
}
|
|
|
|
// Notify terminal server (4103) about approval lifecycle → broadcasts state patches to all clients
|
|
function notifyAddApproval(agent: string, approval: Record<string, unknown>) {
|
|
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<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()
|
|
}))
|
|
|
|
// Track in centralized session state → broadcasts patch to all clients
|
|
notifyAddApproval(body.agent_name || 'main', {
|
|
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<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()
|
|
}))
|
|
|
|
// Track in centralized session state → broadcasts patch to all clients
|
|
notifyAddApproval('main', {
|
|
requestId,
|
|
type: 'plan',
|
|
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: 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<string, unknown>
|
|
|
|
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<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)
|
|
|
|
// 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<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 })
|
|
}
|