The permission_requested WS frame now carries kind ('tool'|'question'|'plan'|
'elicitation'), input (the tool's rawInput payload), and description fields.
PermissionCard detects question-type permissions (Claude Code's AskUserQuestion)
and renders an interactive radio/checkbox form instead of approve/deny buttons.
Submitting answers auto-selects the first allow option.
Also wires up ACP createElicitation (unstable/experimental) — JSON Schema-driven
forms for structured user input. The same PermissionCard renders elicitation
fields with type-appropriate inputs. Both flows use the existing permission-waiter
blocking pattern with 120s timeout.
The response path (POST /api/coder/tasks/:id/permission) now accepts optional
updated_input alongside option_id, forwarded to the ACP agent as the user's
answer payload. Elicitation responses map to accept/decline/cancel actions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
208 lines
6.3 KiB
TypeScript
208 lines
6.3 KiB
TypeScript
/**
|
|
* 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<typeof setTimeout>;
|
|
}
|
|
|
|
interface PendingElicitation {
|
|
type: 'elicitation';
|
|
request: CreateElicitationRequest;
|
|
sessionId: string;
|
|
resolve: (response: CreateElicitationResponse) => void;
|
|
reject: (err: Error) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
type PendingEntry = PendingPermission | PendingElicitation;
|
|
|
|
const pendingByTask = new Map<string, PendingEntry>();
|
|
|
|
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
|
|
|
export interface PermissionPrompt {
|
|
taskId: string;
|
|
kind: PermissionKind;
|
|
toolTitle?: string;
|
|
description?: string;
|
|
input?: Record<string, unknown>;
|
|
options: Array<{ optionId: string; label: string }>;
|
|
}
|
|
|
|
export interface PermissionHooks {
|
|
onPrompt?: (prompt: PermissionPrompt & { sessionId: string }) => void | Promise<void>;
|
|
onResolved?: (taskId: string, sessionId: string) => void | Promise<void>;
|
|
}
|
|
|
|
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<string, unknown>).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<string, unknown>
|
|
: 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<RequestPermissionResponse> {
|
|
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<string, unknown>): 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<string, unknown> = { 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<CreateElicitationResponse> {
|
|
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);
|
|
}
|