From befb1fc98b3c2ffbf5b54ea63cdec0f5b2719537 Mon Sep 17 00:00:00 2001 From: jay Date: Fri, 15 May 2026 03:42:53 +0200 Subject: [PATCH] feat: Phase 0 spike - tmux stream PoC implementation - Add spike.ts with tmux session management and WebSocket streaming - Register /spike command in extension - Add HTML test client with xterm.js - Uses FIFO for pipe-pane streaming - Single reader broadcasts to all WebSocket clients --- extensions/remote-control/index.ts | 4 + extensions/remote-control/spike-client.html | 131 ++++++++++++ extensions/remote-control/spike.ts | 208 ++++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 extensions/remote-control/spike-client.html create mode 100644 extensions/remote-control/spike.ts diff --git a/extensions/remote-control/index.ts b/extensions/remote-control/index.ts index b4e074b..afed32e 100644 --- a/extensions/remote-control/index.ts +++ b/extensions/remote-control/index.ts @@ -24,6 +24,7 @@ import { } from "./config.js"; import { type RawMessage, serializeMessage } from "./messages.js"; import { type RemoteServer, startServer } from "./server.js"; +import { registerSpikeCommand } from "./spike.js"; // ── Extension entry point ──────────────────────────────────────────────────── @@ -33,6 +34,9 @@ const QRCode = _require("qrcode") as { }; export default function remoteControl(pi: ExtensionAPI) { + // Register spike command for Phase 0 PoC + registerSpikeCommand(pi); + let server: RemoteServer | undefined; let pendingSyncTimer: ReturnType | undefined; diff --git a/extensions/remote-control/spike-client.html b/extensions/remote-control/spike-client.html new file mode 100644 index 0000000..529fa9e --- /dev/null +++ b/extensions/remote-control/spike-client.html @@ -0,0 +1,131 @@ + + + + + + Phase 0 Spike Client + + + + + + +
+ + + + + + + diff --git a/extensions/remote-control/spike.ts b/extensions/remote-control/spike.ts new file mode 100644 index 0000000..bed2661 --- /dev/null +++ b/extensions/remote-control/spike.ts @@ -0,0 +1,208 @@ +/** + * 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 { spawn, execSync } from "node:child_process"; +import { WebSocketServer } from "ws"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +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 }; +} + +/** + * Attach to the tmux session in the current terminal + */ +function attachToSession(sessionName: string): void { + console.log(`[spike] Attaching to tmux session: ${sessionName}`); + console.log(`[spike] To detach: Ctrl+B, then D`); + console.log(`[spike] WebSocket available at: ws://127.0.0.1:${WS_PORT}/spike`); + console.log(""); + + // Spawn tmux attach in the foreground + // This will take over the terminal until the user detaches + const attach = spawn("tmux", ["attach", "-t", sessionName], { + stdio: "inherit", + }); + + attach.on("exit", (code) => { + console.log(`\n[spike] Detached from session (exit code: ${code})`); + }); +} + +/** + * 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(_ctx: ExtensionContext): Promise { + console.log("=== Phase 0 Spike: tmux Stream PoC ===\n"); + + let cleanupFn: (() => void) | null = null; + + // Setup cleanup handlers + process.on("SIGINT", () => cleanup(cleanupFn)); + process.on("SIGTERM", () => cleanup(cleanupFn)); + + 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: Attach to session + attachToSession(SPIKE_SESSION); + + } catch (err) { + console.error("[spike] Error:", err); + cleanup(cleanupFn); + } +} + +/** + * Register the spike command with pi + */ +export function registerSpikeCommand(pi: ExtensionAPI): void { + pi.registerCommand("spike", { + description: "Phase 0 Spike: Start tmux stream PoC (ws://127.0.0.1:7799/spike)", + handler: async (_args, ctx) => { + await runSpike(ctx); + }, + }); +}