import type { z } from "zod"; import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema, AgentResumedStatusPayloadSchema, parseServerInfoStatusPayload, RestartRequestedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema, SessionInboundMessageSchema, type ServerInfoStatusPayload, WSOutboundMessageSchema, } from "../shared/messages.js"; import type { AgentStreamEventPayload, AgentSnapshotPayload, ProjectPlacementPayload, AgentPermissionResolvedMessage, CreateAgentRequestMessage, FileDownloadTokenResponse, FileExplorerResponse, FetchAgentTimelineResponseMessage, GitSetupOptions, CheckoutStatusResponse, CheckoutCommitResponse, CheckoutMergeResponse, CheckoutMergeFromBaseResponse, CheckoutPushResponse, CheckoutPrCreateResponse, CheckoutPrStatusResponse, ValidateBranchResponse, BranchSuggestionsResponse, DirectorySuggestionsResponse, PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, ProjectIconResponse, OpenProjectResponseMessage, ArchiveWorkspaceResponseMessage, ListCommandsResponse, ListProviderFeaturesResponseMessage, ListProviderModelsResponseMessage, ListProviderModesResponseMessage, ListAvailableProvidersResponse, ListTerminalsResponse, CreateTerminalResponse, SubscribeTerminalResponse, TerminalState, CloseItemsResponse, KillTerminalResponse, CaptureTerminalResponse, 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 { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js"; import { asUint8Array, decodeTerminalSnapshotPayload, decodeTerminalStreamFrame, encodeTerminalResizePayload, encodeTerminalStreamFrame, TerminalStreamOpcode, type TerminalStreamFrame, } from "../shared/terminal-stream-protocol.js"; import { createRelayE2eeTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, describeTransportClose, describeTransportError, encodeUtf8String, type DaemonTransport, type DaemonTransportFactory, type WebSocketFactory, } from "./daemon-client-transport.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: () => {}, info: (obj, msg) => console.info(msg, obj), warn: (obj, msg) => console.warn(msg, obj), error: (obj, msg) => console.error(msg, obj), }; export type { DaemonTransport, DaemonTransportFactory, WebSocketFactory, WebSocketLike, } from "./daemon-client-transport.js"; export type TerminalStreamEvent = | { terminalId: string; type: "output"; data: Uint8Array } | { terminalId: string; type: "snapshot"; state: TerminalState }; export type ConnectionState = | { status: "idle" } | { status: "connecting"; attempt: number } | { status: "connected" } | { status: "disconnected"; reason?: string } | { status: "disposed" }; export type DaemonEvent = | { type: "agent_update"; agentId: string; payload: Extract["payload"]; } | { type: "workspace_update"; workspaceId: string; payload: Extract["payload"]; } | { type: "agent_stream"; agentId: string; event: AgentStreamEventPayload; timestamp: string; seq?: number; epoch?: 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; clientId: string; clientType?: "mobile" | "browser" | "cli" | "mcp"; appVersion?: string; runtimeGeneration?: number | null; authHeader?: string; suppressSendErrors?: boolean; transportFactory?: DaemonTransportFactory; webSocketFactory?: WebSocketFactory; logger?: Logger; connectTimeoutMs?: number; 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; clientMessageId?: string; outputSchema?: Record; images?: CreateAgentRequestMessage["images"]; git?: GitSetupOptions; worktreeName?: string; requestId?: string; labels?: Record; } & AgentConfigOverrides; type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; type SubscribeCheckoutDiffPayload = Extract< SessionOutboundMessage, { type: "subscribe_checkout_diff_response" } >["payload"]; type CheckoutDiffPayload = Omit; 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 BranchSuggestionsPayload = BranchSuggestionsResponse["payload"]; type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"]; type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"]; type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"]; type CreatePaseoWorktreePayload = Extract< SessionOutboundMessage, { type: "create_paseo_worktree_response" } >["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< AgentSessionConfig, "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" >; type ListCommandsOptions = { requestId?: string; draftConfig?: ListCommandsDraftConfig; }; type SetVoiceModePayload = Extract< SessionOutboundMessage, { type: "set_voice_mode_response" } >["payload"]; type DictationFinishAcceptedPayload = Extract< SessionOutboundMessage, { type: "dictation_stream_finish_accepted" } >["payload"]; type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type CloseItemsPayload = CloseItemsResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; type CaptureTerminalPayload = CaptureTerminalResponse["payload"]; type ChatCreatePayload = Extract< SessionOutboundMessage, { type: "chat/create/response" } >["payload"]; type ChatListPayload = Extract< SessionOutboundMessage, { type: "chat/list/response" } >["payload"]; type ChatInspectPayload = Extract< SessionOutboundMessage, { type: "chat/inspect/response" } >["payload"]; type ChatDeletePayload = Extract< SessionOutboundMessage, { type: "chat/delete/response" } >["payload"]; type ChatPostPayload = Extract< SessionOutboundMessage, { type: "chat/post/response" } >["payload"]; type ChatReadPayload = Extract< SessionOutboundMessage, { type: "chat/read/response" } >["payload"]; type ChatWaitPayload = Extract< SessionOutboundMessage, { type: "chat/wait/response" } >["payload"]; type LoopRunPayload = Extract< SessionOutboundMessage, { type: "loop/run/response" } >["payload"]; type LoopListPayload = Extract< SessionOutboundMessage, { type: "loop/list/response" } >["payload"]; type LoopInspectPayload = Extract< SessionOutboundMessage, { type: "loop/inspect/response" } >["payload"]; type LoopLogsPayload = Extract< SessionOutboundMessage, { type: "loop/logs/response" } >["payload"]; type LoopStopPayload = Extract< SessionOutboundMessage, { type: "loop/stop/response" } >["payload"]; type ScheduleCreatePayload = Extract< SessionOutboundMessage, { type: "schedule/create/response" } >["payload"]; type ScheduleListPayload = Extract< SessionOutboundMessage, { type: "schedule/list/response" } >["payload"]; type ScheduleInspectPayload = Extract< SessionOutboundMessage, { type: "schedule/inspect/response" } >["payload"]; type ScheduleLogsPayload = Extract< SessionOutboundMessage, { type: "schedule/logs/response" } >["payload"]; type SchedulePausePayload = Extract< SessionOutboundMessage, { type: "schedule/pause/response" } >["payload"]; type ScheduleResumePayload = Extract< SessionOutboundMessage, { type: "schedule/resume/response" } >["payload"]; type ScheduleDeletePayload = Extract< SessionOutboundMessage, { type: "schedule/delete/response" } >["payload"]; export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"]; export type FetchAgentTimelineCursor = NonNullable; export type FetchAgentTimelineOptions = { direction?: FetchAgentTimelineDirection; cursor?: FetchAgentTimelineCursor; limit?: number; projection?: FetchAgentTimelineProjection; requestId?: string; }; type AgentRefreshedStatusPayload = z.infer; type RestartRequestedStatusPayload = z.infer; type ShutdownRequestedStatusPayload = z.infer; type FetchAgentsPayload = Extract< SessionOutboundMessage, { type: "fetch_agents_response" } >["payload"]; type FetchAgentsRequest = Extract; export type FetchAgentsOptions = Omit & { requestId?: string; }; export type FetchAgentsEntry = FetchAgentsPayload["entries"][number]; export type FetchAgentsPageInfo = FetchAgentsPayload["pageInfo"]; type FetchWorkspacesPayload = Extract< SessionOutboundMessage, { type: "fetch_workspaces_response" } >["payload"]; type FetchWorkspacesRequest = Extract; export type FetchWorkspacesOptions = Omit & { requestId?: string; }; export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number]; export type FetchWorkspacesPageInfo = FetchWorkspacesPayload["pageInfo"]; export type CreateChatRoomOptions = { name: string; purpose?: string | null; requestId?: string; }; export type InspectChatRoomOptions = { room: string; requestId?: string; }; export type DeleteChatRoomOptions = { room: string; requestId?: string; }; export type PostChatMessageOptions = { room: string; body: string; authorAgentId?: string; replyToMessageId?: string | null; requestId?: string; }; export type ReadChatMessagesOptions = { room: string; limit?: number; since?: string; authorAgentId?: string; requestId?: string; }; export type WaitForChatMessagesOptions = { room: string; afterMessageId?: string | null; timeoutMs?: number; requestId?: string; }; export type RunLoopOptions = { prompt: string; cwd: string; verifyPrompt?: string | null; verifyChecks?: string[]; name?: string | null; sleepMs?: number; maxIterations?: number; maxTimeMs?: number; requestId?: string; }; export type InspectLoopOptions = { id: string; requestId?: string; }; export type LoopLogsOptions = { id: string; afterSeq?: number; requestId?: string; }; export type StopLoopOptions = { id: string; requestId?: string; }; export type CreateScheduleOptions = { prompt: string; name?: string | null; cadence: | { type: "every"; everyMs: number; } | { type: "cron"; expression: string; }; target: | { type: "self"; agentId: string; } | { type: "agent"; agentId: string; } | { type: "new-agent"; config: { provider: AgentProvider; cwd: string; modeId?: string; model?: string; thinkingOptionId?: string; title?: string | null; approvalPolicy?: string; sandboxMode?: string; networkAccess?: boolean; webSearch?: boolean; extra?: AgentSessionConfig["extra"]; systemPrompt?: string; mcpServers?: AgentSessionConfig["mcpServers"]; }; }; maxRuns?: number; expiresAt?: string; requestId?: string; }; export type InspectScheduleOptions = { id: string; requestId?: string; }; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; export type FetchAgentResult = { agent: AgentSnapshotPayload; project: ProjectPlacementPayload | null; }; export type WaitForFinishResult = { status: "idle" | "error" | "permission" | "timeout"; final: AgentSnapshotPayload | null; error: string | null; lastMessage: 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 }; type CorrelatedResponseMessage = Extract< SessionOutboundMessage, { payload: { requestId: string } } >; type CorrelatedResponseType = CorrelatedResponseMessage["type"]; type CorrelatedResponsePayload = Extract< CorrelatedResponseMessage, { type: TType } >["payload"]; class DaemonRpcError extends Error { readonly requestId: string; readonly requestType?: string; readonly code?: string; constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) { const parts = [params.error]; if (params.requestType) parts.push(`requestType=${params.requestType}`); if (params.code) parts.push(`code=${params.code}`); super(parts.join(" ")); 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; const DEFAULT_CONNECT_TIMEOUT_MS = 15000; /** Default timeout for waiting for connection before sending queued messages */ const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000; const DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS = 15000; const DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_DICTATION_FINISH_TIMEOUT_GRACE_MS = 5000; function isWaiterTimeoutError(error: unknown): boolean { return error instanceof Error && error.message.startsWith("Timeout waiting for message"); } function normalizeClientId(value: unknown): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function hashForLog(value: string): string { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) | 0; } return `h_${Math.abs(hash).toString(16)}`; } function toReasonCode(reason: string | null | undefined): string | null { if (!reason) { return null; } const normalized = reason.toLowerCase(); if (normalized.includes("timed out")) { return "connect_timeout"; } if (normalized.includes("disposed")) { return "disposed"; } if (normalized.includes("client closed")) { return "client_closed"; } if (normalized.includes("transport")) { return "transport_error"; } if (normalized.includes("failed to connect")) { return "connect_failed"; } return "unknown"; } 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 connectTimeout: 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 checkoutDiffSubscriptions = new Map< string, { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } >(); private terminalDirectorySubscriptions = new Set(); private terminalSlots = new Map(); private slotTerminals = new Map(); private readonly terminalStreamListeners = new Set<(event: TerminalStreamEvent) => void>(); private logger: Logger; private pendingSendQueue: PendingSend[] = []; private readonly logConnectionPath: "direct" | "relay"; private readonly logServerId: string | null; private readonly logClientIdHash: string; private readonly logGeneration: number | null; private lastServerInfoMessage: ServerInfoStatusPayload | null = null; constructor(private config: DaemonClientConfig) { this.logger = config.logger ?? consoleLogger; this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? "relay" : "direct"; let parsedUrlForLog: URL | null = null; try { parsedUrlForLog = new URL(this.config.url); } catch { parsedUrlForLog = null; } const parsedServerIdForLog = normalizeClientId(parsedUrlForLog?.searchParams.get("serverId")); this.logServerId = parsedServerIdForLog ?? parsedUrlForLog?.host ?? null; const resolvedClientId = normalizeClientId(this.config.clientId); if (!resolvedClientId) { throw new Error("Daemon client requires a non-empty clientId"); } this.config.clientId = resolvedClientId; this.logClientIdHash = hashForLog(resolvedClientId); this.logGeneration = typeof this.config.runtimeGeneration === "number" && Number.isFinite(this.config.runtimeGeneration) ? this.config.runtimeGeneration : null; } // ============================================================================ // Connection // ============================================================================ async connect(): Promise { if (this.connectionState.status === "disposed") { throw new Error("Daemon client is disposed"); } 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.connectionState.status === "disposed") { this.rejectConnect(new Error("Daemon client is disposed")); return; } 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 { // Reconnect can overlap with browser close/error delivery ordering. // Always dispose previous transport before constructing the next one. 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 transportUrl = this.resolveTransportUrlForAttempt(); const transport = transportFactory({ url: transportUrl, headers }); this.transport = transport; this.lastServerInfoMessage = null; this.updateConnectionState( { status: "connecting", attempt: this.reconnectAttempt, }, { event: "CONNECT_REQUEST" }, ); this.resetConnectTimeout(); const timeoutMs = Math.max(1, this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS); this.connectTimeout = setTimeout(() => { if (this.connectionState.status !== "connecting") { return; } this.lastErrorValue = "Connection timed out"; this.disposeTransport(1001, "Connection timed out"); this.scheduleReconnect({ reason: "Connection timed out", event: "CONNECT_TIMEOUT", reasonCode: "connect_timeout", }); }, timeoutMs); this.transportCleanup = [ transport.onOpen(() => { if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; } this.lastErrorValue = null; this.sendHelloMessage(); }), transport.onClose((event) => { this.resetConnectTimeout(); if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; } const reason = describeTransportClose(event); if (reason) { this.lastErrorValue = reason; } this.scheduleReconnect({ reason, event: "TRANSPORT_CLOSE", reasonCode: "transport_closed", }); }), transport.onError((event) => { this.resetConnectTimeout(); 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.scheduleReconnect({ reason, event: "TRANSPORT_ERROR", reasonCode: "transport_error", }); } }, 250); } return; } if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; } this.lastErrorValue = reason; this.scheduleReconnect({ reason, event: "TRANSPORT_ERROR", reasonCode: "transport_error", }); }), transport.onMessage((data) => this.handleTransportMessage(data)), ]; } catch (error) { this.resetConnectTimeout(); const message = error instanceof Error ? error.message : "Failed to connect"; this.lastErrorValue = message; this.scheduleReconnect({ reason: message, event: "CONNECT_FAILED", reasonCode: "connect_failed", }); 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 { if (this.connectionState.status === "disposed") { return; } this.shouldReconnect = false; this.connectPromise = null; this.connectResolve = null; this.connectReject = null; if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } this.resetConnectTimeout(); this.disposeTransport(1000, "Client closed"); this.clearWaiters(new Error("Daemon client closed")); this.rejectPendingSendQueue(new Error("Daemon client closed")); this.clearTerminalSlots(); this.lastServerInfoMessage = null; this.updateConnectionState( { status: "disposed" }, { event: "DISPOSE", reason: "Client closed", reasonCode: "disposed" }, ); } ensureConnected(): void { if (this.connectionState.status === "disposed") { return; } 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)); } } private sendBinaryFrame(frame: Uint8Array): void { if (!this.transport || this.connectionState.status !== "connected") { if (this.config.suppressSendErrors) { return; } throw new Error(`Transport not connected (status: ${this.connectionState.status})`); } try { this.transport.send(frame); } 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 async sendCorrelatedRequest< TResponseType extends CorrelatedResponseType, TResult = CorrelatedResponsePayload, >(params: { requestId: string; message: SessionInboundMessage; timeout: number; responseType: TResponseType; options?: { skipQueue?: boolean }; selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; }): Promise { return this.sendRequest({ requestId: params.requestId, message: params.message, timeout: params.timeout, options: params.options, select: (msg) => { const correlated = msg as CorrelatedResponseMessage; if (correlated.type !== params.responseType) { return null; } const payload = correlated.payload as unknown as CorrelatedResponsePayload; if (payload.requestId !== params.requestId) { return null; } if (!params.selectPayload) { return payload as TResult; } return params.selectPayload(payload); }, }); } private sendCorrelatedSessionRequest< TResponseType extends CorrelatedResponseType, TResult = CorrelatedResponsePayload, >(params: { requestId?: string; message: { type: SessionInboundMessage["type"] } & Record; responseType: TResponseType; timeout: number; selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; }): Promise { const resolvedRequestId = this.createRequestId(params.requestId); const message = SessionInboundMessageSchema.parse({ ...params.message, requestId: resolvedRequestId, }); return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, responseType: params.responseType, timeout: params.timeout, options: { skipQueue: true }, ...(params.selectPayload ? { selectPayload: params.selectPayload } : {}), }); } 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; appVisibilityChangedAt?: string; }): void { this.sendSessionMessage({ type: "client_heartbeat", deviceType: params.deviceType, focusedAgentId: params.focusedAgentId, lastActivityAt: params.lastActivityAt, appVisible: params.appVisible, appVisibilityChangedAt: params.appVisibilityChangedAt, }); } 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?: FetchAgentsOptions): Promise { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agents_request", requestId: resolvedRequestId, ...(options?.filter ? { filter: options.filter } : {}), ...(options?.sort ? { sort: options.sort } : {}), ...(options?.page ? { page: options.page } : {}), ...(options?.subscribe ? { subscribe: options.subscribe } : {}), }); 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; }, }); } async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_workspaces_request", requestId: resolvedRequestId, ...(options?.filter ? { filter: options.filter } : {}), ...(options?.sort ? { sort: options.sort } : {}), ...(options?.page ? { page: options.page } : {}), ...(options?.subscribe ? { subscribe: options.subscribe } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_workspaces_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); } async openProject(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "open_project_request", cwd, }, responseType: "open_project_response", timeout: 10000, }); } async archiveWorkspace( workspaceId: string, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "archive_workspace_request", workspaceId, }, responseType: "archive_workspace_response", timeout: 10000, }); } 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); } if (!payload.agent) { return null; } return { agent: payload.agent, project: payload.project ?? null }; } private resubscribeCheckoutDiffSubscriptions(): void { if (this.checkoutDiffSubscriptions.size === 0) { return; } for (const [subscriptionId, subscription] of this.checkoutDiffSubscriptions) { const message = SessionInboundMessageSchema.parse({ type: "subscribe_checkout_diff_request", subscriptionId, cwd: subscription.cwd, compare: subscription.compare, requestId: this.createRequestId(), }); this.sendSessionMessage(message); } } private resubscribeTerminalDirectorySubscriptions(): void { if (this.terminalDirectorySubscriptions.size === 0) { return; } for (const cwd of this.terminalDirectorySubscriptions) { this.sendSessionMessage({ type: "subscribe_terminals_request", cwd, }); } } // ============================================================================ // 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.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), ...(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: 60000, 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 updateAgent( agentId: string, updates: { name?: string; labels?: Record }, ): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "update_agent_request", agentId, ...(updates.name !== undefined ? { name: updates.name } : {}), ...(updates.labels && Object.keys(updates.labels).length > 0 ? { labels: updates.labels } : {}), requestId, }); const payload = await this.sendRequest({ requestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "update_agent_response") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); if (!payload.accepted) { throw new Error(payload.error ?? "updateAgent rejected"); } } 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 fetchAgentTimeline( agentId: string, options: FetchAgentTimelineOptions = {}, ): Promise { const resolvedRequestId = this.createRequestId(options.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_timeline_request", agentId, requestId: resolvedRequestId, ...(options.direction ? { direction: options.direction } : {}), ...(options.cursor ? { cursor: options.cursor } : {}), ...(typeof options.limit === "number" ? { limit: options.limit } : {}), ...(options.projection ? { projection: options.projection } : {}), }); const payload = await this.sendRequest({ requestId: resolvedRequestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_agent_timeline_response") { return null; } if (msg.payload.requestId !== resolvedRequestId) { return null; } return msg.payload; }, }); if (payload.error) { throw new Error(payload.error); } return payload; } // ============================================================================ // 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 setAgentFeature(agentId: string, featureId: string, value: unknown): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "set_agent_feature_request", agentId, featureId, value, requestId, }); const payload = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "set_agent_feature_response") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); if (!payload.accepted) { throw new Error(payload.error ?? "setAgentFeature 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; }, }); } async shutdownServer(requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "shutdown_server_request", requestId: resolvedRequestId, }); return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { return null; } const shutdown = ShutdownRequestedStatusPayloadSchema.safeParse(msg.payload); if (!shutdown.success) { return null; } if (shutdown.data.requestId !== resolvedRequestId) { return null; } return shutdown.data; }, }); } // ============================================================================ // Audio / Voice // ============================================================================ async setVoiceMode(enabled: boolean, agentId?: string): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "set_voice_mode", enabled, ...(agentId ? { agentId } : {}), requestId, }); const response = await this.sendRequest({ requestId, message, timeout: 10000, select: (msg) => { if (msg.type !== "set_voice_mode_response") { return null; } if (msg.payload.requestId !== requestId) { return null; } return msg.payload; }, }); if (!response.accepted) { const codeSuffix = typeof response.reasonCode === "string" && response.reasonCode.trim().length > 0 ? ` (${response.reasonCode})` : ""; throw new Error((response.error ?? "Failed to set voice mode") + codeSuffix); } return response; } async sendVoiceAudioChunk(audio: string, format: string, isLast = false): Promise { this.sendSessionMessage({ type: "voice_audio_chunk", audio, format, isLast }); } async 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); }); const cleanupError = new Error("Cancelled dictation start waiter"); try { this.sendSessionMessageStrict({ type: "dictation_stream_start", dictationId, format }); await Promise.race([ackPromise, errorPromise]); } finally { ack.cancel(cleanupError); streamError.cancel(cleanupError); void ackPromise.catch(() => undefined); void errorPromise.catch(() => undefined); } } sendDictationStreamChunk(dictationId: string, seq: number, audio: string, format: string): void { this.sendSessionMessageStrict({ type: "dictation_stream_chunk", dictationId, seq, audio, format, }); } async 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; }, 0, { 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; }, 0, { skipQueue: true }, ); const finishAccepted = this.waitForWithCancel( (msg) => { if (msg.type !== "dictation_stream_finish_accepted") { return null; } if (msg.payload.dictationId !== dictationId) { return null; } return msg.payload; }, DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS, { skipQueue: true }, ); const finalPromise = final.promise; const errorPromise = streamError.promise.then((payload) => { throw new Error(payload.error); }); const finishAcceptedPromise = finishAccepted.promise; const finalOutcomePromise = finalPromise.then((payload) => ({ kind: "final" as const, payload, })); const errorOutcomePromise = errorPromise.then( () => ({ kind: "error" as const, error: new Error("Unexpected dictation stream error state"), }), (error) => ({ kind: "error" as const, error: error instanceof Error ? error : new Error(String(error)), }), ); const finishAcceptedOutcomePromise = finishAcceptedPromise.then( (payload) => ({ kind: "accepted" as const, payload }), (error) => { if (isWaiterTimeoutError(error)) { return { kind: "accepted_timeout" as const }; } return { kind: "accepted_error" as const, error: error instanceof Error ? error : new Error(String(error)), }; }, ); const waitForFinalResult = async ( timeoutMs: number, ): Promise<{ dictationId: string; text: string }> => { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { const outcome = await Promise.race([finalOutcomePromise, errorOutcomePromise]); if (outcome.kind === "error") { throw outcome.error; } return outcome.payload; } let timeoutHandle: ReturnType | null = null; const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => { timeoutHandle = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs); }); const outcome = await Promise.race([ finalOutcomePromise, errorOutcomePromise, timeoutPromise, ]); if (timeoutHandle) { clearTimeout(timeoutHandle); } if (outcome.kind === "timeout") { throw new Error(`Timeout waiting for dictation finalization (${timeoutMs}ms)`); } if (outcome.kind === "error") { throw outcome.error; } return outcome.payload; }; const cleanupError = new Error("Cancelled dictation finish waiter"); try { this.sendSessionMessageStrict({ type: "dictation_stream_finish", dictationId, finalSeq }); const firstOutcome = await Promise.race([ finalOutcomePromise, errorOutcomePromise, finishAcceptedOutcomePromise, ]); if (firstOutcome.kind === "final") { return firstOutcome.payload; } if (firstOutcome.kind === "error") { throw firstOutcome.error; } if (firstOutcome.kind === "accepted") { return await waitForFinalResult( firstOutcome.payload.timeoutMs + DEFAULT_DICTATION_FINISH_TIMEOUT_GRACE_MS, ); } return await waitForFinalResult(DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS); } finally { final.cancel(cleanupError); streamError.cancel(cleanupError); finishAccepted.cancel(cleanupError); void finalPromise.catch(() => undefined); void errorPromise.catch(() => undefined); void finishAcceptedPromise.catch(() => undefined); } } 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; } private normalizeCheckoutDiffCompare(compare: { mode: "uncommitted" | "base"; baseRef?: string; }): { mode: "uncommitted" | "base"; baseRef?: string } { if (compare.mode === "uncommitted") { return { mode: "uncommitted" }; } const trimmedBaseRef = compare.baseRef?.trim(); if (!trimmedBaseRef) { return { mode: "base" }; } return { mode: "base", baseRef: trimmedBaseRef }; } async getCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base"; baseRef?: string }, requestId?: string, ): Promise { const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`; try { const payload = await this.subscribeCheckoutDiff(cwd, compare, { subscriptionId: oneShotSubscriptionId, requestId, }); return { cwd: payload.cwd, files: payload.files, error: payload.error, requestId: payload.requestId, }; } finally { try { this.unsubscribeCheckoutDiff(oneShotSubscriptionId); } catch { // Ignore disconnect races during one-shot cleanup. } } } async subscribeCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base"; baseRef?: string }, options?: { subscriptionId?: string; requestId?: string }, ): Promise { const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); const normalizedCompare = this.normalizeCheckoutDiffCompare(compare); const previousSubscription = this.checkoutDiffSubscriptions.get(subscriptionId) ?? null; this.checkoutDiffSubscriptions.set(subscriptionId, { cwd, compare: normalizedCompare, }); const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "subscribe_checkout_diff_request", subscriptionId, cwd, compare: normalizedCompare, requestId: resolvedRequestId, }); try { return await this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, responseType: "subscribe_checkout_diff_response", timeout: 60000, options: { skipQueue: true }, selectPayload: (payload) => { if (payload.subscriptionId !== subscriptionId) { return null; } return payload; }, }); } catch (error) { if (previousSubscription) { this.checkoutDiffSubscriptions.set(subscriptionId, previousSubscription); } else { this.checkoutDiffSubscriptions.delete(subscriptionId); } throw error; } } unsubscribeCheckoutDiff(subscriptionId: string): void { this.checkoutDiffSubscriptions.delete(subscriptionId); this.sendSessionMessage({ type: "unsubscribe_checkout_diff_request", subscriptionId, }); } async checkoutCommit( cwd: string, input: { message?: string; addAll?: boolean }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "checkout_commit_request", cwd, message: input.message, addAll: input.addAll, }, responseType: "checkout_commit_response", timeout: 60000, }); } async checkoutMerge( cwd: string, input: { baseRef?: string; strategy?: "merge" | "squash"; requireCleanTarget?: boolean }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "checkout_merge_request", cwd, baseRef: input.baseRef, strategy: input.strategy, requireCleanTarget: input.requireCleanTarget, }, responseType: "checkout_merge_response", timeout: 60000, }); } async checkoutMergeFromBase( cwd: string, input: { baseRef?: string; requireCleanTarget?: boolean }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "checkout_merge_from_base_request", cwd, baseRef: input.baseRef, requireCleanTarget: input.requireCleanTarget, }, responseType: "checkout_merge_from_base_response", timeout: 60000, }); } async checkoutPush(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "checkout_push_request", cwd, }, responseType: "checkout_push_response", timeout: 60000, }); } async checkoutPrCreate( cwd: string, input: { title?: string; body?: string; baseRef?: string }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "checkout_pr_create_request", cwd, title: input.title, body: input.body, baseRef: input.baseRef, }, responseType: "checkout_pr_create_response", timeout: 60000, }); } async checkoutPrStatus(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "checkout_pr_status_request", cwd, }, responseType: "checkout_pr_status_response", timeout: 60000, }); } async getPaseoWorktreeList( input: { cwd?: string; repoRoot?: string }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "paseo_worktree_list_request", cwd: input.cwd, repoRoot: input.repoRoot, }, responseType: "paseo_worktree_list_response", timeout: 60000, }); } async archivePaseoWorktree( input: { worktreePath?: string; repoRoot?: string; branchName?: string }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "paseo_worktree_archive_request", worktreePath: input.worktreePath, repoRoot: input.repoRoot, branchName: input.branchName, }, responseType: "paseo_worktree_archive_response", timeout: 20000, }); } async createPaseoWorktree( input: { cwd: string; worktreeSlug?: string }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "create_paseo_worktree_request", cwd: input.cwd, worktreeSlug: input.worktreeSlug, }, responseType: "create_paseo_worktree_response", timeout: 60000, }); } async validateBranch( options: { cwd: string; branchName: string }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "validate_branch_request", cwd: options.cwd, branchName: options.branchName, }, responseType: "validate_branch_response", timeout: 10000, }); } async getBranchSuggestions( options: { cwd: string; query?: string; limit?: number }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "branch_suggestions_request", cwd: options.cwd, query: options.query, limit: options.limit, }, responseType: "branch_suggestions_response", timeout: 10000, }); } async getDirectorySuggestions( options: { query: string; limit?: number; cwd?: string; includeFiles?: boolean; includeDirectories?: boolean; }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "directory_suggestions_request", query: options.query, cwd: options.cwd, includeFiles: options.includeFiles, includeDirectories: options.includeDirectories, limit: options.limit, }, responseType: "directory_suggestions_response", timeout: 10000, }); } // ============================================================================ // File Explorer // ============================================================================ async exploreFileSystem( cwd: string, path: string, mode: "list" | "file" = "list", requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "file_explorer_request", cwd, path, mode, }, responseType: "file_explorer_response", timeout: 10000, }); } async requestDownloadToken( cwd: string, path: string, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "file_download_token_request", cwd, path, }, responseType: "file_download_token_response", timeout: 10000, }); } async requestProjectIcon( cwd: string, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "project_icon_request", cwd, }, responseType: "project_icon_response", timeout: 10000, }); } // ============================================================================ // Provider Models / Commands // ============================================================================ async listProviderModels( provider: AgentProvider, options?: { cwd?: string; requestId?: string }, ): Promise { return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { type: "list_provider_models_request", provider, cwd: options?.cwd, }, responseType: "list_provider_models_response", // Provider SDK cold starts (especially model discovery) can exceed 30s. timeout: 45000, }); } async listProviderModes( provider: AgentProvider, options?: { cwd?: string; requestId?: string }, ): Promise { return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { type: "list_provider_modes_request", provider, cwd: options?.cwd, }, responseType: "list_provider_modes_response", timeout: 45000, }); } async listProviderFeatures( draftConfig: ListCommandsDraftConfig, options?: { requestId?: string }, ): Promise { return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { type: "list_provider_features_request", draftConfig, }, responseType: "list_provider_features_response", timeout: 45000, }); } async listAvailableProviders(options?: { requestId?: string; }): Promise { return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { type: "list_available_providers_request", }, responseType: "list_available_providers_response", timeout: 30000, }); } async listCommands(agentId: string, requestId?: string): Promise; async listCommands(agentId: string, options?: ListCommandsOptions): Promise; async listCommands( agentId: string, requestIdOrOptions?: string | ListCommandsOptions, ): Promise { const requestId = typeof requestIdOrOptions === "string" ? requestIdOrOptions : requestIdOrOptions?.requestId; const draftConfig = typeof requestIdOrOptions === "string" ? undefined : requestIdOrOptions?.draftConfig; return this.sendCorrelatedSessionRequest({ requestId, message: { type: "list_commands_request", agentId, ...(draftConfig ? { draftConfig } : {}), }, responseType: "list_commands_response", timeout: 30000, }); } // ============================================================================ // 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 initialResult = await this.fetchAgent(agentId).catch(() => null); if (initialResult && predicate(initialResult.agent)) { return initialResult.agent; } const deadline = Date.now() + timeout; return await new Promise((resolve, reject) => { let settled = false; let pollInFlight = false; let pollTimer: ReturnType | null = null; let timeoutTimer: ReturnType | null = null; let unsubscribe: (() => void) | null = null; const finish = ( result: { kind: "ok"; snapshot: AgentSnapshotPayload } | { kind: "error"; error: Error }, ) => { if (settled) { return; } settled = true; if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; } if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (unsubscribe) { unsubscribe(); unsubscribe = null; } if (result.kind === "ok") { resolve(result.snapshot); return; } reject(result.error); }; const maybeResolve = (snapshot: AgentSnapshotPayload | null) => { if (!snapshot) { return false; } if (!predicate(snapshot)) { return false; } finish({ kind: "ok", snapshot }); return true; }; const poll = async () => { if (settled || pollInFlight) { return; } pollInFlight = true; try { const result = await this.fetchAgent(agentId).catch(() => null); maybeResolve(result?.agent ?? null); } finally { pollInFlight = false; } }; unsubscribe = this.on("agent_update", (message) => { if (settled) { return; } if (message.type !== "agent_update") { return; } if (message.payload.kind !== "upsert") { return; } const snapshot = message.payload.agent; if (snapshot.id !== agentId) { return; } maybeResolve(snapshot); }); const remaining = Math.max(1, deadline - Date.now()); timeoutTimer = setTimeout(() => { finish({ kind: "error", error: new Error(`Timed out waiting for agent ${agentId}`), }); }, remaining); pollTimer = setInterval(() => { void poll(); }, 250); void poll(); }); } async waitForFinish(agentId: string, timeout = 60000): Promise { const requestId = this.createRequestId(); const hasTimeout = Number.isFinite(timeout) && timeout > 0; const message = SessionInboundMessageSchema.parse({ type: "wait_for_finish_request", requestId, agentId, ...(hasTimeout ? { timeoutMs: timeout } : {}), }); const payload = await this.sendCorrelatedRequest({ requestId, message, responseType: "wait_for_finish_response", timeout: hasTimeout ? timeout + 5000 : 0, options: { skipQueue: true }, }); return { status: payload.status, final: payload.final, error: payload.error, lastMessage: payload.lastMessage, }; } // ============================================================================ // Terminals // ============================================================================ subscribeTerminals(input: { cwd: string }): void { this.terminalDirectorySubscriptions.add(input.cwd); if (!this.transport || this.connectionState.status !== "connected") { return; } this.sendSessionMessage({ type: "subscribe_terminals_request", cwd: input.cwd, }); } unsubscribeTerminals(input: { cwd: string }): void { this.terminalDirectorySubscriptions.delete(input.cwd); if (!this.transport || this.connectionState.status !== "connected") { return; } this.sendSessionMessage({ type: "unsubscribe_terminals_request", cwd: input.cwd, }); } async listTerminals(cwd?: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "list_terminals_request", ...(cwd === undefined ? {} : { cwd }), requestId: resolvedRequestId, }); return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, responseType: "list_terminals_response", timeout: 10000, options: { skipQueue: true }, }); } 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.sendCorrelatedRequest({ requestId: resolvedRequestId, message, responseType: "create_terminal_response", timeout: 10000, options: { skipQueue: true }, }); } async subscribeTerminal( terminalId: string, requestId?: string, ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "subscribe_terminal_request", terminalId, requestId: resolvedRequestId, }); const payload = await this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, responseType: "subscribe_terminal_response", timeout: 10000, options: { skipQueue: true }, }); if (payload.error === null) { this.setTerminalSlot(terminalId, payload.slot); } return payload; } unsubscribeTerminal(terminalId: string): void { this.removeTerminalSlot(terminalId); this.sendSessionMessage({ type: "unsubscribe_terminal_request", terminalId, }); } sendTerminalInput(terminalId: string, message: TerminalInput["message"]): void { const slot = this.terminalSlots.get(terminalId); if (typeof slot === "number") { if (message.type === "input") { this.sendBinaryFrame( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Input, slot, payload: encodeUtf8String(message.data), }), ); return; } if (message.type === "resize") { this.sendBinaryFrame( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Resize, slot, payload: encodeTerminalResizePayload({ rows: message.rows, cols: message.cols, }), }), ); return; } } 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.sendCorrelatedRequest({ requestId: resolvedRequestId, message, responseType: "kill_terminal_response", timeout: 10000, options: { skipQueue: true }, }); } async closeItems( input: { agentIds?: string[]; terminalIds?: string[] }, requestId?: string, ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "close_items_request", agentIds: input.agentIds ?? [], terminalIds: input.terminalIds ?? [], requestId: resolvedRequestId, }); return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, responseType: "close_items_response", timeout: 10000, options: { skipQueue: true }, }); } async captureTerminal( terminalId: string, options?: { start?: number; end?: number; stripAnsi?: boolean }, requestId?: string, ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "capture_terminal_request", terminalId, ...(options?.start === undefined ? {} : { start: options.start }), ...(options?.end === undefined ? {} : { end: options.end }), ...(options?.stripAnsi === undefined ? {} : { stripAnsi: options.stripAnsi }), requestId: resolvedRequestId, }); return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, responseType: "capture_terminal_response", timeout: 10000, options: { skipQueue: true }, }); } async createChatRoom(options: CreateChatRoomOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "chat/create", name: options.name, ...(options.purpose ? { purpose: options.purpose } : {}), }, responseType: "chat/create/response", timeout: 10000, }); } async listChatRooms(requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "chat/list", }, responseType: "chat/list/response", timeout: 10000, }); } async inspectChatRoom(options: InspectChatRoomOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "chat/inspect", room: options.room, }, responseType: "chat/inspect/response", timeout: 10000, }); } async deleteChatRoom(options: DeleteChatRoomOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "chat/delete", room: options.room, }, responseType: "chat/delete/response", timeout: 10000, }); } async postChatMessage(options: PostChatMessageOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "chat/post", room: options.room, body: options.body, ...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}), ...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}), }, responseType: "chat/post/response", timeout: 10000, }); } async readChatMessages(options: ReadChatMessagesOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "chat/read", room: options.room, ...(typeof options.limit === "number" ? { limit: options.limit } : {}), ...(options.since ? { since: options.since } : {}), ...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}), }, responseType: "chat/read/response", timeout: 10000, }); } async waitForChatMessages(options: WaitForChatMessagesOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "chat/wait", room: options.room, ...(options.afterMessageId ? { afterMessageId: options.afterMessageId } : {}), ...(typeof options.timeoutMs === "number" ? { timeoutMs: options.timeoutMs } : {}), }, responseType: "chat/wait/response", timeout: (options.timeoutMs ?? 0) + 10000, }); } async scheduleCreate(options: CreateScheduleOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "schedule/create", prompt: options.prompt, cadence: options.cadence, target: options.target, ...(options.name ? { name: options.name } : {}), ...(typeof options.maxRuns === "number" ? { maxRuns: options.maxRuns } : {}), ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}), }, responseType: "schedule/create/response", timeout: 10000, }); } async scheduleList(requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "schedule/list", }, responseType: "schedule/list/response", timeout: 10000, }); } async scheduleInspect(options: InspectScheduleOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "schedule/inspect", scheduleId: options.id, }, responseType: "schedule/inspect/response", timeout: 10000, }); } async scheduleLogs(options: InspectScheduleOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "schedule/logs", scheduleId: options.id, }, responseType: "schedule/logs/response", timeout: 10000, }); } async schedulePause(options: InspectScheduleOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "schedule/pause", scheduleId: options.id, }, responseType: "schedule/pause/response", timeout: 10000, }); } async scheduleResume(options: InspectScheduleOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "schedule/resume", scheduleId: options.id, }, responseType: "schedule/resume/response", timeout: 10000, }); } async scheduleDelete(options: InspectScheduleOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "schedule/delete", scheduleId: options.id, }, responseType: "schedule/delete/response", timeout: 10000, }); } async loopRun(options: RunLoopOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "loop/run", prompt: options.prompt, cwd: options.cwd, ...(options.verifyPrompt ? { verifyPrompt: options.verifyPrompt } : {}), ...(options.verifyChecks && options.verifyChecks.length > 0 ? { verifyChecks: options.verifyChecks } : {}), ...(options.name ? { name: options.name } : {}), ...(typeof options.sleepMs === "number" ? { sleepMs: options.sleepMs } : {}), ...(typeof options.maxIterations === "number" ? { maxIterations: options.maxIterations } : {}), ...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}), }, responseType: "loop/run/response", timeout: 15000, }); } async loopList(requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "loop/list", }, responseType: "loop/list/response", timeout: 10000, }); } async loopInspect(options: string | InspectLoopOptions): Promise { const normalized = typeof options === "string" ? { id: options } : options; return this.sendCorrelatedSessionRequest({ requestId: normalized.requestId, message: { type: "loop/inspect", id: normalized.id, }, responseType: "loop/inspect/response", timeout: 10000, }); } async loopLogs(options: string | LoopLogsOptions, afterSeq?: number): Promise { const normalized = typeof options === "string" ? { id: options, afterSeq } : options; return this.sendCorrelatedSessionRequest({ requestId: normalized.requestId, message: { type: "loop/logs", id: normalized.id, ...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}), }, responseType: "loop/logs/response", timeout: 10000, }); } async loopStop(options: string | StopLoopOptions): Promise { const normalized = typeof options === "string" ? { id: options } : options; return this.sendCorrelatedSessionRequest({ requestId: normalized.requestId, message: { type: "loop/stop", id: normalized.id, }, responseType: "loop/stop/response", timeout: 10000, }); } onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void { this.terminalStreamListeners.add(handler); return () => { this.terminalStreamListeners.delete(handler); }; } async waitForTerminalStreamEvent( predicate: (event: TerminalStreamEvent) => boolean, timeout = 5000, ): Promise { return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { unsubscribe(); reject(new Error(`Timeout waiting for terminal stream event (${timeout}ms)`)); }, timeout); const unsubscribe = this.onTerminalStreamEvent((event) => { if (!predicate(event)) { return; } clearTimeout(timeoutHandle); unsubscribe(); resolve(event); }); }); } // ============================================================================ // Internals // ============================================================================ private createRequestId(requestId?: string): string { return requestId ?? crypto.randomUUID(); } private setTerminalSlot(terminalId: string, slot: number): void { const existingTerminalId = this.slotTerminals.get(slot); if (existingTerminalId && existingTerminalId !== terminalId) { this.terminalSlots.delete(existingTerminalId); } const existingSlot = this.terminalSlots.get(terminalId); if (typeof existingSlot === "number" && existingSlot !== slot) { this.slotTerminals.delete(existingSlot); } this.terminalSlots.set(terminalId, slot); this.slotTerminals.set(slot, terminalId); } private removeTerminalSlot(terminalId: string): void { const slot = this.terminalSlots.get(terminalId); if (typeof slot !== "number") { return; } this.terminalSlots.delete(terminalId); if (this.slotTerminals.get(slot) === terminalId) { this.slotTerminals.delete(slot); } } private clearTerminalSlots(): void { this.terminalSlots.clear(); this.slotTerminals.clear(); } getLastServerInfoMessage(): ServerInfoStatusPayload | null { return this.lastServerInfoMessage; } private resolveTransportUrlForAttempt(): string { return this.config.url; } private sendHelloMessage(): void { if (!this.transport) { this.scheduleReconnect({ reason: "Transport unavailable before hello", event: "HELLO_TRANSPORT_MISSING", reasonCode: "transport_error", }); return; } try { this.transport.send( JSON.stringify({ type: "hello", clientId: this.config.clientId, clientType: this.config.clientType ?? "cli", protocolVersion: 1, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), }), ); } catch (error) { const message = error instanceof Error ? error.message : "Failed to send hello message"; this.lastErrorValue = message; this.scheduleReconnect({ reason: message, event: "HELLO_SEND_FAILED", reasonCode: "transport_error", }); } } 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 { this.resetConnectTimeout(); if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; } for (const cleanup of this.transportCleanup) { try { cleanup(); } catch { // no-op } } this.transportCleanup = []; } private resetConnectTimeout(): void { if (!this.connectTimeout) { return; } clearTimeout(this.connectTimeout); this.connectTimeout = null; } private handleTransportMessage(data: unknown): void { const rawData = data && typeof data === "object" && "data" in data ? (data as { data: unknown }).data : data; if ( typeof Blob !== "undefined" && rawData instanceof Blob && typeof rawData.arrayBuffer === "function" ) { void rawData .arrayBuffer() .then((buffer) => { this.handleTransportMessage(buffer); }) .catch(() => { // Ignore failed blob decoding and allow reconnect logic to recover. }); return; } const rawBytes = asUint8Array(rawData); if (rawBytes) { const frame = decodeTerminalStreamFrame(rawBytes); if (frame) { this.handleBinaryFrame(frame); return; } } 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 { type?: string })?.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 handleBinaryFrame(frame: TerminalStreamFrame): void { const terminalId = this.slotTerminals.get(frame.slot); if (!terminalId) { return; } if (frame.opcode === TerminalStreamOpcode.Output) { this.emitTerminalStreamEvent({ terminalId, type: "output", data: frame.payload, }); return; } if (frame.opcode === TerminalStreamOpcode.Snapshot) { const state = decodeTerminalSnapshotPayload(frame.payload); if (!state) { return; } this.emitTerminalStreamEvent({ terminalId, type: "snapshot", state, }); } } private emitTerminalStreamEvent(event: TerminalStreamEvent): void { for (const listener of this.terminalStreamListeners) { try { listener(event); } catch { // no-op } } } private updateConnectionState( next: ConnectionState, metadata?: { event: string; reason?: string; reasonCode?: string }, ): void { const previous = this.connectionState; this.connectionState = next; const reasonFromNext = next.status === "disconnected" && typeof next.reason === "string" ? next.reason : null; const reason = metadata?.reason ?? reasonFromNext; const reasonCode = metadata?.reasonCode ?? toReasonCode(reason); this.logger.debug( { serverId: this.logServerId, clientIdHash: this.logClientIdHash, from: previous.status, to: next.status, event: metadata?.event ?? "STATE_UPDATE", connectionPath: this.logConnectionPath, generation: this.logGeneration, reasonCode, reason, }, "DaemonClientTransition", ); for (const listener of this.connectionListeners) { try { listener(next); } catch { // no-op } } } setReconnectEnabled(enabled: boolean): void { this.config = { ...this.config, reconnect: { ...this.config.reconnect, enabled } }; } private scheduleReconnect(input?: { reason?: string; event?: string; reasonCode?: string; }): void { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } const wasDisposed = this.connectionState.status === "disposed"; const reason = input?.reason; 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.clearTerminalSlots(); this.lastServerInfoMessage = null; if (wasDisposed) { this.rejectConnect(new Error(reason ?? "Daemon client is disposed")); return; } this.updateConnectionState( { status: "disconnected", ...(reason ? { reason } : {}), }, { event: input?.event ?? "TRANSPORT_CLOSE", ...(reason ? { reason } : {}), ...(input?.reasonCode ? { reasonCode: input.reasonCode } : {}), }, ); 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; this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null; if (!this.shouldReconnect) { return; } this.attemptConnect(); }, delay); } private handleSessionMessage(msg: SessionOutboundMessage): void { if (msg.type === "status") { const serverInfo = parseServerInfoStatusPayload(msg.payload); if (serverInfo) { this.lastServerInfoMessage = serverInfo; if (this.connectionState.status === "connecting") { this.resetConnectTimeout(); this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.resubscribeCheckoutDiffSubscriptions(); this.resubscribeTerminalDirectorySubscriptions(); this.flushPendingSendQueue(); this.resolveConnect(); } } } if (msg.type === "terminal_stream_exit") { this.removeTerminalSlot(msg.payload.terminalId); } 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 "workspace_update": return { type: "workspace_update", workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id, payload: msg.payload, }; case "agent_stream": return { type: "agent_stream", agentId: msg.payload.agentId, event: msg.payload.event, timestamp: msg.payload.timestamp, ...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}), ...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}), }; 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 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 }; } } 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, }; }