diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 72d1999..431f90e 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -25,7 +25,8 @@ milestones = [ { name = "v1.0.0 — Stable Release", completion = 100 }, { name = "Phase 0 — Scrub baseline (V-lang removed, docs honest)", completion = 100, date = "2026-04-16" }, { name = "Phase 1 — Audio dependable (Opus honest, jitter sync, comfort noise, REMB, Avow chain)", completion = 0 }, - { name = "Phase 2 — LLM real (provider, circuit breaker, fixed parse_frame, NimblePool wired)", completion = 0 }, + { name = "Phase 2 — P2P AI channel dependable (burble-ai-bridge fixes, round-trip tests, docs) — CRITICAL PATH for family/pair-programming use case", completion = 30 }, + { name = "Phase 2b — server-side Burble.LLM (provider, circuit breaker, fixed parse_frame, NimblePool wired) — SECONDARY, not required for family use case", completion = 0 }, { name = "Phase 3 — RTSP + signaling + text + AffineScript client start", completion = 0 }, { name = "Phase 4 — PTP hardware clock via Zig NIF, phc2sys supervisor, multi-node align", completion = 0 }, { name = "Phase 5 — ReScript -> AffineScript completion", completion = 0 } @@ -47,13 +48,24 @@ resolved-2026-04-16 = [ ] [critical-next-actions] +phase-2-p2p-ai-bridge = [ + "DONE 2026-04-16: Opus honest-demotion (commit 179fa34)", + "DONE 2026-04-16: AI bridge receive-leg bug fix (dead setupAIChannelWithBridge replaced with inline forwarding in setupAIChannel)", + "DONE 2026-04-16: Bridge heartbeat + robust wsClient assign + env-var port", + "DONE 2026-04-16: Bridge UI status indicator (green/amber/grey dot)", + "DONE 2026-04-16: Deno round-trip test (POST /send on A -> GET /recv on B)", + "DONE 2026-04-16: CLAUDE.md troubleshooting section", + "NEXT: multi-message ordering test (bursts of 100 messages each way, no drops)", + "NEXT: reconnect-resume test (drop bridge WS mid-session, verify queue not lost)", + "NEXT: documentation for the Claude-to-Claude protocol patterns (task/result/chat shapes)" +] phase-1-audio = [ - "Decide Opus strategy: honest-demotion vs libopus link", - "Validate TFLite neural model or gate behind feature flag", - "Wire RTP-timestamp jitter sync across peers (precursor to PTP phase)", - "Server-side comfort noise injection on RX silence", - "REMB bitrate adaptation feedback loop", - "Replace Avow stub with hash-chain audit log + non-circularity property test" + "DONE 2026-04-16: Opus honest contract (opus_transcode returns :not_implemented)", + "NEXT: Validate TFLite neural model or gate behind feature flag", + "NEXT: Wire RTP-timestamp jitter sync across peers (precursor to PTP phase)", + "NEXT: Server-side comfort noise injection on RX silence", + "NEXT: REMB bitrate adaptation feedback loop", + "NEXT: Replace Avow stub with hash-chain audit log + non-circularity property test" ] [maintenance-status] diff --git a/CLAUDE.md b/CLAUDE.md index fc55881..33c0289 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,16 +6,17 @@ Burble is a P2P voice chat with an AI data channel. When two people connect via ## Quick start (receiver side) -You've been sent a room code. Do this: +You've been sent a room code. Do this **in order** — the bridge must start before +the page so the page's auto-retry can pick it up: ```bash git clone https://github.com/hyperpolymath/burble cd burble -# Start the AI bridge (runs in background) -deno run --allow-net client/web/burble-ai-bridge.js & +# 1. Start the AI bridge (leaves it running in the background) +deno run --allow-net --allow-env client/web/burble-ai-bridge.js & -# Open the voice client in your browser +# 2. Open the voice client in your browser xdg-open client/web/p2p-voice.html ``` @@ -26,20 +27,39 @@ In the browser: 4. Click **Generate Reply** 5. Copy the reply code and send it back -Once connected, the AI bridge auto-links. Test it: +Once connected, the "AI Channel" card shows a green dot next to "bridge online" — +that confirms the page has reached your local Claude. Test the path: ```bash # Check bridge is connected curl http://localhost:6474/status +# {"connected":true,"queued":0,"port":6474,...} # Send a message to the other Claude curl -X POST http://localhost:6474/send -H "Content-Type: application/json" \ -d '{"type":"hello","from":"receiver"}' -# Poll for messages from the other side +# Poll for messages from the other side (drains the queue) curl http://localhost:6474/recv ``` +### Troubleshooting the AI channel + +- **`/send` returns `{"ok":false,"error":"not connected"}`** — the page isn't + connected to the bridge. Reload `p2p-voice.html` or check that the bridge + process is still running (`lsof -i :6474`). +- **`/recv` always empty even though the other side sends** — this was a real + bug up to 2026-04-16 (a dead `setupAIChannelWithBridge` function was never + called, so the remote-to-local leg didn't forward). Fix landed in the + commit that replaced it with inline bridge-forwarding. If the symptom + recurs, check the browser console for `[Burble AI] Bridge forward failed`. +- **Bridge status dot stays grey on the page** — the page retries the bridge + every 5-10 s. If the dot never goes green, the bridge isn't listening on + `ws://127.0.0.1:6475`. +- **Need two bridges on one machine** (testing/dev): set + `BURBLE_AI_BRIDGE_PORT=7474 deno run …` — both HTTP and WS ports shift + together (`7474` + `7475`). + ## Claude-to-Claude protocol Messages are JSON over WebRTC DataChannel. No schema enforced — any valid JSON works. Suggested message types: diff --git a/client/web/burble-ai-bridge.js b/client/web/burble-ai-bridge.js index fee12ab..6129ba7 100644 --- a/client/web/burble-ai-bridge.js +++ b/client/web/burble-ai-bridge.js @@ -14,7 +14,9 @@ // into the page. Messages flow: // curl POST /send → bridge → WS → page → DataChannel → remote page → WS → bridge → curl GET /recv -const PORT = 6474; +// Port can be overridden by env var so tests can run two bridges side-by-side. +// Defaults to 6474 (HTTP) + 6475 (WebSocket relay) for normal use. +const PORT = parseInt(Deno.env.get("BURBLE_AI_BRIDGE_PORT") || "6474"); const messageQueue = []; let wsClient = null; @@ -116,28 +118,67 @@ Deno.serve({ port: PORT, hostname: "127.0.0.1" }, async (req) => { return new Response("Burble AI Bridge\n\nPOST /send — send JSON to remote peer\nGET /recv — poll received messages\nGET /status — connection status\nGET /health — health check\n", { status: 200 }); }); -// WebSocket server for p2p-voice.html to connect to +// Heartbeat parameters. The bridge pings every HEARTBEAT_INTERVAL_MS; if no +// pong arrives within HEARTBEAT_TIMEOUT_MS the socket is considered dead. +// Silent network drops (laptop sleep, wifi switch) otherwise leave wsClient +// stuck at readyState=1 until the next send fails. +const HEARTBEAT_INTERVAL_MS = 15_000; +const HEARTBEAT_TIMEOUT_MS = 5_000; + +// WebSocket server for p2p-voice.html to connect to. Deno.serve({ port: PORT + 1, hostname: "127.0.0.1" }, (req) => { if (req.headers.get("upgrade") !== "websocket") { return new Response("WebSocket only", { status: 400 }); } const { socket, response } = Deno.upgradeWebSocket(req); + // Assign wsClient IMMEDIATELY after upgrade rather than inside onopen. + // Under Deno 2.x upgraded sockets are frequently already in readyState=1 + // by the time we reach this line, meaning the `open` event may not fire + // and wsClient would otherwise stay null indefinitely. + wsClient = socket; + + let pongTimer = null; + let heartbeatTimer = null; + + const stopHeartbeat = () => { + if (heartbeatTimer !== null) { clearInterval(heartbeatTimer); heartbeatTimer = null; } + if (pongTimer !== null) { clearTimeout(pongTimer); pongTimer = null; } + }; + + const sendPing = () => { + if (socket.readyState !== 1) return; + try { + socket.send(JSON.stringify({ type: "ping", ts: Date.now() })); + pongTimer = setTimeout(() => { + console.warn("[Burble AI Bridge] Pong timeout — closing stale socket"); + try { socket.close(1011, "heartbeat timeout"); } catch (_) {} + }, HEARTBEAT_TIMEOUT_MS); + } catch (e) { + console.warn("[Burble AI Bridge] Ping send failed:", e.message); + } + }; + socket.onopen = () => { - wsClient = socket; console.log("[Burble AI Bridge] Page connected via WebSocket"); + heartbeatTimer = setInterval(sendPing, HEARTBEAT_INTERVAL_MS); }; socket.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); + if (msg.type === "pong") { + // Heartbeat reply — cancel the timeout. + if (pongTimer !== null) { clearTimeout(pongTimer); pongTimer = null; } + return; + } if (msg.type === "received") { // Message from remote peer, queue for Claude to poll. // SECURITY FIX: Enforce bounded queue size (proven SafeQueue principle). // Discard oldest messages when at capacity to prevent memory exhaustion // if the consumer stops polling /recv. if (messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) { - const discarded = messageQueue.shift(); + messageQueue.shift(); console.warn( `[Burble AI Bridge] Queue full (${MAX_MESSAGE_QUEUE_SIZE}), discarded oldest message` ); @@ -151,10 +192,14 @@ Deno.serve({ port: PORT + 1, hostname: "127.0.0.1" }, (req) => { }; socket.onclose = () => { - wsClient = null; + stopHeartbeat(); + if (wsClient === socket) wsClient = null; console.log("[Burble AI Bridge] Page disconnected"); }; + // Start the heartbeat even if onopen never fires (see comment above). + heartbeatTimer = setInterval(sendPing, HEARTBEAT_INTERVAL_MS); + return response; }); diff --git a/client/web/p2p-voice.html b/client/web/p2p-voice.html index a1b21b9..8e3a200 100644 --- a/client/web/p2p-voice.html +++ b/client/web/p2p-voice.html @@ -114,7 +114,12 @@

Connected