/** * Launch the adversary through the public pi-subagents delegation protocol. * * This deliberately uses the documented event contract instead of importing * pi-subagents internals, so the package works against a released * pi-subagents without patching it. * * Known limitation: the public protocol's `context` field accepts only * "fresh" or "fork" and carries no run ID, so a prior adversary session cannot * be resumed through it. Reviews after the first send the incremental * follow-up task, which asks the reviewer to re-read the current notes and * changed evidence, but the reviewer does not retain its own transcript across * reviews. `session` in the returned progress reports "fresh" accordingly. */ import { randomUUID } from "node:crypto"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { type AdversaryVerdict, parseAdversaryVerdict } from "./adversary.ts"; const REQUEST_EVENT = "prompt-template:subagent:request"; const STARTED_EVENT = "prompt-template:subagent:started"; const UPDATE_EVENT = "prompt-template:subagent:update"; const RESPONSE_EVENT = "prompt-template:subagent:response"; const CANCEL_EVENT = "prompt-template:subagent:cancel"; const PROTOCOL_VERSION = 1; export interface ReviewProgress { turns?: number; toolCount?: number; tokens?: number; currentTool?: string; } export interface DelegationResult { status: string; output?: string; runId?: string; turns?: number; toolCount?: number; tokens?: number; error?: string; } export interface DelegateOptions { agent: string; task: string; cwd: string; signal: AbortSignal; /** Reject if pi-subagents does not acknowledge the request within this window. */ ackTimeoutMs: number; onProgress?: (progress: ReviewProgress) => void; } function asRecord(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function numberOrUndefined(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } /** * Send one delegation request and resolve when the matching response arrives. * Rejects when the delegation protocol is not served (no pi-subagents loaded), * so the caller surfaces a real infrastructure failure instead of hanging. */ export function delegate(pi: ExtensionAPI, options: DelegateOptions): Promise { const requestId = randomUUID(); return new Promise((resolve, reject) => { const disposers: Array<() => void> = []; let settled = false; let ackTimer: ReturnType | undefined; const cleanup = () => { if (ackTimer !== undefined) clearTimeout(ackTimer); ackTimer = undefined; for (const dispose of disposers) { try { dispose(); } catch { // A listener may already be gone during session shutdown. } } disposers.length = 0; }; const finish = (outcome: () => void) => { if (settled) return; settled = true; cleanup(); outcome(); }; const subscribe = (event: string, handler: (payload: Record) => void) => { const dispose = pi.events.on(event, (data: unknown) => { const payload = asRecord(data); if (!payload || payload.requestId !== requestId) return; handler(payload); }); if (typeof dispose === "function") disposers.push(dispose); }; subscribe(STARTED_EVENT, () => { // The request was accepted; the review may now run for as long as it needs. if (ackTimer !== undefined) clearTimeout(ackTimer); ackTimer = undefined; }); subscribe(UPDATE_EVENT, (payload) => { const turns = numberOrUndefined(payload.turns); const toolCount = numberOrUndefined(payload.toolCount); const tokens = numberOrUndefined(payload.tokens); const currentTool = typeof payload.currentTool === "string" ? payload.currentTool : undefined; options.onProgress?.({ ...(turns !== undefined ? { turns } : {}), ...(toolCount !== undefined ? { toolCount } : {}), ...(tokens !== undefined ? { tokens } : {}), ...(currentTool ? { currentTool } : {}), }); }); subscribe(RESPONSE_EVENT, (payload) => { const output = typeof payload.output === "string" ? payload.output : undefined; const runId = typeof payload.runId === "string" ? payload.runId : undefined; const turns = numberOrUndefined(payload.turns); const toolCount = numberOrUndefined(payload.toolCount); const tokens = numberOrUndefined(payload.tokens); const error = typeof payload.error === "string" ? payload.error : undefined; finish(() => resolve({ status: typeof payload.status === "string" ? payload.status : "failed", ...(output !== undefined ? { output } : {}), ...(runId !== undefined ? { runId } : {}), ...(turns !== undefined ? { turns } : {}), ...(toolCount !== undefined ? { toolCount } : {}), ...(tokens !== undefined ? { tokens } : {}), ...(error !== undefined ? { error } : {}), }), ); }); const onAbort = () => { try { pi.events.emit(CANCEL_EVENT, { version: PROTOCOL_VERSION, requestId }); } catch { // Cancellation is best effort; the review result is discarded anyway. } finish(() => reject(new Error("Adversarial review was cancelled."))); }; if (options.signal.aborted) { onAbort(); return; } options.signal.addEventListener("abort", onAbort, { once: true }); disposers.push(() => options.signal.removeEventListener("abort", onAbort)); ackTimer = setTimeout(() => { finish(() => reject( new Error( "pi-subagents did not accept the review request. Install and load pi-subagents to enable independent verification.", ), ), ); }, options.ackTimeoutMs); try { pi.events.emit(REQUEST_EVENT, { version: PROTOCOL_VERSION, requestId, agent: options.agent, task: options.task, context: "fresh", cwd: options.cwd, artifacts: true, // Do not send outputSchema here. pi-subagents v1 stores structured // output separately but exposes only finalOutput on its public // response; the adversary tasks require a JSON text response instead. acceptance: { level: "none", reason: "The orchestration adversary verifies and returns feedback rather than implementing the task.", }, }); } catch (error) { finish(() => reject(error instanceof Error ? error : new Error(String(error)))); } }); } export interface AdversaryReviewRequest { agent: string; task: string; cwd: string; signal: AbortSignal; ackTimeoutMs: number; onProgress?: (progress: ReviewProgress) => void; } export interface AdversaryReviewResult { verdict: AdversaryVerdict; runId?: string; } /** Run one adversarial review and parse its structured verdict. */ export async function requestAdversaryReview( pi: ExtensionAPI, request: AdversaryReviewRequest, ): Promise { const result = await delegate(pi, { agent: request.agent, task: request.task, cwd: request.cwd, signal: request.signal, ackTimeoutMs: request.ackTimeoutMs, ...(request.onProgress ? { onProgress: request.onProgress } : {}), }); if (result.status !== "completed") { throw new Error(result.error ?? `Adversarial review ended with status "${result.status}".`); } const verdict = parseAdversaryVerdict(result.output); if (!verdict) { throw new Error("Adversarial review returned no usable verdict."); } return { verdict, ...(result.runId ? { runId: result.runId } : {}) }; }