/** * Blocks ACP dispatch on permission/elicitation prompts until the user responds via API. */ import type { RequestPermissionRequest, RequestPermissionResponse, CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk'; import { isUnattendedMode } from './provider-manifest.js'; const DEFAULT_TIMEOUT_MS = 120_000; interface PendingPermission { type: 'permission'; request: RequestPermissionRequest; sessionId: string; resolve: (response: RequestPermissionResponse) => void; reject: (err: Error) => void; timer: ReturnType; } interface PendingElicitation { type: 'elicitation'; request: CreateElicitationRequest; sessionId: string; resolve: (response: CreateElicitationResponse) => void; reject: (err: Error) => void; timer: ReturnType; } type PendingEntry = PendingPermission | PendingElicitation; const pendingByTask = new Map(); export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation'; export interface PermissionPrompt { taskId: string; kind: PermissionKind; toolTitle?: string; description?: string; input?: Record; options: Array<{ optionId: string; label: string }>; } export interface PermissionHooks { onPrompt?: (prompt: PermissionPrompt & { sessionId: string }) => void | Promise; onResolved?: (taskId: string, sessionId: string) => void | Promise; } let hooks: PermissionHooks = {}; export function setPermissionHooks(next: PermissionHooks): void { hooks = next; } function resolveKind(params: RequestPermissionRequest): PermissionKind { const input = params.toolCall?.rawInput; if (input && typeof input === 'object' && !Array.isArray(input) && 'questions' in input && Array.isArray((input as Record).questions)) { return 'question'; } return 'tool'; } function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt { const kind = resolveKind(params); const rawInput = params.toolCall?.rawInput; const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput as Record : undefined; return { taskId, kind, toolTitle: params.toolCall?.title ?? undefined, ...(input ? { input } : {}), options: params.options.map((o) => ({ optionId: o.optionId, label: o.name, })), }; } export function waitForPermissionResponse( taskId: string, sessionId: string, provider: string, modeId: string | undefined, params: RequestPermissionRequest, timeoutMs = DEFAULT_TIMEOUT_MS, ): Promise { if (isUnattendedMode(provider, modeId)) { const first = params.options[0]; if (first) { return Promise.resolve({ outcome: { outcome: 'selected', optionId: first.optionId } }); } return Promise.resolve({ outcome: { outcome: 'cancelled' } }); } return new Promise((resolve, reject) => { const existing = pendingByTask.get(taskId); if (existing) { clearTimeout(existing.timer); existing.reject(new Error('superseded by newer permission request')); } const timer = setTimeout(() => { pendingByTask.delete(taskId); void hooks.onResolved?.(taskId, sessionId); resolve({ outcome: { outcome: 'cancelled' } }); }, timeoutMs); pendingByTask.set(taskId, { type: 'permission', request: params, sessionId, resolve, reject, timer }); const prompt = toPrompt(taskId, params); void hooks.onPrompt?.({ ...prompt, sessionId }); }); } export function respondToPermission(taskId: string, optionId: string | null, updatedInput?: Record): boolean { const pending = pendingByTask.get(taskId); if (!pending) return false; clearTimeout(pending.timer); pendingByTask.delete(taskId); if (pending.type === 'elicitation') { if (updatedInput) { const content = updatedInput as { [key: string]: string | number | boolean | string[] }; pending.resolve({ action: 'accept', content }); } else { pending.resolve({ action: 'decline' }); } } else { if (optionId) { pending.resolve({ outcome: { outcome: 'selected', optionId } }); } else { pending.resolve({ outcome: { outcome: 'cancelled' } }); } } void hooks.onResolved?.(taskId, pending.sessionId); return true; } export function getPendingPermission(taskId: string): PermissionPrompt | null { const pending = pendingByTask.get(taskId); if (!pending) return null; if (pending.type === 'elicitation') { return elicitationToPrompt(taskId, pending.request); } return toPrompt(taskId, pending.request); } function elicitationToPrompt(taskId: string, params: CreateElicitationRequest): PermissionPrompt { const input: Record = { message: params.message }; if ('requestedSchema' in params) { input.requestedSchema = params.requestedSchema; } return { taskId, kind: 'elicitation', toolTitle: params.message, input, options: [], }; } export function waitForElicitationResponse( taskId: string, sessionId: string, provider: string, modeId: string | undefined, params: CreateElicitationRequest, timeoutMs = DEFAULT_TIMEOUT_MS, ): Promise { if (isUnattendedMode(provider, modeId)) { return Promise.resolve({ action: 'decline' }); } return new Promise((resolve, reject) => { const existing = pendingByTask.get(taskId); if (existing) { clearTimeout(existing.timer); existing.reject(new Error('superseded by newer elicitation request')); } const timer = setTimeout(() => { pendingByTask.delete(taskId); void hooks.onResolved?.(taskId, sessionId); resolve({ action: 'cancel' }); }, timeoutMs); pendingByTask.set(taskId, { type: 'elicitation', request: params, sessionId, resolve, reject, timer }); const prompt = elicitationToPrompt(taskId, params); void hooks.onPrompt?.({ ...prompt, sessionId }); }); } export function cancelPendingPermission(taskId: string): void { const pending = pendingByTask.get(taskId); if (!pending) return; clearTimeout(pending.timer); pendingByTask.delete(taskId); if (pending.type === 'elicitation') { pending.resolve({ action: 'cancel' }); } else { pending.resolve({ outcome: { outcome: 'cancelled' } }); } void hooks.onResolved?.(taskId, pending.sessionId); }