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
This commit is contained in:
jay 2026-05-15 03:42:53 +02:00
parent 15772558dd
commit befb1fc98b
3 changed files with 343 additions and 0 deletions

View File

@ -24,6 +24,7 @@ import {
} from "./config.js"; } from "./config.js";
import { type RawMessage, serializeMessage } from "./messages.js"; import { type RawMessage, serializeMessage } from "./messages.js";
import { type RemoteServer, startServer } from "./server.js"; import { type RemoteServer, startServer } from "./server.js";
import { registerSpikeCommand } from "./spike.js";
// ── Extension entry point ──────────────────────────────────────────────────── // ── Extension entry point ────────────────────────────────────────────────────
@ -33,6 +34,9 @@ const QRCode = _require("qrcode") as {
}; };
export default function remoteControl(pi: ExtensionAPI) { export default function remoteControl(pi: ExtensionAPI) {
// Register spike command for Phase 0 PoC
registerSpikeCommand(pi);
let server: RemoteServer | undefined; let server: RemoteServer | undefined;
let pendingSyncTimer: ReturnType<typeof setTimeout> | undefined; let pendingSyncTimer: ReturnType<typeof setTimeout> | undefined;

View File

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Phase 0 Spike Client</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" />
<style>
body {
margin: 0;
padding: 20px;
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
#header {
margin-bottom: 20px;
}
#status {
padding: 10px;
background: #252526;
border-radius: 4px;
margin-bottom: 10px;
}
#terminal {
background: #000;
border: 1px solid #3c3c3c;
border-radius: 4px;
}
.connected { color: #4ec9b0; }
.disconnected { color: #f48771; }
.info { color: #6a9955; }
</style>
</head>
<body>
<div id="header">
<h1>Phase 0 Spike — tmux Stream Client</h1>
<div id="status">
Status: <span id="status-text" class="disconnected">Not connected</span>
</div>
<div id="stats" style="font-size: 12px; color: #858585;">
Frames: <span id="frame-count">0</span> |
Bytes: <span id="byte-count">0</span> |
Latency: <span id="latency"></span>
</div>
</div>
<div id="terminal"></div>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script>
// Initialize xterm.js
const term = new Terminal({
cols: 120,
rows: 40,
cursorBlink: true,
theme: {
background: '#000000',
foreground: '#ffffff',
},
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
// Stats tracking
let frameCount = 0;
let byteCount = 0;
let lastFrameTime = Date.now();
function updateStats(bytes) {
frameCount++;
byteCount += bytes;
const now = Date.now();
const latency = now - lastFrameTime;
lastFrameTime = now;
document.getElementById('frame-count').textContent = frameCount;
document.getElementById('byte-count').textContent = byteCount.toLocaleString();
document.getElementById('latency').textContent = `${latency}ms`;
}
function setStatus(text, className) {
const statusEl = document.getElementById('status-text');
statusEl.textContent = text;
statusEl.className = className;
}
// Connect to WebSocket
const wsUrl = 'ws://127.0.0.1:7799/spike';
setStatus('Connecting...', 'info');
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
setStatus('Connected', 'connected');
console.log('[spike-client] Connected to', wsUrl);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data);
term.write(bytes);
updateStats(bytes.length);
}
};
ws.onerror = (error) => {
console.error('[spike-client] WebSocket error:', error);
setStatus('Error', 'disconnected');
};
ws.onclose = () => {
setStatus('Disconnected', 'disconnected');
console.log('[spike-client] Connection closed');
};
// Handle window resize
window.addEventListener('resize', () => {
fitAddon.fit();
});
</script>
</body>
</html>

View File

@ -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<any>();
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<void> {
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);
},
});
}