2// Local bridge between the cymsimweb page and a Claude Code session.
3//
4// The page runs in a browser and cannot start Claude Code itself, so this process runs on the
5// user's machine: it drives the Claude Agent SDK (which uses the local `claude` login, i.e. the
6// user's Claude Code subscription), and relays the tools Claude calls to the page over a
7// WebSocket, where they read the app's state, replace the YAML, start and stop runs, and play
8// the audio. It also serves the built site from dist/ so the page can be used from the same
9// origin. Listens on 127.0.0.1 only and accepts WebSocket connections only from known origins.
10//
11// npm run bridge # ws://localhost:8765, site at http://localhost:8765
12// node bridge/server.mjs --port 9000 --origin https://my.site --model claude-sonnet-5
13import fs from "node:fs";
14import http from "node:http";
15import path from "node:path";
16import { fileURLToPath } from "node:url";
17import { WebSocketServer } from "ws";
18import { z } from "zod";
19import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
20import { defaultConfig } from "../src/config/schema.ts";
21import { PRESETS } from "../src/config/presets.ts";
22import { configToYaml } from "../src/config/yaml.ts";
24const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
26// ---------------------------------------------------------------- arguments
27const argv = process.argv.slice(2);
28const opt = (name, def) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : def; };
29const PORT = Number(opt("--port", process.env.CYMSIMWEB_BRIDGE_PORT ?? 8765));
30const MODEL = opt("--model", process.env.CYMSIMWEB_BRIDGE_MODEL ?? undefined);
31const EXTRA_ORIGINS = argv.flatMap((a, i) => (a === "--origin" && argv[i + 1] ? [argv[i + 1]] : []));
32const ALLOWED_ORIGINS = new Set([
33 "https://cymsimweb--proof-of-concept.vault1-sites.magland.org",
34 "https://vault1.magland.org",
35 ...EXTRA_ORIGINS,
36]);
37const isAllowedOrigin = (origin) => {
38 if (!origin) return true; // non-browser clients (tests); browsers always send Origin
39 if (ALLOWED_ORIGINS.has(origin)) return true;
40 return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
41};
43// ---------------------------------------------------------------- what Claude is told about the app
44const read = (f) => { try { return fs.readFileSync(path.join(root, f), "utf8"); } catch { return ""; } };
45const SYSTEM_APPEND = `
46# cymsimweb assistant
48You are embedded in cymsimweb, a browser application that simulates a struck cymbal with a nonlinear
49finite-element shell model on WebGPU (the companion of the cymsim Python package). The user talks to
50you in a chat panel beside the app. You can see the app's state and change it through the
51mcp__cymsimweb__* tools; the tools run inside the page. Your working directory is the app's source
52repository: read README.md, THEORY.md, src/config/schema.ts (the configuration fields and defaults),
53src/model/*.ts (geometry, mesh, elements, shell, exciters, radiation) and src/gpu/shaders.ts when a
54question needs the details. The equations are in the Python package's docs/model.md, which you may
55fetch at https://vault1.magland.org/proof-of-concept/cymsim/blob/main/docs/model.md . Do not modify
56files: you have no write access, and the app is changed only through the tools.
58## Tools
59- get_app_state: the current YAML, its validation issues, the run state and progress, the set-up
60 summary of the last run, the latest energies and hit results, and a spectrogram summary (per-octave
61 band onsets and levels, spectral centroid over time). Call it before answering questions about
62 what is going on, and after a run to interpret the result.
63- set_yaml: replace the configuration in the editor with a complete YAML document. Omitted fields take
64 the defaults, so a short document is fine, but write every field the user asked about explicitly.
65 The tool returns validation issues; fix them and call again if there are any. Units are SI.
66- select_preset: load one of the built-in configurations.
67- start_run / pause_run / stop_run: control the simulation. A run takes tens of seconds to minutes
68 depending on the GPU; start_run returns immediately, so check progress with get_app_state.
69- play_audio: play the generated sound from a time; set_signal chooses the unbaffled or the baffled
70 (Rayleigh) listener signal for the spectrogram and playback.
72## Guidance
73When the user describes a cymbal or a stroke in words, translate it into the configuration: size sets
74profile.radius (18 inch crash: 0.2286 m; 20 inch ride: 0.254 m), weight sets the thicknesses (thin
75crash h_rim 0.8-1.0 mm, medium 1.0-1.2 mm, ride 1.6-2.0 mm at the rim, thicker toward the bell),
76bell size sets bell_height and bell_shoulder_radius, a stick shoulder hit is a "stick" exciter near
77the rim (r about 0.85 R) and a soft mallet stroke a "mallet" exciter on the bow (r about 0.6 R).
78Loudness is the exciter velocity (stick 4-9 m/s, mallet 3-8 m/s). Losses: sigma0 sets the decay of the
79low modes (T60 about 6.9/sigma0 s), sigma1 the loss growing with frequency. Numerics: element_size
800.006 m is the default resolution and needs a discrete GPU; 0.01-0.02 m for quick previews; duration
81is the simulated seconds. Several hits go in the hits list, each with its own time, position, velocity
82and exciter. After setting a configuration, say briefly what you set and offer to start the run
83(or start it if the user asked for that). When a run finishes, get_app_state and describe the
84result: peak contact force, contact time, how nonlinear (max|w|/h), and whether the band onsets show
85the upward cascade (high bands rising later than low ones).
87Keep replies short and concrete. Do not paste the whole YAML into the chat unless asked; the editor
88shows it.
90## The YAML schema (every field with its default)
91\`\`\`yaml
92${configToYaml(defaultConfig())}\`\`\`
94## Presets
95${PRESETS.map((p) => `- ${p.id}: ${p.name}`).join("\n")}
97## README.md
98${read("README.md")}
99`;
101// ---------------------------------------------------------------- one chat session per socket
102class Session {
103 constructor(ws) {
104 this.ws = ws; this.sessionId = null; this.active = null; this.queue = []; this.pending = new Map(); this.nextId = 1;
105 const relay = (name, description, schema, extras) => tool(name, description, schema, async (args) => this.callBrowser(name, args), { alwaysLoad: true, ...extras });
106 this.server = createSdkMcpServer({
107 name: "cymsimweb", version: "1.0.0",
108 tools: [
109 relay("get_app_state", "Read the app's state: current YAML and validation issues, run state and progress, set-up summary, latest energies and hit results, spectrogram band summary.", {}, { annotations: { readOnlyHint: true } }),
110 relay("set_yaml", "Replace the configuration YAML in the editor. Returns validation issues (empty when valid).", { yaml: z.string().describe("Complete YAML document; omitted fields take the defaults") }),
111 relay("select_preset", "Load a built-in preset into the editor.", { id: z.enum(PRESETS.map((p) => p.id)) }),
112 relay("start_run", "Build the set-up from the current YAML and start the simulation. Returns immediately.", {}),
113 relay("pause_run", "Pause or continue the running simulation.", {}),
114 relay("stop_run", "Stop the running simulation, keeping the audio generated so far.", {}),
115 relay("play_audio", "Play the generated sound in the browser from a time in seconds.", { from_seconds: z.number().min(0).default(0) }),
116 relay("set_signal", "Choose which listener signal the spectrogram and playback use.", { signal: z.enum(["unbaffled", "rayleigh"]) }),
117 ],
118 });
119 }
121 send(obj) { if (this.ws.readyState === this.ws.OPEN) this.ws.send(JSON.stringify(obj)); }
123 /** Ask the page to run a tool and wait for its answer. */
124 callBrowser(name, args) {
125 const id = this.nextId++;
126 this.send({ type: "tool_call", id, name, args });
127 return new Promise((resolve) => {
128 const timer = setTimeout(() => { this.pending.delete(id); resolve({ content: [{ type: "text", text: "the page did not answer within 120 s" }], isError: true }); }, 120000);
129 this.pending.set(id, (result) => { clearTimeout(timer); resolve(result); });
130 });
131 }
133 onMessage(raw) {
134 let msg;
135 try { msg = JSON.parse(raw); } catch { return; }
136 if (msg.type === "tool_result") {
137 const cb = this.pending.get(msg.id);
138 if (cb) { this.pending.delete(msg.id); cb({ content: [{ type: "text", text: typeof msg.text === "string" ? msg.text : JSON.stringify(msg.text) }], isError: !!msg.isError }); }
139 } else if (msg.type === "user" && typeof msg.text === "string" && msg.text.trim()) {
140 if (this.active) this.queue.push(msg.text); else void this.run(msg.text);
141 } else if (msg.type === "interrupt") {
142 this.queue = []; void this.active?.interrupt();
143 } else if (msg.type === "reset") {
144 this.queue = []; void this.active?.interrupt(); this.sessionId = null; this.send({ type: "reset" });
145 }
146 }
148 async run(text) {
149 const q = query({
150 prompt: text,
151 options: {
152 cwd: root,
153 resume: this.sessionId ?? undefined,
154 includePartialMessages: true,
155 systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_APPEND },
156 mcpServers: { cymsimweb: this.server },
157 tools: ["Read", "Glob", "Grep", "WebFetch"],
158 allowedTools: ["Read", "Glob", "Grep", "WebFetch", "mcp__cymsimweb__*"],
159 permissionMode: "dontAsk",
160 maxTurns: 40,
161 ...(MODEL ? { model: MODEL } : {}),
162 },
163 });
164 this.active = q;
165 this.send({ type: "status", state: "working" });
166 try {
167 for await (const m of q) {
168 if (m.type === "system" && m.subtype === "init") { this.sessionId = m.session_id; this.send({ type: "init", session_id: m.session_id, model: m.model }); }
169 else if (m.type === "stream_event") {
170 const e = m.event;
171 if (e.type === "content_block_delta" && e.delta?.type === "text_delta" && m.parent_tool_use_id === null) this.send({ type: "delta", text: e.delta.text });
172 else if (e.type === "content_block_start" && e.content_block?.type === "text" && m.parent_tool_use_id === null) this.send({ type: "block_start" });
173 } else if (m.type === "assistant") {
174 for (const block of m.message.content) {
175 if (block.type === "tool_use") this.send({ type: "tool_use", name: block.name.replace(/^mcp__cymsimweb__/, ""), input: summarize(block.input) });
176 }
177 } else if (m.type === "result") {
178 this.sessionId = m.session_id;
179 this.send({ type: "result", ok: m.subtype === "success", error: m.subtype === "success" ? undefined : m.subtype, cost: m.total_cost_usd, turns: m.num_turns });
180 }
181 }
182 } catch (err) {
183 this.send({ type: "error", message: String(err?.message ?? err) });
184 } finally {
185 this.active = null;
186 this.send({ type: "status", state: "idle" });
187 const next = this.queue.shift();
188 if (next) void this.run(next);
189 }
190 }
191}
193function summarize(input) {
194 const s = JSON.stringify(input ?? {});
195 return s.length > 160 ? s.slice(0, 157) + "..." : s;
196}
198// ---------------------------------------------------------------- static site + WebSocket
199const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".wasm": "application/wasm", ".map": "application/json" };
200const dist = path.join(root, "dist");
201const server = http.createServer((req, res) => {
202 const url = new URL(req.url ?? "/", "http://localhost");
203 let file = path.normalize(path.join(dist, decodeURIComponent(url.pathname)));
204 if (!file.startsWith(dist)) { res.writeHead(403); res.end(); return; }
205 if (url.pathname === "/" || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(dist, "index.html");
206 if (!fs.existsSync(file)) { res.writeHead(404, { "content-type": "text/plain" }); res.end("dist/ not found: run `npm run build` first, or use the deployed site with this bridge"); return; }
207 res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "application/octet-stream" });
208 fs.createReadStream(file).pipe(res);
209});
210const wss = new WebSocketServer({ server, verifyClient: ({ origin }) => isAllowedOrigin(origin) });
211wss.on("connection", (ws, req) => {
212 const session = new Session(ws);
213 console.log(`[bridge] page connected from ${req.headers.origin ?? "no origin"}`);
214 ws.send(JSON.stringify({ type: "hello", cwd: root, model: MODEL ?? "default", version: 1 }));
215 ws.on("message", (data) => session.onMessage(data.toString()));
216 ws.on("close", () => { void session.active?.interrupt(); console.log("[bridge] page disconnected"); });
217});
218server.listen(PORT, "127.0.0.1", () => {
219 console.log(`[bridge] Claude Code bridge for cymsimweb on ws://localhost:${PORT} (site: http://localhost:${PORT}, model: ${MODEL ?? "Claude Code default"})`);
220 console.log(`[bridge] accepting pages from localhost and ${[...ALLOWED_ORIGINS].join(", ")}`);
221});