#!/usr/bin/env node // Local bridge between the cymsimweb page and a Claude Code session. // // The page runs in a browser and cannot start Claude Code itself, so this process runs on the // user's machine: it drives the Claude Agent SDK (which uses the local `claude` login, i.e. the // user's Claude Code subscription), and relays the tools Claude calls to the page over a // WebSocket, where they read the app's state, replace the YAML, start and stop runs, and play // the audio. It also serves the built site from dist/ so the page can be used from the same // origin. Listens on 127.0.0.1 only and accepts WebSocket connections only from known origins. // // npm run bridge # ws://localhost:8765, site at http://localhost:8765 // node bridge/server.mjs --port 9000 --origin https://my.site --model claude-sonnet-5 import fs from "node:fs"; import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { WebSocketServer } from "ws"; import { z } from "zod"; import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk"; import { defaultConfig } from "../src/config/schema.ts"; import { PRESETS } from "../src/config/presets.ts"; import { configToYaml } from "../src/config/yaml.ts"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); // ---------------------------------------------------------------- arguments const argv = process.argv.slice(2); const opt = (name, def) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : def; }; const PORT = Number(opt("--port", process.env.CYMSIMWEB_BRIDGE_PORT ?? 8765)); const MODEL = opt("--model", process.env.CYMSIMWEB_BRIDGE_MODEL ?? undefined); const EXTRA_ORIGINS = argv.flatMap((a, i) => (a === "--origin" && argv[i + 1] ? [argv[i + 1]] : [])); const ALLOWED_ORIGINS = new Set([ "https://cymsimweb--proof-of-concept.vault1-sites.magland.org", "https://vault1.magland.org", ...EXTRA_ORIGINS, ]); const isAllowedOrigin = (origin) => { if (!origin) return true; // non-browser clients (tests); browsers always send Origin if (ALLOWED_ORIGINS.has(origin)) return true; return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin); }; // ---------------------------------------------------------------- what Claude is told about the app const read = (f) => { try { return fs.readFileSync(path.join(root, f), "utf8"); } catch { return ""; } }; const SYSTEM_APPEND = ` # cymsimweb assistant You are embedded in cymsimweb, a browser application that simulates a struck cymbal with a nonlinear finite-element shell model on WebGPU (the companion of the cymsim Python package). The user talks to you in a chat panel beside the app. You can see the app's state and change it through the mcp__cymsimweb__* tools; the tools run inside the page. Your working directory is the app's source repository: read README.md, THEORY.md, src/config/schema.ts (the configuration fields and defaults), src/model/*.ts (geometry, mesh, elements, shell, exciters, radiation) and src/gpu/shaders.ts when a question needs the details. The equations are in the Python package's docs/model.md, which you may fetch at https://vault1.magland.org/proof-of-concept/cymsim/blob/main/docs/model.md . Do not modify files: you have no write access, and the app is changed only through the tools. ## Tools - get_app_state: the current YAML, its validation issues, the run state and progress, the set-up summary of the last run, the latest energies and hit results, and a spectrogram summary (per-octave band onsets and levels, spectral centroid over time). Call it before answering questions about what is going on, and after a run to interpret the result. - set_yaml: replace the configuration in the editor with a complete YAML document. Omitted fields take the defaults, so a short document is fine, but write every field the user asked about explicitly. The tool returns validation issues; fix them and call again if there are any. Units are SI. - select_preset: load one of the built-in configurations. - start_run / pause_run / stop_run: control the simulation. A run takes tens of seconds to minutes depending on the GPU; start_run returns immediately, so check progress with get_app_state. - play_audio: play the generated sound from a time; set_signal chooses the unbaffled or the baffled (Rayleigh) listener signal for the spectrogram and playback. - play_movie: replay the captured displacement of the plate, slowed down by a factor (50 is a good start; the lowest modes of an 18 inch crash are a few hundred Hz, so x200 to x500 shows individual cycles). set_movie_fps sets how many frames per second of simulated time the next run captures (2000 by default, 0 disables it); the frames are what play_movie replays. - set_spectrogram_axis: linear (the default) or log frequency axis, and the upper edge in Hz. ## Guidance When the user describes a cymbal or a stroke in words, translate it into the configuration: size sets profile.radius (18 inch crash: 0.2286 m; 20 inch ride: 0.254 m), weight sets the thicknesses (thin crash 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), bell size sets bell_height and bell_shoulder_radius, a stick shoulder hit is a "stick" exciter near the rim (r about 0.85 R) and a soft mallet stroke a "mallet" exciter on the bow (r about 0.6 R). Loudness is the exciter velocity (stick 4-9 m/s, mallet 3-8 m/s). Losses: sigma0 sets the decay of the low modes (T60 about 6.9/sigma0 s), sigma1 the loss growing with frequency. Numerics: element_size 0.006 m is the default resolution and needs a discrete GPU; 0.01-0.02 m for quick previews; duration is the simulated seconds. Several hits go in the hits list, each with its own time, position, velocity and exciter. After setting a configuration, say briefly what you set and offer to start the run (or start it if the user asked for that). When a run finishes, get_app_state and describe the result: peak contact force, contact time, how nonlinear (max|w|/h), and whether the band onsets show the upward cascade (high bands rising later than low ones). Keep replies short and concrete. Do not paste the whole YAML into the chat unless asked; the editor shows it. ## The YAML schema (every field with its default) \`\`\`yaml ${configToYaml(defaultConfig())}\`\`\` ## Presets ${PRESETS.map((p) => `- ${p.id}: ${p.name}`).join("\n")} ## README.md ${read("README.md")} `; // ---------------------------------------------------------------- one chat session per socket class Session { constructor(ws) { this.ws = ws; this.sessionId = null; this.active = null; this.queue = []; this.pending = new Map(); this.nextId = 1; const relay = (name, description, schema, extras) => tool(name, description, schema, async (args) => this.callBrowser(name, args), { alwaysLoad: true, ...extras }); this.server = createSdkMcpServer({ name: "cymsimweb", version: "1.0.0", tools: [ 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 } }), 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") }), relay("select_preset", "Load a built-in preset into the editor.", { id: z.enum(PRESETS.map((p) => p.id)) }), relay("start_run", "Build the set-up from the current YAML and start the simulation. Returns immediately.", {}), relay("pause_run", "Pause or continue the running simulation.", {}), relay("stop_run", "Stop the running simulation, keeping the audio generated so far.", {}), relay("play_audio", "Play the generated sound in the browser from a time in seconds.", { from_seconds: z.number().min(0).default(0) }), relay("set_signal", "Choose which listener signal the spectrogram and playback use.", { signal: z.enum(["unbaffled", "rayleigh"]) }), relay("play_movie", "Replay the captured displacement of the cymbal in the plate view, slowed down.", { slowdown: z.number().min(2).max(10000).default(50).describe("how many times slower than reality"), from_seconds: z.number().min(0).default(0).describe("simulated time to start from"), }), relay("set_spectrogram_axis", "Set the spectrogram's frequency axis.", { scale: z.enum(["linear", "log"]), max_frequency_hz: z.number().min(500).max(96000).optional().describe("upper edge; omit for the Nyquist frequency"), }), relay("set_movie_fps", "Set how many frames of the displacement movie are captured per second of simulated time (0 disables capture). Applies to the next run.", { fps: z.number().min(0).max(44100) }), ], }); } send(obj) { if (this.ws.readyState === this.ws.OPEN) this.ws.send(JSON.stringify(obj)); } /** Ask the page to run a tool and wait for its answer. */ callBrowser(name, args) { const id = this.nextId++; this.send({ type: "tool_call", id, name, args }); return new Promise((resolve) => { const timer = setTimeout(() => { this.pending.delete(id); resolve({ content: [{ type: "text", text: "the page did not answer within 120 s" }], isError: true }); }, 120000); this.pending.set(id, (result) => { clearTimeout(timer); resolve(result); }); }); } onMessage(raw) { let msg; try { msg = JSON.parse(raw); } catch { return; } if (msg.type === "tool_result") { const cb = this.pending.get(msg.id); 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 }); } } else if (msg.type === "user" && typeof msg.text === "string" && msg.text.trim()) { if (this.active) this.queue.push(msg.text); else void this.run(msg.text); } else if (msg.type === "interrupt") { this.queue = []; void this.active?.interrupt(); } else if (msg.type === "reset") { this.queue = []; void this.active?.interrupt(); this.sessionId = null; this.send({ type: "reset" }); } } async run(text) { const q = query({ prompt: text, options: { cwd: root, resume: this.sessionId ?? undefined, includePartialMessages: true, systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_APPEND }, mcpServers: { cymsimweb: this.server }, tools: ["Read", "Glob", "Grep", "WebFetch"], allowedTools: ["Read", "Glob", "Grep", "WebFetch", "mcp__cymsimweb__*"], permissionMode: "dontAsk", maxTurns: 40, ...(MODEL ? { model: MODEL } : {}), }, }); this.active = q; this.send({ type: "status", state: "working" }); try { for await (const m of q) { if (m.type === "system" && m.subtype === "init") { this.sessionId = m.session_id; this.send({ type: "init", session_id: m.session_id, model: m.model }); } else if (m.type === "stream_event") { const e = m.event; 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 }); else if (e.type === "content_block_start" && e.content_block?.type === "text" && m.parent_tool_use_id === null) this.send({ type: "block_start" }); } else if (m.type === "assistant") { for (const block of m.message.content) { if (block.type === "tool_use") this.send({ type: "tool_use", name: block.name.replace(/^mcp__cymsimweb__/, ""), input: summarize(block.input) }); } } else if (m.type === "result") { this.sessionId = m.session_id; this.send({ type: "result", ok: m.subtype === "success", error: m.subtype === "success" ? undefined : m.subtype, cost: m.total_cost_usd, turns: m.num_turns }); } } } catch (err) { this.send({ type: "error", message: String(err?.message ?? err) }); } finally { this.active = null; this.send({ type: "status", state: "idle" }); const next = this.queue.shift(); if (next) void this.run(next); } } } function summarize(input) { const s = JSON.stringify(input ?? {}); return s.length > 160 ? s.slice(0, 157) + "..." : s; } // ---------------------------------------------------------------- static site + WebSocket const 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" }; const dist = path.join(root, "dist"); const server = http.createServer((req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); let file = path.normalize(path.join(dist, decodeURIComponent(url.pathname))); if (!file.startsWith(dist)) { res.writeHead(403); res.end(); return; } if (url.pathname === "/" || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(dist, "index.html"); 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; } res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "application/octet-stream" }); fs.createReadStream(file).pipe(res); }); const wss = new WebSocketServer({ server, verifyClient: ({ origin }) => isAllowedOrigin(origin) }); wss.on("connection", (ws, req) => { const session = new Session(ws); console.log(`[bridge] page connected from ${req.headers.origin ?? "no origin"}`); ws.send(JSON.stringify({ type: "hello", cwd: root, model: MODEL ?? "default", version: 1 })); ws.on("message", (data) => session.onMessage(data.toString())); ws.on("close", () => { void session.active?.interrupt(); console.log("[bridge] page disconnected"); }); }); server.listen(PORT, "127.0.0.1", () => { console.log(`[bridge] Claude Code bridge for cymsimweb on ws://localhost:${PORT} (site: http://localhost:${PORT}, model: ${MODEL ?? "Claude Code default"})`); console.log(`[bridge] accepting pages from localhost and ${[...ALLOWED_ORIGINS].join(", ")}`); });