export type GatewayEventName = | 'gateway.ready' | 'session.info' | 'message.start' | 'message.delta' | 'message.complete' | 'thinking.delta' | 'reasoning.delta' | 'reasoning.available' | 'status.update' | 'tool.start' | 'tool.progress' | 'tool.complete' | 'tool.generating' | 'clarify.request' | 'approval.request' | 'sudo.request' | 'secret.request' | 'background.complete' | 'error' | 'skin.changed' | (string & {}) export interface GatewayEvent

{ payload?: P session_id?: string type: GatewayEventName } export type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed' | 'error' export type GatewayRequestId = number | string export interface JsonRpcFrame { error?: { message?: string } id?: GatewayRequestId | null method?: string params?: GatewayEvent result?: unknown } export type WebSocketLike = WebSocket type PendingCall = { reject: (error: Error) => void resolve: (value: unknown) => void timer?: ReturnType } export interface GatewayClientOptions { closedErrorMessage?: string connectErrorMessage?: string connectTimeoutMs?: number createRequestId?: (nextId: number) => GatewayRequestId requestIdPrefix?: string requestTimeoutMs?: number socketFactory?: (url: string) => WebSocketLike notConnectedErrorMessage?: string } const ANY = '*' const DEFAULT_REQUEST_TIMEOUT_MS = 120_000 // A reconnect after sleep/wake must not hang forever in 'connecting' (which // keeps the composer disabled and stuck on "Starting Hermes..."). If the open // handshake doesn't land in this window, fail to 'error' so callers can retry. const DEFAULT_CONNECT_TIMEOUT_MS = 15_000 export class JsonRpcGatewayClient { private nextId = 0 private pending = new Map() private socket: WebSocketLike | null = null private state: ConnectionState = 'idle' private readonly eventHandlers = new Map void>>() private readonly stateHandlers = new Set<(state: ConnectionState) => void>() private readonly options: Required> & Pick constructor(options: GatewayClientOptions = {}) { this.options = { closedErrorMessage: options.closedErrorMessage ?? 'WebSocket closed', connectErrorMessage: options.connectErrorMessage ?? 'WebSocket connection failed', connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, createRequestId: options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`), notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected', requestIdPrefix: options.requestIdPrefix ?? 'r', requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, socketFactory: options.socketFactory } } get connectionState(): ConnectionState { return this.state } async connect(wsUrl: string): Promise { if (this.socket?.readyState === WebSocket.OPEN || this.state === 'connecting') { return } this.setState('connecting') const socket = this.options.socketFactory?.(wsUrl) ?? new WebSocket(wsUrl) this.socket = socket socket.addEventListener('message', message => { if (this.socket !== socket) { return } this.handleMessage(message.data) }) socket.addEventListener('close', () => { if (this.socket !== socket) { return } this.socket = null this.setState('closed') this.rejectAllPending(new Error(this.options.closedErrorMessage)) }) await new Promise((resolve, reject) => { let settled = false let timer: ReturnType | undefined const cleanup = () => { if (timer !== undefined) { clearTimeout(timer) } socket.removeEventListener('open', onOpen) socket.removeEventListener('error', onError) } const onOpen = () => { if (settled || this.socket !== socket) { return } settled = true cleanup() this.setState('open') resolve() } const onError = () => { if (settled || this.socket !== socket) { return } settled = true cleanup() this.setState('error') reject(new Error(this.options.connectErrorMessage)) } socket.addEventListener('open', onOpen, { once: true }) socket.addEventListener('error', onError, { once: true }) if (this.options.connectTimeoutMs > 0) { timer = setTimeout(() => { if (settled) { return } settled = true cleanup() // Drop the half-open socket so the next connect() starts clean // instead of short-circuiting on a zombie 'connecting' state. if (this.socket === socket) { try { socket.close() } catch { // ignore } this.socket = null } this.setState('error') reject(new Error(this.options.connectErrorMessage)) }, this.options.connectTimeoutMs) } }) } close(): void { this.socket?.close() this.socket = null } on

(type: GatewayEventName, handler: (event: GatewayEvent

) => void): () => void { let handlers = this.eventHandlers.get(type) if (!handlers) { handlers = new Set() this.eventHandlers.set(type, handlers) } handlers.add(handler as (event: GatewayEvent) => void) return () => handlers?.delete(handler as (event: GatewayEvent) => void) } onAny(handler: (event: GatewayEvent) => void): () => void { return this.on(ANY as GatewayEventName, handler) } onEvent(handler: (event: GatewayEvent) => void): () => void { return this.onAny(handler) } onState(handler: (state: ConnectionState) => void): () => void { this.stateHandlers.add(handler) handler(this.state) return () => this.stateHandlers.delete(handler) } request(method: string, params: Record = {}, timeoutMs = this.options.requestTimeoutMs): Promise { const socket = this.socket if (!socket || socket.readyState !== WebSocket.OPEN) { return Promise.reject(new Error(this.options.notConnectedErrorMessage)) } const id = this.options.createRequestId(++this.nextId) return new Promise((resolve, reject) => { const pending: PendingCall = { reject, resolve: value => resolve(value as T) } if (timeoutMs > 0) { pending.timer = setTimeout(() => { if (this.pending.delete(id)) { reject(new Error(`request timed out: ${method}`)) } }, timeoutMs) } this.pending.set(id, pending) try { socket.send( JSON.stringify({ jsonrpc: '2.0', id, method, params }) ) } catch (error) { this.clearPending(id) reject(error instanceof Error ? error : new Error(String(error))) } }) } private handleMessage(raw: unknown): void { const text = typeof raw === 'string' ? raw : String(raw) let frame: JsonRpcFrame try { frame = JSON.parse(text) as JsonRpcFrame } catch { return } if (frame.id !== undefined && frame.id !== null) { const call = this.pending.get(frame.id) if (!call) { return } this.clearPending(frame.id) if (frame.error) { call.reject(new Error(frame.error.message || 'Hermes RPC failed')) } else { call.resolve(frame.result) } return } if (frame.method === 'event' && frame.params?.type) { this.dispatchEvent(frame.params) } } private clearPending(id: GatewayRequestId): void { const call = this.pending.get(id) if (call?.timer) { clearTimeout(call.timer) } this.pending.delete(id) } private dispatchEvent(event: GatewayEvent): void { for (const handler of this.eventHandlers.get(event.type) ?? []) { handler(event) } for (const handler of this.eventHandlers.get(ANY) ?? []) { handler(event) } } private rejectAllPending(error: Error): void { for (const [id, call] of this.pending) { if (call.timer) { clearTimeout(call.timer) } call.reject(error) this.pending.delete(id) } } private setState(state: ConnectionState): void { if (this.state === state) { return } this.state = state for (const handler of this.stateHandlers) { handler(state) } } }