import type { z } from "zod"; import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema, AgentResumedStatusPayloadSchema, RestartRequestedStatusPayloadSchema, SessionInboundMessageSchema, WSOutboundMessageSchema, } from "../shared/messages.js"; import type { AgentStreamEventPayload, AgentSnapshotPayload, AgentPermissionResolvedMessage, CreateAgentRequestMessage, FileDownloadTokenResponse, FileExplorerResponse, GitDiffResponse, GitSetupOptions, HighlightedDiffResponse, CheckoutStatusResponse, CheckoutDiffResponse, CheckoutCommitResponse, CheckoutMergeResponse, CheckoutMergeFromBaseResponse, CheckoutPushResponse, CheckoutPrCreateResponse, CheckoutPrStatusResponse, ValidateBranchResponse, PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, ProjectIconResponse, ListCommandsResponse, ExecuteCommandResponse, ListProviderModelsResponseMessage, SpeechModelsListResponse, SpeechModelsDownloadResponse, ListTerminalsResponse, CreateTerminalResponse, SubscribeTerminalResponse, TerminalOutput, KillTerminalResponse, TerminalInput, SessionInboundMessage, SessionOutboundMessage, } from "../shared/messages.js"; import type { AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, AgentProvider, AgentSessionConfig, } from "../server/agent/agent-sdk-types.js"; import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js"; import { createClientChannel, type EncryptedChannel, type Transport as RelayTransport, } from "@getpaseo/relay/e2ee"; import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js"; export interface Logger { debug(obj: object, msg?: string): void; info(obj: object, msg?: string): void; warn(obj: object, msg?: string): void; error(obj: object, msg?: string): void; } const consoleLogger: Logger = { debug: (obj, msg) => console.debug(msg, obj), info: (obj, msg) => console.info(msg, obj), warn: (obj, msg) => console.warn(msg, obj), error: (obj, msg) => console.error(msg, obj), }; export type DaemonTransport = { send: (data: string) => void; close: (code?: number, reason?: string) => void; onMessage: (handler: (data: unknown) => void) => () => void; onOpen: (handler: () => void) => () => void; onClose: (handler: (event?: unknown) => void) => () => void; onError: (handler: (event?: unknown) => void) => () => void; }; export type DaemonTransportFactory = (options: { url: string; headers?: Record; }) => DaemonTransport; export type WebSocketFactory = ( url: string, options?: { headers?: Record } ) => WebSocketLike; export type WebSocketLike = { readyState: number; send: (data: string) => void; close: (code?: number, reason?: string) => void; on?: (event: string, listener: (...args: any[]) => void) => void; off?: (event: string, listener: (...args: any[]) => void) => void; removeListener?: (event: string, listener: (...args: any[]) => void) => void; addEventListener?: (event: string, listener: (event: any) => void) => void; removeEventListener?: (event: string, listener: (event: any) => void) => void; onopen?: ((event: any) => void) | null; onclose?: ((event: any) => void) | null; onerror?: ((event: any) => void) | null; onmessage?: ((event: any) => void) | null; }; export type ConnectionState = | { status: "idle" } | { status: "connecting"; attempt: number } | { status: "connected" } | { status: "disconnected"; reason?: string }; export type DaemonEvent = | { type: "agent_update"; agentId: string; payload: | { kind: "upsert"; agent: AgentSnapshotPayload } | { kind: "remove"; agentId: string }; } | { type: "agent_stream"; agentId: string; event: AgentStreamEventPayload; timestamp: string; } | { type: "status"; payload: { status: string } & Record } | { type: "agent_deleted"; agentId: string } | { type: "agent_permission_request"; agentId: string; request: AgentPermissionRequest; } | { type: "agent_permission_resolved"; agentId: string; requestId: string; resolution: AgentPermissionResponse; } | { type: "error"; message: string }; export type DaemonEventHandler = (event: DaemonEvent) => void; export type DaemonClientConfig = { url: string; authHeader?: string; suppressSendErrors?: boolean; transportFactory?: DaemonTransportFactory; webSocketFactory?: WebSocketFactory; logger?: Logger; e2ee?: { enabled?: boolean; daemonPublicKeyB64?: string; }; reconnect?: { enabled?: boolean; baseDelayMs?: number; maxDelayMs?: number; }; }; export type SendMessageOptions = { messageId?: string; images?: Array<{ data: string; mimeType: string }>; }; type AgentConfigOverrides = Partial>; export type CreateAgentRequestOptions = { config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; initialPrompt?: string; images?: CreateAgentRequestMessage["images"]; git?: GitSetupOptions; worktreeName?: string; requestId?: string; labels?: Record; } & AgentConfigOverrides; type GitDiffPayload = GitDiffResponse["payload"]; type HighlightedDiffPayload = HighlightedDiffResponse["payload"]; type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; type CheckoutDiffPayload = CheckoutDiffResponse["payload"]; type CheckoutCommitPayload = CheckoutCommitResponse["payload"]; type CheckoutMergePayload = CheckoutMergeResponse["payload"]; type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type ValidateBranchPayload = ValidateBranchResponse["payload"]; type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"]; type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type SpeechModelsListPayload = SpeechModelsListResponse["payload"]; type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ExecuteCommandPayload = ExecuteCommandResponse["payload"]; type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type TerminalOutputPayload = TerminalOutput["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; type AgentRefreshedStatusPayload = z.infer< typeof AgentRefreshedStatusPayloadSchema >; type RestartRequestedStatusPayload = z.infer< typeof RestartRequestedStatusPayloadSchema >; export type WaitForFinishResult = { status: "idle" | "error" | "permission" | "timeout"; final: AgentSnapshotPayload | null; error: string | null; }; type Waiter = { predicate: (msg: SessionOutboundMessage) => T | null; resolve: (value: T) => void; reject: (error: Error) => void; timeoutHandle: ReturnType | null; }; type WaitHandle = { promise: Promise; cancel: (error: Error) => void; }; type RpcWaitResult = | { kind: "ok"; value: T } | { kind: "error"; error: DaemonRpcError }; class DaemonRpcError extends Error { readonly requestId: string; readonly requestType?: string; readonly code?: string; constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) { super(params.error); this.name = "DaemonRpcError"; this.requestId = params.requestId; this.requestType = params.requestType; this.code = params.code; } } const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500; const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000; /** Default timeout for waiting for connection before sending queued messages */ const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000; interface PendingSend { message: SessionInboundMessage; resolve: () => void; reject: (error: Error) => void; timeoutHandle: ReturnType; } export class DaemonClient { private transport: DaemonTransport | null = null; private transportCleanup: Array<() => void> = []; private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set(); private messageHandlers: Map< SessionOutboundMessage["type"], Set<(message: SessionOutboundMessage) => void> > = new Map(); private eventListeners: Set = new Set(); private waiters: Set> = new Set(); private checkoutStatusInFlight: Map> = new Map(); private connectionListeners: Set<(status: ConnectionState) => void> = new Set(); private reconnectTimeout: ReturnType | null = null; private pendingGenericTransportErrorTimeout: ReturnType | null = null; private reconnectAttempt = 0; private shouldReconnect = true; private connectPromise: Promise | null = null; private connectResolve: (() => void) | null = null; private connectReject: ((error: Error) => void) | null = null; private lastErrorValue: string | null = null; private connectionState: ConnectionState = { status: "idle" }; private agentUpdateSubscriptions = new Map< string, { labels?: Record; agentId?: string } | undefined >(); private logger: Logger; private pendingSendQueue: PendingSend[] = []; private relayClientId: string | null = null; constructor(private config: DaemonClientConfig) { this.logger = config.logger ?? consoleLogger; // Relay requires a clientId so the daemon can create an independent // socket + E2EE channel per connected client. Generate one per DaemonClient // instance (stable across reconnects in this tab/app session). if (isRelayClientWebSocketUrl(this.config.url)) { try { const parsed = new URL(this.config.url); if (!parsed.searchParams.get("clientId")) { this.relayClientId = `clt_${safeRandomId()}`; parsed.searchParams.set("clientId", this.relayClientId); this.config.url = parsed.toString(); } } catch { // ignore - invalid URL will be handled on connect } } } // ============================================================================ // Connection // ============================================================================ async connect(): Promise { if (this.connectionState.status === "connected") { return; } if (this.connectPromise) { return this.connectPromise; } this.shouldReconnect = true; this.connectPromise = new Promise((resolve, reject) => { this.connectResolve = resolve; this.connectReject = reject; this.attemptConnect(); }); return this.connectPromise; } private attemptConnect(): void { if (!this.shouldReconnect) { this.rejectConnect(new Error("Daemon client is closed")); return; } if (this.connectionState.status === "connecting") { return; } const headers: Record = {}; if (this.config.authHeader) { headers["Authorization"] = this.config.authHeader; } try { // If we reconnect while the previous socket is still open (common in browsers // where `onerror` may fire before `onclose`), we can end up with multiple // concurrent relay sockets. Cloudflare then closes the old one with // "Replaced by new connection", causing a disconnect loop. this.disposeTransport(); const baseTransportFactory = this.config.transportFactory ?? createWebSocketTransportFactory( this.config.webSocketFactory ?? defaultWebSocketFactory ); const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayClientWebSocketUrl(this.config.url); let transportFactory = baseTransportFactory; if (shouldUseRelayE2ee) { const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64; if (!daemonPublicKeyB64) { throw new Error("daemonPublicKeyB64 is required for relay E2EE"); } transportFactory = createRelayE2eeTransportFactory({ baseFactory: baseTransportFactory, daemonPublicKeyB64, logger: this.logger, }); } const transport = transportFactory({ url: this.config.url, headers }); this.transport = transport; this.updateConnectionState({ status: "connecting", attempt: this.reconnectAttempt, }); this.transportCleanup = [ transport.onOpen(() => { if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; } this.lastErrorValue = null; this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }); this.resubscribeAgentUpdates(); this.flushPendingSendQueue(); this.resolveConnect(); }), transport.onClose((event) => { if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; } const closeRecord = event as { code?: unknown; reason?: unknown } | null; const closeCode = closeRecord && typeof closeRecord === "object" && typeof closeRecord.code === "number" ? closeRecord.code : null; const closeReason = closeRecord && typeof closeRecord === "object" && typeof closeRecord.reason === "string" ? closeRecord.reason : null; const reason = describeTransportClose(event); if (reason) { this.lastErrorValue = reason; } this.updateConnectionState({ status: "disconnected", ...(reason ? { reason } : {}), }); // When connecting over the relay, only one client connection is allowed at a time. // If another device/tab takes over, we should not auto-reconnect and "fight" the new // connection (which causes flapping where both sides repeatedly replace each other). if ( isRelayClientWebSocketUrl(this.config.url) && closeCode === 1008 && (closeReason ?? reason) === "Replaced by new connection" ) { this.shouldReconnect = false; this.clearWaiters(new Error(reason ?? "Replaced by new connection")); this.rejectPendingSendQueue(new Error(reason ?? "Replaced by new connection")); this.rejectConnect(new Error(reason ?? "Replaced by new connection")); return; } this.scheduleReconnect(reason); }), transport.onError((event) => { const reason = describeTransportError(event); const isGeneric = reason === "Transport error"; // Browser WebSocket.onerror often provides no useful details and is followed // by a close event (often with code 1006). Prefer surfacing the close details // instead of immediately disconnecting with a generic "Transport error". if (isGeneric) { this.lastErrorValue ??= reason; if (!this.pendingGenericTransportErrorTimeout) { this.pendingGenericTransportErrorTimeout = setTimeout(() => { this.pendingGenericTransportErrorTimeout = null; if ( this.connectionState.status === "connected" || this.connectionState.status === "connecting" ) { this.lastErrorValue = reason; this.updateConnectionState({ status: "disconnected", reason }); this.scheduleReconnect(reason); } }, 250); } return; } if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; } this.lastErrorValue = reason; this.updateConnectionState({ status: "disconnected", reason }); this.scheduleReconnect(reason); }), transport.onMessage((data) => this.handleTransportMessage(data)), ]; } catch (error) { const message = error instanceof Error ? error.message : "Failed to connect"; this.lastErrorValue = message; this.scheduleReconnect(message); this.rejectConnect(error instanceof Error ? error : new Error(message)); } } private resolveConnect(): void { if (this.connectResolve) { this.connectResolve(); } this.connectPromise = null; this.connectResolve = null; this.connectReject = null; } private rejectConnect(error: Error): void { if (this.connectReject) { this.connectReject(error); } this.connectPromise = null; this.connectResolve = null; this.connectReject = null; } async close(): Promise { this.shouldReconnect = false; this.connectPromise = null; this.connectResolve = null; this.connectReject = null; if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } this.disposeTransport(1000, "Client closed"); this.clearWaiters(new Error("Daemon client closed")); this.updateConnectionState({ status: "disconnected", reason: "client_closed", }); } ensureConnected(): void { if (!this.shouldReconnect) { this.shouldReconnect = true; } if ( this.connectionState.status === "connected" || this.connectionState.status === "connecting" ) { return; } void this.connect(); } getConnectionState(): ConnectionState { return this.connectionState; } subscribeConnectionStatus( listener: (status: ConnectionState) => void ): () => void { this.connectionListeners.add(listener); listener(this.connectionState); return () => { this.connectionListeners.delete(listener); }; } get isConnected(): boolean { return this.connectionState.status === "connected"; } get isConnecting(): boolean { return this.connectionState.status === "connecting"; } get lastError(): string | null { return this.lastErrorValue; } // ============================================================================ // Message Subscription // ============================================================================ subscribe(handler: DaemonEventHandler): () => void { this.eventListeners.add(handler); return () => this.eventListeners.delete(handler); } subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void { this.rawMessageListeners.add(handler); return () => { this.rawMessageListeners.delete(handler); }; } on(type: SessionOutboundMessage["type"], handler: (message: SessionOutboundMessage) => void): () => void; on(handler: DaemonEventHandler): () => void; on( arg1: SessionOutboundMessage["type"] | DaemonEventHandler, arg2?: (message: SessionOutboundMessage) => void ): () => void { if (typeof arg1 === "function") { return this.subscribe(arg1); } const type = arg1 as SessionOutboundMessage["type"]; const handler = arg2 as (message: SessionOutboundMessage) => void; if (!this.messageHandlers.has(type)) { this.messageHandlers.set(type, new Set()); } this.messageHandlers.get(type)!.add(handler); return () => { const handlers = this.messageHandlers.get(type); if (!handlers) { return; } handlers.delete(handler); if (handlers.size === 0) { this.messageHandlers.delete(type); } }; } // ============================================================================ // Core Send Helpers // ============================================================================ /** * Send a session message. For fire-and-forget messages (heartbeats, etc.), * failures are suppressed if `suppressSendErrors` is configured. * For RPC methods that wait for responses, use `sendSessionMessageOrThrow` instead. */ private sendSessionMessage(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { if (this.config.suppressSendErrors) { return; } throw new Error(`Transport not connected (status: ${this.connectionState.status})`); } const payload = SessionInboundMessageSchema.parse(message); try { this.transport.send(JSON.stringify({ type: "session", message: payload })); } catch (error) { if (this.config.suppressSendErrors) { return; } throw error instanceof Error ? error : new Error(String(error)); } } /** * Send a session message for RPC methods that create waiters. * If the connection is still being established ("connecting"), the message * is queued and will be sent once connected (or rejected after timeout). * This prevents waiters from hanging forever when called during connection. */ private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise { const status = this.connectionState.status; // If connected, send immediately if (this.transport && status === "connected") { const payload = SessionInboundMessageSchema.parse(message); this.transport.send(JSON.stringify({ type: "session", message: payload })); return Promise.resolve(); } // If connecting, queue the message to be sent once connected if (status === "connecting") { return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { // Remove from queue const idx = this.pendingSendQueue.findIndex((p) => p.resolve === resolve); if (idx !== -1) { this.pendingSendQueue.splice(idx, 1); } reject(new Error(`Timed out waiting for connection to send message`)); }, DEFAULT_SEND_QUEUE_TIMEOUT_MS); this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle }); }); } // Not connected and not connecting - fail immediately return Promise.reject(new Error(`Transport not connected (status: ${status})`)); } /** * Flush pending send queue - called when connection is established. */ private flushPendingSendQueue(): void { const queue = this.pendingSendQueue; this.pendingSendQueue = []; for (const pending of queue) { clearTimeout(pending.timeoutHandle); try { if (this.transport && this.connectionState.status === "connected") { const payload = SessionInboundMessageSchema.parse(pending.message); this.transport.send(JSON.stringify({ type: "session", message: payload })); pending.resolve(); } else { pending.reject(new Error("Connection lost before message could be sent")); } } catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); } } } /** * Reject all pending sends - called when connection fails or is closed. */ private rejectPendingSendQueue(error: Error): void { const queue = this.pendingSendQueue; this.pendingSendQueue = []; for (const pending of queue) { clearTimeout(pending.timeoutHandle); pending.reject(error); } } private async sendRequest( params: { requestId: string; message: SessionInboundMessage; timeout: number; select: (msg: SessionOutboundMessage) => T | null; options?: { skipQueue?: boolean }; } ): Promise { const { promise, cancel } = this.waitForWithCancel>( (msg) => { if (msg.type === "rpc_error" && msg.payload.requestId === params.requestId) { return { kind: "error", error: new DaemonRpcError({ requestId: msg.payload.requestId, error: msg.payload.error, requestType: msg.payload.requestType, code: msg.payload.code, }), }; } const value = params.select(msg); if (value === null) { return null; } return { kind: "ok", value }; }, params.timeout, params.options ); try { await this.sendSessionMessageOrThrow(params.message); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); cancel(err); void promise.catch(() => undefined); throw err; } const result = await promise; if (result.kind === "error") { throw result.error; } return result.value; } private sendSessionMessageStrict(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { throw new Error("Transport not connected"); } const payload = SessionInboundMessageSchema.parse(message); try { this.transport.send(JSON.stringify({ type: "session", message: payload })); } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } } clearAgentAttention(agentId: string | string[]): void { this.sendSessionMessage({ type: "clear_agent_attention", agentId }); } sendHeartbeat(params: { deviceType: "web" | "mobile"; focusedAgentId: string | null; lastActivityAt: string; appVisible: boolean; }): void { this.sendSessionMessage({ type: "client_heartbeat", deviceType: params.deviceType, focusedAgentId: params.focusedAgentId, lastActivityAt: params.lastActivityAt, appVisible: params.appVisible, }); } registerPushToken(token: string): void { this.sendSessionMessage({ type: "register_push_token", token, }); } async ping(params?: { requestId?: string; timeoutMs?: number }): Promise<{ requestId: string; clientSentAt: number; serverReceivedAt: number; serverSentAt: number; rttMs: number; }> { const requestId = params?.requestId ?? `ping-${Date.now()}-${Math.random().toString(36).slice(2)}`; const clientSentAt = Date.now(); const payload = await this.sendRequest({ requestId, message: { type: "ping", requestId, clientSentAt }, timeout: params?.timeoutMs ?? 5000, select: (msg) => { if (msg.type !== "pong") return null; if (msg.payload.requestId !== requestId) return null; if (typeof msg.payload.serverReceivedAt !== "number") return null; if (typeof msg.payload.serverSentAt !== "number") return null; return msg.payload; }, }); return { requestId, clientSentAt, serverReceivedAt: payload.serverReceivedAt, serverSentAt: payload.serverSentAt, rttMs: Date.now() - clientSentAt, }; } // ============================================================================ // Agent RPCs (requestId-correlated) // ============================================================================ async fetchAgents(options?: { filter?: { labels?: Record }; requestId?: string; }): Promise { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agents_request", requestId: resolvedRequestId, ...(options?.filter ? { filter: options.filter } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_agents_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload.agents; }, }); } async fetchAgent(agentId: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_request", requestId: resolvedRequestId, agentId, }); const payload = await this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_agent_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); if (payload.error) { throw new Error(payload.error); } return payload.agent; } subscribeAgentUpdates(options?: { subscriptionId?: string; filter?: { labels?: Record; agentId?: string }; }): string { const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); this.agentUpdateSubscriptions.set(subscriptionId, options?.filter); const message = SessionInboundMessageSchema.parse({ type: "subscribe_agent_updates", subscriptionId, ...(options?.filter ? { filter: options.filter } : {}), }); this.sendSessionMessage(message); return subscriptionId; } unsubscribeAgentUpdates(subscriptionId: string): void { this.agentUpdateSubscriptions.delete(subscriptionId); const message = SessionInboundMessageSchema.parse({ type: "unsubscribe_agent_updates", subscriptionId, }); this.sendSessionMessage(message); } private resubscribeAgentUpdates(): void { if (this.agentUpdateSubscriptions.size === 0) { return; } for (const [subscriptionId, filter] of this.agentUpdateSubscriptions) { const message = SessionInboundMessageSchema.parse({ type: "subscribe_agent_updates", subscriptionId, ...(filter ? { filter } : {}), }); this.sendSessionMessage(message); } } // ============================================================================ // Agent Lifecycle // ============================================================================ async createAgent(options: CreateAgentRequestOptions): Promise { const requestId = this.createRequestId(options.requestId); const config = resolveAgentConfig(options); const message = SessionInboundMessageSchema.parse({ type: "create_agent_request", requestId, config, ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), ...(options.images && options.images.length > 0 ? { images: options.images } : {}), ...(options.git ? { git: options.git } : {}), ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}), ...(options.labels && Object.keys(options.labels).length > 0 ? { labels: options.labels } : {}), }); const status = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { return null; } const created = AgentCreatedStatusPayloadSchema.safeParse(msg.payload); if (created.success && created.data.requestId === requestId) { return created.data; } const failed = AgentCreateFailedStatusPayloadSchema.safeParse(msg.payload); if (failed.success && failed.data.requestId === requestId) { return failed.data; } return null; }, }); if (status.status === "agent_create_failed") { throw new Error(status.error); } return status.agent; } async deleteAgent(agentId: string): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "delete_agent_request", agentId, requestId, }); await this.sendRequest({ requestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "agent_deleted") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); } async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "archive_agent_request", agentId, requestId, }); const result = await this.sendRequest({ requestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "agent_archived") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); return { archivedAt: result.archivedAt }; } async resumeAgent( handle: AgentPersistenceHandle, overrides?: Partial ): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "resume_agent_request", requestId, handle, ...(overrides ? { overrides } : {}), }); const status = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { return null; } const resumed = AgentResumedStatusPayloadSchema.safeParse(msg.payload); if (resumed.success && resumed.data.requestId === requestId) { return resumed.data; } return null; }, }); return status.agent; } async refreshAgent( agentId: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "refresh_agent_request", agentId, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { return null; } const refreshed = AgentRefreshedStatusPayloadSchema.safeParse(msg.payload); if (refreshed.success && refreshed.data.requestId === resolvedRequestId) { return refreshed.data; } return null; }, }); } async initializeAgent( agentId: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "initialize_agent_request", agentId, requestId: resolvedRequestId, }); const payload = await this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "initialize_agent_request") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); if (payload.error) { throw new Error(payload.error); } const agent = await this.fetchAgent(agentId); if (!agent) { throw new Error(`Agent not found after initialize: ${agentId}`); } return agent; } // ============================================================================ // Agent Interaction // ============================================================================ async sendAgentMessage( agentId: string, text: string, options?: SendMessageOptions ): Promise { const requestId = this.createRequestId(); const messageId = options?.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ type: "send_agent_message_request", requestId, agentId, text, ...(messageId ? { messageId } : {}), ...(options?.images ? { images: options.images } : {}), }); const payload = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "send_agent_message_response") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); if (!payload.accepted) { throw new Error(payload.error ?? "sendAgentMessage rejected"); } } async sendMessage( agentId: string, text: string, options?: SendMessageOptions ): Promise { await this.sendAgentMessage(agentId, text, options); } async cancelAgent(agentId: string): Promise { this.sendSessionMessage({ type: "cancel_agent_request", agentId }); } async setAgentMode(agentId: string, modeId: string): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "set_agent_mode_request", agentId, modeId, requestId, }); const payload = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "set_agent_mode_response") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); if (!payload.accepted) { throw new Error(payload.error ?? "setAgentMode rejected"); } } async setAgentModel(agentId: string, modelId: string | null): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "set_agent_model_request", agentId, modelId, requestId, }); const payload = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "set_agent_model_response") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); if (!payload.accepted) { throw new Error(payload.error ?? "setAgentModel rejected"); } } async setAgentThinkingOption( agentId: string, thinkingOptionId: string | null ): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "set_agent_thinking_request", agentId, thinkingOptionId, requestId, }); const payload = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "set_agent_thinking_response") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); if (!payload.accepted) { throw new Error(payload.error ?? "setAgentThinkingOption rejected"); } } async restartServer( reason?: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "restart_server_request", ...(reason && reason.trim().length > 0 ? { reason } : {}), requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { return null; } const restarted = RestartRequestedStatusPayloadSchema.safeParse( msg.payload ); if (!restarted.success) { return null; } if (restarted.data.requestId !== resolvedRequestId) { return null; } return restarted.data; }, }); } // ============================================================================ // Audio / Voice // ============================================================================ async setVoiceMode(enabled: boolean, voiceAgentId?: string): Promise { this.sendSessionMessage({ type: "set_voice_mode", enabled, voiceAgentId }); } async sendVoiceAudioChunk( audio: string, format: string, isLast: boolean ): Promise { this.sendSessionMessage({ type: "voice_audio_chunk", audio, format, isLast }); } startDictationStream(dictationId: string, format: string): Promise { const ack = this.waitForWithCancel( (msg) => { if (msg.type !== "dictation_stream_ack") { return null; } if (msg.payload.dictationId !== dictationId) { return null; } if (msg.payload.ackSeq !== -1) { return null; } return msg.payload; }, 30000, { skipQueue: true } ); const ackPromise = ack.promise.then(() => undefined); const streamError = this.waitForWithCancel( (msg) => { if (msg.type !== "dictation_stream_error") { return null; } if (msg.payload.dictationId !== dictationId) { return null; } return msg.payload; }, 30000, { skipQueue: true } ); const errorPromise = streamError.promise.then((payload) => { throw new Error(payload.error); }); try { this.sendSessionMessageStrict({ type: "dictation_stream_start", dictationId, format }); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); ack.cancel(err); streamError.cancel(err); void ackPromise.catch(() => undefined); void errorPromise.catch(() => undefined); throw err; } return Promise.race([ackPromise, errorPromise]); } sendDictationStreamChunk(dictationId: string, seq: number, audio: string, format: string): void { this.sendSessionMessageStrict({ type: "dictation_stream_chunk", dictationId, seq, audio, format }); } finishDictationStream(dictationId: string, finalSeq: number): Promise<{ dictationId: string; text: string }> { const final = this.waitForWithCancel( (msg) => { if (msg.type !== "dictation_stream_final") { return null; } if (msg.payload.dictationId !== dictationId) { return null; } return msg.payload; }, 30000, { skipQueue: true } ); const streamError = this.waitForWithCancel( (msg) => { if (msg.type !== "dictation_stream_error") { return null; } if (msg.payload.dictationId !== dictationId) { return null; } return msg.payload; }, 30000, { skipQueue: true } ); const finalPromise = final.promise; const errorPromise = streamError.promise.then((payload) => { throw new Error(payload.error); }); try { this.sendSessionMessageStrict({ type: "dictation_stream_finish", dictationId, finalSeq }); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); final.cancel(err); streamError.cancel(err); void finalPromise.catch(() => undefined); void errorPromise.catch(() => undefined); throw err; } return Promise.race([finalPromise, errorPromise]); } cancelDictationStream(dictationId: string): void { this.sendSessionMessageStrict({ type: "dictation_stream_cancel", dictationId }); } async abortRequest(): Promise { this.sendSessionMessage({ type: "abort_request" }); } async audioPlayed(id: string): Promise { this.sendSessionMessage({ type: "audio_played", id }); } // ============================================================================ // Git Operations // ============================================================================ async getCheckoutStatus( cwd: string, options?: { requestId?: string } ): Promise { const requestId = options?.requestId; if (!requestId) { const existing = this.checkoutStatusInFlight.get(cwd); if (existing) { return existing; } } const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_status_request", cwd, requestId: resolvedRequestId, }); const responsePromise = this.sendRequest({ requestId: resolvedRequestId, message, timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "checkout_status_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); if (!requestId) { this.checkoutStatusInFlight.set(cwd, responsePromise); void responsePromise .finally(() => { if (this.checkoutStatusInFlight.get(cwd) === responsePromise) { this.checkoutStatusInFlight.delete(cwd); } }) .catch(() => undefined); } return responsePromise; } async getCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base"; baseRef?: string }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_diff_request", cwd, compare, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "checkout_diff_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async checkoutCommit( cwd: string, input: { message?: string; addAll?: boolean }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_commit_request", cwd, message: input.message, addAll: input.addAll, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "checkout_commit_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async checkoutMerge( cwd: string, input: { baseRef?: string; strategy?: "merge" | "squash"; requireCleanTarget?: boolean }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_merge_request", cwd, baseRef: input.baseRef, strategy: input.strategy, requireCleanTarget: input.requireCleanTarget, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "checkout_merge_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async checkoutMergeFromBase( cwd: string, input: { baseRef?: string; requireCleanTarget?: boolean }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_merge_from_base_request", cwd, baseRef: input.baseRef, requireCleanTarget: input.requireCleanTarget, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "checkout_merge_from_base_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async checkoutPush(cwd: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_push_request", cwd, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "checkout_push_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async checkoutPrCreate( cwd: string, input: { title?: string; body?: string; baseRef?: string }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_pr_create_request", cwd, title: input.title, body: input.body, baseRef: input.baseRef, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "checkout_pr_create_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async checkoutPrStatus( cwd: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_pr_status_request", cwd, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "checkout_pr_status_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async getPaseoWorktreeList( input: { cwd?: string; repoRoot?: string }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "paseo_worktree_list_request", cwd: input.cwd, repoRoot: input.repoRoot, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "paseo_worktree_list_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async archivePaseoWorktree( input: { worktreePath?: string; repoRoot?: string; branchName?: string }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "paseo_worktree_archive_request", worktreePath: input.worktreePath, repoRoot: input.repoRoot, branchName: input.branchName, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 20000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "paseo_worktree_archive_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async getGitDiff( agentId: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "git_diff_request", agentId, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "git_diff_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async getHighlightedDiff( agentId: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "highlighted_diff_request", agentId, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "highlighted_diff_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async validateBranch( options: { cwd: string; branchName: string }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "validate_branch_request", cwd: options.cwd, branchName: options.branchName, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "validate_branch_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } // ============================================================================ // File Explorer // ============================================================================ async exploreFileSystem( agentId: string, path: string, mode: "list" | "file" = "list", requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "file_explorer_request", agentId, path, mode, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "file_explorer_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async requestDownloadToken( agentId: string, path: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "file_download_token_request", agentId, path, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "file_download_token_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async requestProjectIcon( cwd: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "project_icon_request", cwd, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "project_icon_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } // ============================================================================ // Provider Models / Commands // ============================================================================ async listProviderModels( provider: AgentProvider, options?: { cwd?: string; requestId?: string } ): Promise { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "list_provider_models_request", provider, cwd: options?.cwd, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 30000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "list_provider_models_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async listSpeechModels(requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "speech_models_list_request", requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 30000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "speech_models_list_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async downloadSpeechModels( options?: { modelIds?: string[]; requestId?: string } ): Promise { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "speech_models_download_request", modelIds: options?.modelIds, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 30 * 60 * 1000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "speech_models_download_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async listCommands( agentId: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "list_commands_request", agentId, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 30000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "list_commands_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async executeCommand( agentId: string, commandName: string, args?: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "execute_command_request", agentId, commandName, args, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 30000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "execute_command_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } // ============================================================================ // Permissions // ============================================================================ async respondToPermission( agentId: string, requestId: string, response: AgentPermissionResponse ): Promise { this.sendSessionMessage({ type: "agent_permission_response", agentId, requestId, response, }); } async respondToPermissionAndWait( agentId: string, requestId: string, response: AgentPermissionResponse, timeout = 15000 ): Promise { const message = SessionInboundMessageSchema.parse({ type: "agent_permission_response", agentId, requestId, response, }); return this.sendRequest({ requestId, message, timeout, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "agent_permission_resolved") { return null; } if (msg.payload.requestId !== requestId) { return null; } if (msg.payload.agentId !== agentId) { return null; } return msg.payload; }, }); } // ============================================================================ // Waiting / Streaming Helpers // ============================================================================ async waitForAgentUpsert( agentId: string, predicate: (snapshot: AgentSnapshotPayload) => boolean, timeout = 60000 ): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeout) { const snapshot = await this.fetchAgent(agentId).catch(() => null); if (snapshot && predicate(snapshot)) { return snapshot; } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error(`Timed out waiting for agent ${agentId}`); } async waitForFinish( agentId: string, timeout = 60000 ): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "wait_for_finish_request", requestId, agentId, timeoutMs: timeout, }); const payload = await this.sendRequest({ requestId, message, timeout: timeout + 5000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "wait_for_finish_response") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); return { status: payload.status, final: payload.final, error: payload.error, }; } // ============================================================================ // Terminals // ============================================================================ async listTerminals( cwd: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "list_terminals_request", cwd, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "list_terminals_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async createTerminal( cwd: string, name?: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "create_terminal_request", cwd, name, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "create_terminal_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async subscribeTerminal( terminalId: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "subscribe_terminal_request", terminalId, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "subscribe_terminal_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } unsubscribeTerminal(terminalId: string): void { this.sendSessionMessage({ type: "unsubscribe_terminal_request", terminalId, }); } sendTerminalInput( terminalId: string, message: TerminalInput["message"] ): void { this.sendSessionMessage({ type: "terminal_input", terminalId, message, }); } async killTerminal( terminalId: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "kill_terminal_request", terminalId, requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "kill_terminal_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async waitForTerminalOutput( terminalId: string, timeout = 5000 ): Promise { return this.waitFor( (msg) => { if (msg.type !== "terminal_output") { return null; } if (msg.payload.terminalId !== terminalId) { return null; } return msg.payload; }, timeout, { skipQueue: true } ); } // ============================================================================ // Internals // ============================================================================ private createRequestId(requestId?: string): string { return requestId ?? crypto.randomUUID(); } private disposeTransport(code = 1001, reason = "Reconnecting"): void { this.cleanupTransport(); if (this.transport) { try { this.transport.close(code, reason); } catch { // no-op } this.transport = null; } } private cleanupTransport(): void { if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; } for (const cleanup of this.transportCleanup) { try { cleanup(); } catch { // no-op } } this.transportCleanup = []; } private handleTransportMessage(data: unknown): void { const rawData = data && typeof data === "object" && "data" in data ? (data as { data: unknown }).data : data; const payload = decodeMessageData(rawData); if (!payload) { return; } let parsedJson: unknown; try { parsedJson = JSON.parse(payload); } catch { return; } const parsed = WSOutboundMessageSchema.safeParse(parsedJson); if (!parsed.success) { const msgType = (parsedJson as { message?: { type?: string } })?.message?.type ?? "unknown"; this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed"); return; } if (parsed.data.type === "pong") { return; } this.handleSessionMessage(parsed.data.message); } private updateConnectionState(next: ConnectionState): void { this.connectionState = next; for (const listener of this.connectionListeners) { try { listener(next); } catch { // no-op } } } private scheduleReconnect(reason?: string): void { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } if (!this.shouldReconnect || this.config.reconnect?.enabled === false) { this.rejectConnect( new Error(reason ?? "Transport disconnected before connect") ); return; } const attempt = this.reconnectAttempt; const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS; const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS; const delay = Math.min(baseDelay * 2 ** attempt, maxDelay); this.reconnectAttempt = attempt + 1; if (typeof reason === "string" && reason.trim().length > 0) { this.lastErrorValue = reason.trim(); } // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. this.clearWaiters(new Error(reason ?? "Connection lost")); this.rejectPendingSendQueue(new Error(reason ?? "Connection lost")); this.updateConnectionState({ status: "disconnected", ...(reason ? { reason } : {}), }); this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null; if (!this.shouldReconnect) { return; } this.attemptConnect(); }, delay); } private handleSessionMessage(msg: SessionOutboundMessage): void { if (this.rawMessageListeners.size > 0) { for (const handler of this.rawMessageListeners) { try { handler(msg); } catch { // no-op } } } const handlers = this.messageHandlers.get(msg.type); if (handlers) { for (const handler of handlers) { try { handler(msg); } catch { // no-op } } } const event = this.toEvent(msg); if (event) { for (const handler of this.eventListeners) { handler(event); } } this.resolveWaiters(msg); } private resolveWaiters(msg: SessionOutboundMessage): void { for (const waiter of Array.from(this.waiters)) { const result = waiter.predicate(msg); if (result !== null) { this.waiters.delete(waiter); if (waiter.timeoutHandle) { clearTimeout(waiter.timeoutHandle); } waiter.resolve(result); } } } private clearWaiters(error: Error): void { for (const waiter of Array.from(this.waiters)) { if (waiter.timeoutHandle) { clearTimeout(waiter.timeoutHandle); } waiter.reject(error); } this.waiters.clear(); } private toEvent(msg: SessionOutboundMessage): DaemonEvent | null { switch (msg.type) { case "agent_update": return { type: "agent_update", agentId: msg.payload.kind === "upsert" ? msg.payload.agent.id : msg.payload.agentId, payload: msg.payload, }; case "agent_stream": return { type: "agent_stream", agentId: msg.payload.agentId, event: msg.payload.event, timestamp: msg.payload.timestamp, }; case "status": return { type: "status", payload: msg.payload }; case "agent_deleted": return { type: "agent_deleted", agentId: msg.payload.agentId }; case "agent_permission_request": return { type: "agent_permission_request", agentId: msg.payload.agentId, request: msg.payload.request, }; case "agent_permission_resolved": return { type: "agent_permission_resolved", agentId: msg.payload.agentId, requestId: msg.payload.requestId, resolution: msg.payload.resolution, }; default: return null; } } private async waitFor( predicate: (msg: SessionOutboundMessage) => T | null, timeout = 30000, _options?: { skipQueue?: boolean } ): Promise { return this.waitForWithCancel(predicate, timeout, _options).promise; } private waitForWithCancel( predicate: (msg: SessionOutboundMessage) => T | null, timeout = 30000, _options?: { skipQueue?: boolean } ): WaitHandle { // Capture stack trace at call site, not inside setTimeout const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`); let waiter: Waiter | null = null; let settled = false; let rejectFn: ((error: Error) => void) | null = null; const promise = new Promise((resolve, reject) => { const wrappedResolve = (value: T) => { if (settled) return; settled = true; resolve(value); }; const wrappedReject = (error: Error) => { if (settled) return; settled = true; reject(error); }; rejectFn = wrappedReject; const timeoutHandle = timeout > 0 ? setTimeout(() => { if (waiter) { this.waiters.delete(waiter); } wrappedReject(timeoutError); }, timeout) : null; waiter = { predicate, resolve: wrappedResolve, reject: wrappedReject, timeoutHandle, }; this.waiters.add(waiter); }); const cancel = (error: Error) => { if (settled) { return; } if (waiter) { this.waiters.delete(waiter); if (waiter.timeoutHandle) { clearTimeout(waiter.timeoutHandle); } } if (rejectFn) { rejectFn(error); return; } // Extremely unlikely: cancel called before the Promise executor ran. queueMicrotask(() => { if (!settled && rejectFn) { rejectFn(error); } }); }; return { promise, cancel }; } } // ============================================================================ // Helpers // ============================================================================ function defaultWebSocketFactory( url: string, _options?: { headers?: Record } ): WebSocketLike { const globalWs = (globalThis as { WebSocket?: any }).WebSocket; if (!globalWs) { throw new Error("WebSocket is not available in this runtime"); } return new globalWs(url); } function createWebSocketTransportFactory( factory: WebSocketFactory ): DaemonTransportFactory { return ({ url, headers }) => { const ws = factory(url, { headers }); return { send: (data) => { if (typeof ws.readyState === "number" && ws.readyState !== 1) { throw new Error(`WebSocket not open (readyState=${ws.readyState})`); } ws.send(data); }, close: (code?: number, reason?: string) => ws.close(code, reason), onOpen: (handler) => bindWsHandler(ws, "open", handler), onClose: (handler) => bindWsHandler(ws, "close", handler), onError: (handler) => bindWsHandler(ws, "error", handler), onMessage: (handler) => bindWsHandler(ws, "message", handler), }; }; } function createRelayE2eeTransportFactory(args: { baseFactory: DaemonTransportFactory; daemonPublicKeyB64: string; logger: Logger; }): DaemonTransportFactory { return ({ url, headers }) => { const base = args.baseFactory({ url, headers }); return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger); }; } function createEncryptedTransport( base: DaemonTransport, daemonPublicKeyB64: string, logger: Logger ): DaemonTransport { let channel: EncryptedChannel | null = null; let opened = false; let closed = false; const openHandlers = new Set<() => void>(); const closeHandlers = new Set<(event?: unknown) => void>(); const errorHandlers = new Set<(event?: unknown) => void>(); const messageHandlers = new Set<(data: unknown) => void>(); const emitOpen = () => { if (opened || closed) return; opened = true; for (const handler of openHandlers) { try { handler(); } catch { // no-op } } }; const emitClose = (event?: unknown) => { if (closed) return; closed = true; for (const handler of closeHandlers) { try { handler(event); } catch { // no-op } } }; const emitError = (event?: unknown) => { if (closed) return; for (const handler of errorHandlers) { try { handler(event); } catch { // no-op } } }; const emitMessage = (data: unknown) => { if (closed) return; for (const handler of messageHandlers) { try { handler(data); } catch { // no-op } } }; const relayTransport: RelayTransport = { send: (data) => { if (typeof data === "string") { base.send(data); return; } if (data instanceof ArrayBuffer) { if (typeof TextDecoder !== "undefined") { base.send(new TextDecoder().decode(data)); return; } if (typeof Buffer !== "undefined") { base.send(Buffer.from(data).toString("utf8")); return; } base.send(String(data)); return; } base.send(String(data)); }, close: (code?: number, reason?: string) => base.close(code, reason), onmessage: null, onclose: null, onerror: null, }; const startHandshake = async () => { try { channel = await createClientChannel(relayTransport, daemonPublicKeyB64, { onopen: emitOpen, onmessage: (data) => emitMessage(data), onclose: (code, reason) => emitClose({ code, reason }), onerror: (error) => emitError(error), }); } catch (error) { logger.warn({ err: error }, "relay_e2ee_handshake_failed"); emitError(error); base.close(1011, "E2EE handshake failed"); } }; base.onOpen(() => { void startHandshake(); }); base.onMessage((event) => { relayTransport.onmessage?.(extractRelayMessageData(event)); }); base.onClose((event) => { const record = event as { code?: number; reason?: string } | undefined; relayTransport.onclose?.(record?.code ?? 0, record?.reason ?? ""); emitClose(event); }); base.onError((event) => { relayTransport.onerror?.( event instanceof Error ? event : new Error(String(event)) ); emitError(event); }); return { send: (data) => { if (!channel) { throw new Error("Encrypted channel not ready"); } void channel.send(data).catch((error) => { emitError(error); }); }, close: (code?: number, reason?: string) => { if (channel) { channel.close(code, reason); } else { base.close(code, reason); } emitClose({ code, reason }); }, onMessage: (handler) => { messageHandlers.add(handler); return () => messageHandlers.delete(handler); }, onOpen: (handler) => { openHandlers.add(handler); if (opened) { try { handler(); } catch { // no-op } } return () => openHandlers.delete(handler); }, onClose: (handler) => { closeHandlers.add(handler); if (closed) { try { handler(); } catch { // no-op } } return () => closeHandlers.delete(handler); }, onError: (handler) => { errorHandlers.add(handler); return () => errorHandlers.delete(handler); }, }; } function extractRelayMessageData(event: unknown): string | ArrayBuffer { const raw = event && typeof event === "object" && "data" in event ? (event as { data: unknown }).data : event; if (typeof raw === "string") return raw; if (raw instanceof ArrayBuffer) return raw; if (ArrayBuffer.isView(raw)) { const view = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); const out = new Uint8Array(view.byteLength); out.set(view); return out.buffer; } return String(raw ?? ""); } function bindWsHandler( ws: WebSocketLike, event: "open" | "close" | "error" | "message", handler: (...args: any[]) => void ): () => void { if (typeof ws.addEventListener === "function") { ws.addEventListener(event, handler); return () => { if (typeof ws.removeEventListener === "function") { ws.removeEventListener(event, handler); } }; } if (typeof ws.on === "function") { ws.on(event, handler); return () => { if (typeof ws.off === "function") { ws.off(event, handler); return; } if (typeof ws.removeListener === "function") { ws.removeListener(event, handler); } }; } const prop = `on${event}` as "onopen" | "onclose" | "onerror" | "onmessage"; const previous = (ws as any)[prop]; (ws as any)[prop] = handler; return () => { if ((ws as any)[prop] === handler) { (ws as any)[prop] = previous ?? null; } }; } function describeTransportClose(event?: unknown): string { if (!event) { return "Transport closed"; } if (event instanceof Error) { return event.message; } if (typeof event === "string") { return event; } if (typeof event === "object") { const record = event as { reason?: unknown; message?: unknown; code?: unknown }; if (typeof record.reason === "string" && record.reason.trim().length > 0) { return record.reason.trim(); } if (typeof record.message === "string" && record.message.trim().length > 0) { return record.message.trim(); } if (typeof record.code === "number") { return `Transport closed (code ${record.code})`; } } return "Transport closed"; } function describeTransportError(event?: unknown): string { if (!event) { return "Transport error"; } if (event instanceof Error) { return event.message; } if (typeof event === "string") { return event; } if (typeof event === "object") { const record = event as { message?: unknown }; if (typeof record.message === "string" && record.message.trim().length > 0) { return record.message.trim(); } } return "Transport error"; } function safeRandomId(): string { try { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } } catch { // ignore } return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } function decodeMessageData(data: unknown): string | null { if (data === null || data === undefined) { return null; } if (typeof data === "string") { return data; } if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) { if (typeof Buffer !== "undefined") { return Buffer.from(data).toString("utf8"); } if (typeof TextDecoder !== "undefined") { return new TextDecoder().decode(data); } } if (ArrayBuffer.isView(data)) { const view = new Uint8Array( data.buffer, data.byteOffset, data.byteLength ); if (typeof Buffer !== "undefined") { return Buffer.from(view).toString("utf8"); } if (typeof TextDecoder !== "undefined") { return new TextDecoder().decode(view); } } if (typeof (data as { toString?: () => string }).toString === "function") { return (data as { toString: () => string }).toString(); } return null; } function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionConfig { const { config, provider, cwd, initialPrompt: _initialPrompt, images: _images, git: _git, worktreeName: _worktreeName, requestId: _requestId, labels: _labels, ...overrides } = options; const baseConfig: Partial = { ...(provider ? { provider } : {}), ...(cwd ? { cwd } : {}), ...overrides, }; const merged = config ? { ...baseConfig, ...config } : baseConfig; if (!merged.provider || !merged.cwd) { throw new Error("createAgent requires provider and cwd"); } if (!merged.modeId) { merged.modeId = getAgentProviderDefinition(merged.provider).defaultModeId ?? undefined; } return { ...merged, provider: merged.provider, cwd: merged.cwd, }; }