/ 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  });