// WebSocket client of the local Claude Code bridge (bridge/server.mjs): sends the user's // messages, receives streamed replies, and executes the tools Claude calls against the app. export interface AppApi { getState(): Promise; setYaml(yaml: string): { issues: { path: string; message: string }[] }; selectPreset(id: string): string; start(): Promise; pause(): string; stop(): string; play(fromSeconds: number): string; setSignal(signal: string): string; } export type ChatEvent = | { type: "connection"; state: "connecting" | "open" | "closed" } | { type: "hello"; model: string; cwd: string } | { type: "init"; session_id: string; model: string } | { type: "status"; state: "working" | "idle" } | { type: "block_start" } | { type: "delta"; text: string } | { type: "tool_use"; name: string; input: string } | { type: "tool_call"; name: string } | { type: "result"; ok: boolean; error?: string; cost: number; turns: number } | { type: "reset" } | { type: "error"; message: string }; export class ChatClient { private ws: WebSocket | null = null; private wanted = false; private retry = 1000; readonly url: string; private readonly api: AppApi; private readonly onEvent: (e: ChatEvent) => void; constructor(url: string, api: AppApi, onEvent: (e: ChatEvent) => void) { this.url = url; this.api = api; this.onEvent = onEvent; } get connected() { return this.ws?.readyState === WebSocket.OPEN; } connect() { this.wanted = true; if (this.ws && this.ws.readyState <= WebSocket.OPEN) return; this.onEvent({ type: "connection", state: "connecting" }); const ws = new WebSocket(this.url); this.ws = ws; ws.onopen = () => { this.retry = 1000; this.onEvent({ type: "connection", state: "open" }); }; ws.onmessage = (ev) => void this.handle(String(ev.data)); ws.onclose = () => { this.onEvent({ type: "connection", state: "closed" }); if (this.wanted) { setTimeout(() => this.connect(), this.retry); this.retry = Math.min(this.retry * 2, 15000); } }; ws.onerror = () => { /* onclose follows */ }; } disconnect() { this.wanted = false; this.ws?.close(); this.ws = null; } private send(obj: unknown) { if (this.connected) this.ws!.send(JSON.stringify(obj)); } sendUser(text: string) { this.send({ type: "user", text }); } interrupt() { this.send({ type: "interrupt" }); } reset() { this.send({ type: "reset" }); } private async handle(raw: string) { let msg: any; try { msg = JSON.parse(raw); } catch { return; } if (msg.type === "tool_call") { this.onEvent({ type: "tool_call", name: msg.name }); const { text, isError } = await this.execute(msg.name, msg.args ?? {}); this.send({ type: "tool_result", id: msg.id, text, isError }); return; } this.onEvent(msg as ChatEvent); } /** Run a tool against the app; every result is a string for Claude. */ async execute(name: string, args: Record): Promise<{ text: string; isError: boolean }> { try { switch (name) { case "get_app_state": return { text: JSON.stringify(await this.api.getState()), isError: false }; case "set_yaml": { const r = this.api.setYaml(String(args.yaml ?? "")); return r.issues.length ? { text: `configuration set, but it has issues:\n${r.issues.map((i) => `- ${i.path || "document"}: ${i.message}`).join("\n")}`, isError: true } : { text: "configuration set; it is valid", isError: false }; } case "select_preset": return { text: this.api.selectPreset(String(args.id)), isError: false }; case "start_run": return { text: await this.api.start(), isError: false }; case "pause_run": return { text: this.api.pause(), isError: false }; case "stop_run": return { text: this.api.stop(), isError: false }; case "play_audio": return { text: this.api.play(Number(args.from_seconds ?? 0)), isError: false }; case "set_signal": return { text: this.api.setSignal(String(args.signal)), isError: false }; default: return { text: `unknown tool ${name}`, isError: true }; } } catch (err) { return { text: `tool ${name} failed: ${(err as Error).message}`, isError: true }; } } }