/** * spike.ts — Phase 0 Spike: tmux Stream PoC * * Spawns a tmux session running pi, pipes the output via pipe-pane to a FIFO, * and streams it over WebSocket. * * This is throwaway PoC code to verify the foundational assumption: * - pi runs cleanly in tmux * - pipe-pane captures ANSI output accurately * - WebSocket streaming has acceptable latency * - SSH attach and WS stream stay in sync */ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import { execSync } from "node:child_process"; import { WebSocketServer } from "ws"; const SPIKE_SESSION = "pi-spike"; const WS_PORT = 7799; const FIFO_PATH = path.join(os.tmpdir(), `${SPIKE_SESSION}.fifo`); /** * Check if a tmux session exists */ function sessionExists(sessionName: string): boolean { try { execSync(`tmux has-session -t ${sessionName} 2>/dev/null`); return true; } catch { return false; } } /** * Create a new tmux session running pi */ function createSession(sessionName: string): void { console.log(`[spike] Creating tmux session: ${sessionName}`); execSync(`tmux new-session -d -s ${sessionName} -x 120 -y 40 'pi'`); } /** * Setup pipe-pane to stream to a FIFO */ function setupPipePane(sessionName: string, fifoPath: string): void { // Remove existing FIFO if present if (fs.existsSync(fifoPath)) { fs.unlinkSync(fifoPath); } // Create new FIFO execSync(`mkfifo ${fifoPath}`); console.log(`[spike] Created FIFO: ${fifoPath}`); // Setup pipe-pane execSync(`tmux pipe-pane -t ${sessionName} -o "cat > ${fifoPath}"`); console.log(`[spike] Attached pipe-pane to session ${sessionName}`); } /** * Start the WebSocket server and stream from FIFO * Uses a single FIFO reader that broadcasts to all connected clients */ function startWebSocketServer(fifoPath: string): { wss: WebSocketServer, cleanup: () => void } { const wss = new WebSocketServer({ port: WS_PORT, host: "127.0.0.1" }); const clients = new Set(); console.log(`[spike] WebSocket server listening on ws://127.0.0.1:${WS_PORT}/spike`); // Single FIFO reader that broadcasts to all clients const stream = fs.createReadStream(fifoPath); stream.on("data", (chunk: Buffer) => { for (const ws of clients) { if (ws.readyState === 1) { // WebSocket.OPEN ws.send(chunk, { binary: true }); } } }); stream.on("error", (err) => { console.error(`[spike] FIFO stream error:`, err); }); stream.on("end", () => { console.log("[spike] FIFO stream ended"); }); wss.on("connection", (ws, req) => { const clientAddr = req.socket.remoteAddress; console.log(`[spike] Client connected: ${clientAddr}`); clients.add(ws); ws.on("close", () => { console.log(`[spike] Client disconnected: ${clientAddr}`); clients.delete(ws); }); ws.on("error", (err) => { console.error(`[spike] WebSocket error:`, err); }); }); const cleanup = () => { stream.destroy(); wss.close(); }; return { wss, cleanup }; } /** * Print instructions for connecting to the session */ function printInstructions(sessionName: string): void { console.log(""); console.log("=== Spike Server Running ==="); console.log(""); console.log("To attach to the tmux session (in another terminal):"); console.log(` tmux attach -t ${sessionName}`); console.log(""); console.log("WebSocket endpoint:"); console.log(` ws://127.0.0.1:${WS_PORT}/spike`); console.log(""); console.log("To test with the HTML client:"); const clientPath = path.join(path.dirname(new URL(import.meta.url).pathname), "spike-client.html"); console.log(` open ${clientPath}`); console.log(""); console.log("To stop: Ctrl+C in this terminal"); console.log(""); } /** * Cleanup function */ function cleanup(cleanupFn: (() => void) | null): void { console.log("\n[spike] Cleaning up..."); if (cleanupFn) { cleanupFn(); } // Remove FIFO if (fs.existsSync(FIFO_PATH)) { try { fs.unlinkSync(FIFO_PATH); console.log("[spike] Removed FIFO"); } catch (err) { console.error("[spike] Failed to remove FIFO:", err); } } console.log("[spike] Cleanup complete"); process.exit(0); } /** * Main spike entry point */ export async function runSpike(): Promise { console.log("=== Phase 0 Spike: tmux Stream PoC ===\n"); let cleanupFn: (() => void) | null = null; // Setup cleanup handlers const cleanupHandler = () => cleanup(cleanupFn); process.on("SIGINT", cleanupHandler); process.on("SIGTERM", cleanupHandler); try { // Step 1: Create or reuse tmux session if (sessionExists(SPIKE_SESSION)) { console.log(`[spike] Session ${SPIKE_SESSION} already exists, reusing it`); } else { createSession(SPIKE_SESSION); } // Step 2: Setup pipe-pane to FIFO setupPipePane(SPIKE_SESSION, FIFO_PATH); // Step 3: Start WebSocket server const server = startWebSocketServer(FIFO_PATH); cleanupFn = server.cleanup; // Give the server a moment to start await new Promise(resolve => setTimeout(resolve, 500)); // Step 4: Print instructions printInstructions(SPIKE_SESSION); // Keep the process alive // User can Ctrl+C to stop await new Promise(() => {}); // Never resolves } catch (err) { console.error("[spike] Error:", err); cleanup(cleanupFn); } } // Run if invoked directly if (import.meta.url === `file://${process.argv[1]}`) { runSpike(); }