Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch rewrite with streaming/persist, permission prompts, and agent commands. Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline, WS user-delta replace, and inference orphan tool_call stripping. Archive openspec v2-2; update CHANGELOG and CURRENT. Co-authored-by: Cursor <cursoragent@cursor.com>
114 lines
3.3 KiB
TypeScript
114 lines
3.3 KiB
TypeScript
/**
|
|
* Blocks ACP dispatch on permission prompts until the user responds via API.
|
|
*/
|
|
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
|
import { isUnattendedMode } from './provider-manifest.js';
|
|
|
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
|
|
interface PendingPermission {
|
|
request: RequestPermissionRequest;
|
|
sessionId: string;
|
|
resolve: (response: RequestPermissionResponse) => void;
|
|
reject: (err: Error) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
const pendingByTask = new Map<string, PendingPermission>();
|
|
|
|
export interface PermissionPrompt {
|
|
taskId: string;
|
|
toolTitle?: string;
|
|
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 toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
|
|
return {
|
|
taskId,
|
|
toolTitle: params.toolCall?.title ?? undefined,
|
|
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, { request: params, sessionId, resolve, reject, timer });
|
|
|
|
const prompt = toPrompt(taskId, params);
|
|
void hooks.onPrompt?.({ ...prompt, sessionId });
|
|
});
|
|
}
|
|
|
|
export function respondToPermission(taskId: string, optionId: string | null): boolean {
|
|
const pending = pendingByTask.get(taskId);
|
|
if (!pending) return false;
|
|
|
|
clearTimeout(pending.timer);
|
|
pendingByTask.delete(taskId);
|
|
|
|
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;
|
|
return toPrompt(taskId, pending.request);
|
|
}
|
|
|
|
export function cancelPendingPermission(taskId: string): void {
|
|
const pending = pendingByTask.get(taskId);
|
|
if (!pending) return;
|
|
clearTimeout(pending.timer);
|
|
pendingByTask.delete(taskId);
|
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
|
void hooks.onResolved?.(taskId, pending.sessionId);
|
|
}
|