import { randomUUID } from "node:crypto"; import { WebSocket, type RawData } from "ws"; export type PiRpcEvent = Record & { type?: string }; export interface PiRpcReplayGap { expected: number; received: number; earliestAvailable?: number; latestAvailable?: number; } export const PI_RPC_REPLAY_GAP_MESSAGE = "Agent output was truncated because part of the sandbox event replay was no longer available. This generation was marked as failed to avoid saving an incomplete transcript."; export class PiRpcReplayGapError extends Error { readonly name = "PiRpcReplayGapError"; constructor(readonly gap: PiRpcReplayGap) { super(PI_RPC_REPLAY_GAP_MESSAGE); } } interface PendingCommand { resolve: (response: PiRpcResponse) => void; reject: (error: Error) => void; timer: ReturnType; } export interface PiRpcResponse extends Record { id?: string; type: "response"; command?: string; success: boolean; data?: unknown; error?: string; } /** A correlated, durable command rejection (as opposed to an ambiguous transport failure). */ export class PiRpcCommandRejectedError extends Error { readonly name = "PiRpcCommandRejectedError"; constructor(readonly response: PiRpcResponse) { super(response.error ?? `Pi RPC command '${response.command ?? "unknown"}' failed`); } } export interface PiRpcClientOptions { headers: Record; lastSequence?: number; connectTimeoutMs?: number; commandTimeoutMs?: number; minReconnectDelayMs?: number; maxReconnectDelayMs?: number; onGap?: (gap: PiRpcReplayGap) => void; onStatus?: (status: "connecting" | "connected" | "reconnecting" | "closed") => void; } type EventListener = (event: PiRpcEvent, sequence: number) => void; type ReplayGapListener = (error: PiRpcReplayGapError) => void; /** * Reconnecting RPC client for the sandbox bridge. Commands use pi's native * correlation ids while bridge sequence numbers make reconnect replay exact. */ export class PiRpcClient { private socket: WebSocket | undefined; private readonly listeners = new Set(); private readonly replayGapListeners = new Set(); private readonly pending = new Map(); private reconnectTimer: ReturnType | undefined; private socketReadyTimer: { socket: WebSocket; timer: ReturnType } | undefined; private connectionDeadlineTimer: ReturnType | undefined; private connectionPromise: Promise | undefined; private connectionResolve: (() => void) | undefined; private connectionReject: ((error: Error) => void) | undefined; private readySocket: WebSocket | undefined; private replayTarget: { socket: WebSocket; sequence: number } | undefined; private replayGapFailure: PiRpcReplayGapError | undefined; private lastConnectionError: Error | undefined; private hasConnected = false; private disposed = false; private reconnectAttempts = 0; private lastSequenceValue: number; constructor( private readonly bridgeUrl: string, private readonly options: PiRpcClientOptions ) { this.lastSequenceValue = options.lastSequence ?? 0; } get lastSequence(): number { return this.lastSequenceValue; } get connected(): boolean { return ( this.socket !== undefined && this.readySocket === this.socket && this.socket.readyState === WebSocket.OPEN ); } get replayGapError(): PiRpcReplayGapError | undefined { return this.replayGapFailure; } async connect(): Promise { if (this.replayGapFailure) throw this.replayGapFailure; if (this.disposed) throw new Error("Pi RPC client is closed"); if (this.connected) return; if (this.connectionPromise) return this.connectionPromise; this.connectionPromise = new Promise((resolve, reject) => { this.connectionResolve = resolve; this.connectionReject = reject; }); const connectionPromise = this.connectionPromise; const timeoutMs = this.options.connectTimeoutMs ?? 15_000; this.connectionDeadlineTimer = setTimeout(() => { const detail = this.lastConnectionError?.message; const stopInitialRetries = !this.hasConnected; this.rejectConnection( new Error( `Timed out connecting to pi RPC bridge after ${timeoutMs}ms${detail ? `: ${detail}` : ""}` ) ); if (stopInitialRetries && this.reconnectTimer) clearTimeout(this.reconnectTimer); if (stopInitialRetries) this.reconnectTimer = undefined; if (this.socket && this.readySocket !== this.socket) this.socket.terminate(); }, timeoutMs); if (!this.socket && !this.reconnectTimer) this.openSocket(false); return connectionPromise; } subscribe(listener: EventListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } subscribeReplayGap(listener: ReplayGapListener): () => void { if (this.replayGapFailure) { listener(this.replayGapFailure); return () => undefined; } this.replayGapListeners.add(listener); return () => this.replayGapListeners.delete(listener); } async command( command: Record & { type: string }, options: { timeoutMs?: number } = {} ): Promise { await this.connect(); if (this.replayGapFailure) throw this.replayGapFailure; const socket = this.socket; if (!socket || socket.readyState !== WebSocket.OPEN) { throw new Error("Pi RPC bridge is not connected"); } const id = typeof command.id === "string" ? command.id : randomUUID(); const timeoutMs = options.timeoutMs ?? this.options.commandTimeoutMs ?? 30_000; const response = new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Pi RPC command '${command.type}' timed out after ${timeoutMs}ms`)); }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); }); try { socket.send(JSON.stringify({ ...command, id })); } catch (error) { const pending = this.pending.get(id); if (pending) { clearTimeout(pending.timer); this.pending.delete(id); } throw error; } const result = (await response) as PiRpcResponse & { data?: TData }; if (!result.success) { throw new PiRpcCommandRejectedError(result); } return result; } /** * Send a protocol message that intentionally has no correlated response. * Legacy callers may use this for request-id-only `extension_ui_response`; * durable Agent controls use the patched correlated form through `command`. */ async send(message: Record & { type: string }): Promise { await this.connect(); if (this.replayGapFailure) throw this.replayGapFailure; const socket = this.socket; if (!socket || socket.readyState !== WebSocket.OPEN) { throw new Error("Pi RPC bridge is not connected"); } socket.send(JSON.stringify(message)); } waitFor( predicate: (event: PiRpcEvent) => boolean, options: { signal?: AbortSignal; timeoutMs?: number } = {} ): Promise<{ event: PiRpcEvent; sequence: number }> { if (this.replayGapFailure) return Promise.reject(this.replayGapFailure); return new Promise((resolve, reject) => { let timer: ReturnType | undefined; const cleanup = () => { this.listeners.delete(listener); this.replayGapListeners.delete(onReplayGap); if (timer) clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); }; const listener: EventListener = (event, sequence) => { if (!predicate(event)) return; cleanup(); resolve({ event, sequence }); }; const onAbort = () => { cleanup(); reject(new DOMException("The operation was aborted", "AbortError")); }; const onReplayGap: ReplayGapListener = (error) => { cleanup(); reject(error); }; this.listeners.add(listener); this.replayGapListeners.add(onReplayGap); if (this.replayGapFailure) return onReplayGap(this.replayGapFailure); if (options.signal?.aborted) return onAbort(); options.signal?.addEventListener("abort", onAbort, { once: true }); if (options.timeoutMs) { timer = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for pi event after ${options.timeoutMs}ms`)); }, options.timeoutMs); } }); } close(): void { if (this.disposed) return; this.disposed = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.clearSocketReadyTimer(); if (this.connectionDeadlineTimer) clearTimeout(this.connectionDeadlineTimer); this.rejectConnection(new Error("Pi RPC client closed")); for (const [id, pending] of this.pending) { clearTimeout(pending.timer); pending.reject(new Error(`Pi RPC client closed before response '${id}'`)); } this.pending.clear(); if (this.socket?.readyState === WebSocket.OPEN) this.socket.close(1000, "Client closed"); else this.socket?.terminate(); this.socket = undefined; this.readySocket = undefined; this.replayTarget = undefined; this.options.onStatus?.("closed"); } private openSocket(reconnecting: boolean): void { if (this.disposed || this.replayGapFailure || this.socket) return; if (!reconnecting) this.options.onStatus?.("connecting"); let socket: WebSocket; try { const url = new URL(this.bridgeUrl); url.searchParams.set("fromSeq", String(this.lastSequenceValue)); socket = new WebSocket(url, { headers: this.options.headers, handshakeTimeout: this.options.connectTimeoutMs ?? 15_000, }); } catch (error) { this.lastConnectionError = asError(error, "Unable to open pi RPC bridge"); this.scheduleReconnect(); return; } this.socket = socket; this.socketReadyTimer = { socket, timer: setTimeout(() => { if (this.socket !== socket || this.readySocket === socket) return; this.lastConnectionError = new Error("Pi RPC bridge readiness timed out"); socket.terminate(); }, this.options.connectTimeoutMs ?? 15_000), }; socket.on("message", (raw: RawData) => this.handleMessage(socket, raw)); socket.on("error", (error: Error) => { if (this.socket === socket) this.lastConnectionError = error; }); socket.on("close", () => { this.clearSocketReadyTimer(socket); if (this.socket !== socket) return; this.socket = undefined; this.readySocket = undefined; if (this.replayTarget?.socket === socket) this.replayTarget = undefined; if (this.disposed || this.replayGapFailure) return; this.scheduleReconnect(); }); } private handleMessage(socket: WebSocket, raw: RawData): void { if (this.socket !== socket) return; let envelope: Record; try { envelope = JSON.parse(raw.toString()) as Record; } catch { return; } if (envelope.type === "bridge_ready") { const earliest = asInteger(envelope.fromSeq); const latest = asInteger(envelope.toSeq); if (earliest === undefined || latest === undefined) { this.lastConnectionError = new Error("Pi RPC bridge sent invalid replay metadata"); socket.terminate(); return; } if (latest < this.lastSequenceValue) { this.failReplayGap({ expected: this.lastSequenceValue + 1, received: latest, earliestAvailable: earliest, latestAvailable: latest, }); return; } if (earliest > this.lastSequenceValue + 1) { this.failReplayGap({ expected: this.lastSequenceValue + 1, received: earliest, earliestAvailable: earliest, latestAvailable: latest, }); return; } this.replayTarget = { socket, sequence: latest }; if (latest <= this.lastSequenceValue) this.markSocketReady(socket); return; } if (envelope.type !== "bridge_event") return; const sequence = asInteger(envelope.seq); const payload = envelope.payload; if (sequence === undefined || !isRecord(payload)) return; if (sequence <= this.lastSequenceValue) return; if (sequence > this.lastSequenceValue + 1) { this.failReplayGap({ expected: this.lastSequenceValue + 1, received: sequence, latestAvailable: this.replayTarget?.sequence, }); return; } this.lastSequenceValue = sequence; if (payload.type === "response" && typeof payload.id === "string") { const pending = this.pending.get(payload.id); if (pending) { clearTimeout(pending.timer); this.pending.delete(payload.id); pending.resolve(payload as PiRpcResponse); } } for (const listener of this.listeners) listener(payload, sequence); if ( this.replayTarget?.socket === socket && this.lastSequenceValue >= this.replayTarget.sequence ) { this.markSocketReady(socket); } } private scheduleReconnect(): void { if ( this.disposed || this.replayGapFailure || this.reconnectTimer || (!this.hasConnected && !this.connectionPromise) ) return; const minimum = this.options.minReconnectDelayMs ?? 250; const maximum = this.options.maxReconnectDelayMs ?? 5_000; const delay = Math.min(maximum, minimum * 2 ** this.reconnectAttempts++); this.options.onStatus?.("reconnecting"); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; this.openSocket(true); }, delay); } private rejectConnection(error: Error): void { const reject = this.connectionReject; this.clearConnectionPromise(); reject?.(error); } private resolveConnection(): void { const resolve = this.connectionResolve; this.clearConnectionPromise(); resolve?.(); } private markSocketReady(socket: WebSocket): void { if (this.socket !== socket || this.replayGapFailure) return; this.clearSocketReadyTimer(socket); if (this.replayTarget?.socket === socket) this.replayTarget = undefined; this.readySocket = socket; this.hasConnected = true; this.lastConnectionError = undefined; this.reconnectAttempts = 0; this.options.onStatus?.("connected"); this.resolveConnection(); } private failReplayGap(gap: PiRpcReplayGap): void { if (this.replayGapFailure) return; const error = new PiRpcReplayGapError(gap); this.replayGapFailure = error; this.lastConnectionError = error; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; this.clearSocketReadyTimer(); this.replayTarget = undefined; try { this.options.onGap?.(gap); } catch { // Observability callbacks must not weaken the fail-closed boundary. } for (const listener of this.replayGapListeners) { try { listener(error); } catch { // One consumer must not prevent the others from observing the gap. } } this.replayGapListeners.clear(); this.rejectConnection(error); for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); const socket = this.socket; this.socket = undefined; this.readySocket = undefined; if (socket?.readyState === WebSocket.OPEN) { try { socket.send(JSON.stringify({ type: "abort" })); } catch { // The replay gap remains terminal even if the best-effort abort cannot be sent. } try { socket.close(1011, "Replay gap"); } catch { socket.terminate(); } } else { socket?.terminate(); } this.options.onStatus?.("closed"); } private clearSocketReadyTimer(socket?: WebSocket): void { const active = this.socketReadyTimer; if (!active || (socket && active.socket !== socket)) return; clearTimeout(active.timer); this.socketReadyTimer = undefined; } private clearConnectionPromise(): void { if (this.connectionDeadlineTimer) clearTimeout(this.connectionDeadlineTimer); this.connectionDeadlineTimer = undefined; this.connectionPromise = undefined; this.connectionResolve = undefined; this.connectionReject = undefined; } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function asError(value: unknown, fallback: string): Error { return value instanceof Error ? value : new Error(fallback); } function asInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; }