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:
parent
15772558dd
commit
befb1fc98b
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue