/ web-terminal / server.mjs
server.mjs
1 /** 2 * Bob Web Terminal — shared Claude Code session over WebSocket. 3 * 4 * Single persistent pty session. All connected clients (phone, laptop, etc.) 5 * see the same output and can send input — like tmux shared mode. 6 * 7 * Usage: node server.mjs 8 * Env: PORT (default 10850), CLAUDE_DIR (default /home/rig/bob/haven) 9 */ 10 11 import { spawn } from "node-pty"; 12 import { readFileSync } from "fs"; 13 import { join, dirname } from "path"; 14 import { fileURLToPath } from "url"; 15 import { createServer } from "http"; 16 import { WebSocketServer } from "ws"; 17 18 const __dirname = dirname(fileURLToPath(import.meta.url)); 19 20 const PORT = parseInt(process.env.PORT || "10850"); 21 const CLAUDE_DIR = process.env.CLAUDE_DIR || "/home/rig/bob/haven"; 22 const CLAUDE_BIN = process.env.CLAUDE_BIN || "/home/rig/.bun/bin/claude"; 23 const SCROLLBACK_LIMIT = 100_000; // chars of scrollback to replay to new clients 24 25 const html = readFileSync(join(__dirname, "index.html"), "utf-8"); 26 27 // ── Shared session state ───────────────────────────────────────────── 28 let ptyProc = null; 29 let scrollback = ""; // recent output buffer for replaying to new clients 30 const clients = new Set(); 31 32 function spawnSession(cols, rows) { 33 if (ptyProc) return; // already running 34 35 ptyProc = spawn(CLAUDE_BIN, ["--dangerously-skip-permissions"], { 36 name: "xterm-256color", 37 cols, 38 rows, 39 cwd: CLAUDE_DIR, 40 env: { 41 ...process.env, 42 TERM: "xterm-256color", 43 COLORTERM: "truecolor", 44 LANG: "en_US.UTF-8", 45 PATH: `/home/rig/.bun/bin:/home/rig/.nix-profile/bin:${process.env.PATH}`, 46 HOME: process.env.HOME || "/home/rig", 47 }, 48 }); 49 50 console.log(`Session spawned (${cols}x${rows}), pid=${ptyProc.pid}`); 51 52 ptyProc.onData((data) => { 53 // Append to scrollback (trim if too large) 54 scrollback += data; 55 if (scrollback.length > SCROLLBACK_LIMIT) { 56 scrollback = scrollback.slice(-SCROLLBACK_LIMIT); 57 } 58 // Broadcast to all connected clients 59 for (const ws of clients) { 60 try { ws.send(data); } catch {} 61 } 62 }); 63 64 ptyProc.onExit(({ exitCode, signal }) => { 65 console.log(`Session ended (code=${exitCode}, signal=${signal})`); 66 const msg = `\r\n\x1b[1;33m[Session ended (code=${exitCode}) — refresh to start a new one]\x1b[0m\r\n`; 67 for (const ws of clients) { 68 try { ws.send(msg); } catch {} 69 } 70 ptyProc = null; 71 scrollback = ""; 72 }); 73 } 74 75 // ── HTTP server ────────────────────────────────────────────────────── 76 const httpServer = createServer((req, res) => { 77 if (req.url === "/health") { 78 res.writeHead(200, { "Content-Type": "application/json" }); 79 res.end(JSON.stringify({ 80 status: "ok", 81 session_alive: ptyProc !== null, 82 clients: clients.size, 83 claude_dir: CLAUDE_DIR, 84 })); 85 return; 86 } 87 res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); 88 res.end(html); 89 }); 90 91 // ── WebSocket server ───────────────────────────────────────────────── 92 const wss = new WebSocketServer({ server: httpServer, path: "/ws" }); 93 94 wss.on("connection", (ws) => { 95 const id = crypto.randomUUID().slice(0, 8); 96 clients.add(ws); 97 console.log(`[${id}] Client connected (${clients.size} total)`); 98 99 // Replay scrollback so new client sees current screen state 100 if (scrollback) { 101 ws.send(scrollback); 102 } 103 104 ws.on("message", (raw) => { 105 const msg = raw.toString(); 106 107 // JSON control messages 108 if (msg.startsWith("{")) { 109 try { 110 const ctrl = JSON.parse(msg); 111 if (ctrl.type === "resize") { 112 const cols = ctrl.cols || 80; 113 const rows = ctrl.rows || 24; 114 115 if (!ptyProc) { 116 spawnSession(cols, rows); 117 } else { 118 // Resize to the largest connected client 119 // (simple approach: just use the latest resize) 120 ptyProc.resize(cols, rows); 121 } 122 return; 123 } 124 } catch {} 125 } 126 127 // Raw terminal input → shared pty 128 if (ptyProc) { 129 ptyProc.write(msg); 130 } 131 }); 132 133 ws.on("close", () => { 134 clients.delete(ws); 135 console.log(`[${id}] Client disconnected (${clients.size} remaining)`); 136 // Don't kill the session when clients disconnect — keep it alive 137 }); 138 }); 139 140 httpServer.listen(PORT, () => { 141 console.log(`Bob Web Terminal listening on :${PORT}`); 142 console.log(` Claude dir: ${CLAUDE_DIR}`); 143 console.log(` Claude bin: ${CLAUDE_BIN}`); 144 console.log(` Mode: shared session (all clients see the same terminal)`); 145 });