1// WebSocket client of the local Claude Code bridge (bridge/server.mjs): sends the user's
2// messages, receives streamed replies, and executes the tools Claude calls against the app.
4export interface AppApi {
5 getState(): Promise<unknown>;
6 setYaml(yaml: string): { issues: { path: string; message: string }[] };
7 selectPreset(id: string): string;
8 start(): Promise<string>;
9 pause(): string;
10 stop(): string;
11 play(fromSeconds: number): string;
12 setSignal(signal: string): string;
13}
15export type ChatEvent =
16 | { type: "connection"; state: "connecting" | "open" | "closed" }
17 | { type: "hello"; model: string; cwd: string }
18 | { type: "init"; session_id: string; model: string }
19 | { type: "status"; state: "working" | "idle" }
20 | { type: "block_start" }
21 | { type: "delta"; text: string }
22 | { type: "tool_use"; name: string; input: string }
23 | { type: "tool_call"; name: string }
24 | { type: "result"; ok: boolean; error?: string; cost: number; turns: number }
25 | { type: "reset" }
26 | { type: "error"; message: string };
28export class ChatClient {
29 private ws: WebSocket | null = null;
30 private wanted = false;
31 private retry = 1000;
32 readonly url: string; private readonly api: AppApi; private readonly onEvent: (e: ChatEvent) => void;
34 constructor(url: string, api: AppApi, onEvent: (e: ChatEvent) => void) { this.url = url; this.api = api; this.onEvent = onEvent; }
36 get connected() { return this.ws?.readyState === WebSocket.OPEN; }
38 connect() {
39 this.wanted = true;
40 if (this.ws && this.ws.readyState <= WebSocket.OPEN) return;
41 this.onEvent({ type: "connection", state: "connecting" });
42 const ws = new WebSocket(this.url);
43 this.ws = ws;
44 ws.onopen = () => { this.retry = 1000; this.onEvent({ type: "connection", state: "open" }); };
45 ws.onmessage = (ev) => void this.handle(String(ev.data));
46 ws.onclose = () => {
47 this.onEvent({ type: "connection", state: "closed" });
48 if (this.wanted) { setTimeout(() => this.connect(), this.retry); this.retry = Math.min(this.retry * 2, 15000); }
49 };
50 ws.onerror = () => { /* onclose follows */ };
51 }
53 disconnect() { this.wanted = false; this.ws?.close(); this.ws = null; }
55 private send(obj: unknown) { if (this.connected) this.ws!.send(JSON.stringify(obj)); }
56 sendUser(text: string) { this.send({ type: "user", text }); }
57 interrupt() { this.send({ type: "interrupt" }); }
58 reset() { this.send({ type: "reset" }); }
60 private async handle(raw: string) {
61 let msg: any;
62 try { msg = JSON.parse(raw); } catch { return; }
63 if (msg.type === "tool_call") {
64 this.onEvent({ type: "tool_call", name: msg.name });
65 const { text, isError } = await this.execute(msg.name, msg.args ?? {});
66 this.send({ type: "tool_result", id: msg.id, text, isError });
67 return;
68 }
69 this.onEvent(msg as ChatEvent);
70 }
72 /** Run a tool against the app; every result is a string for Claude. */
73 async execute(name: string, args: Record<string, unknown>): Promise<{ text: string; isError: boolean }> {
74 try {
75 switch (name) {
76 case "get_app_state": return { text: JSON.stringify(await this.api.getState()), isError: false };
77 case "set_yaml": {
78 const r = this.api.setYaml(String(args.yaml ?? ""));
79 return r.issues.length
80 ? { text: `configuration set, but it has issues:\n${r.issues.map((i) => `- ${i.path || "document"}: ${i.message}`).join("\n")}`, isError: true }
81 : { text: "configuration set; it is valid", isError: false };
82 }
83 case "select_preset": return { text: this.api.selectPreset(String(args.id)), isError: false };
84 case "start_run": return { text: await this.api.start(), isError: false };
85 case "pause_run": return { text: this.api.pause(), isError: false };
86 case "stop_run": return { text: this.api.stop(), isError: false };
87 case "play_audio": return { text: this.api.play(Number(args.from_seconds ?? 0)), isError: false };
88 case "set_signal": return { text: this.api.setSignal(String(args.signal)), isError: false };
89 default: return { text: `unknown tool ${name}`, isError: true };
90 }
91 } catch (err) {
92 return { text: `tool ${name} failed: ${(err as Error).message}`, isError: true };
93 }
94 }
95}