#!/usr/bin/env ts-node /** * Phase 0.5 Spike — tmux Control Mode * * This is a throwaway PoC to evaluate tmux control mode as an alternative * to pipe-pane for streaming pi output. * * Usage: * npm run spike-cc * * Then in another terminal: * tmux attach -t pi-cc # Verify parallel attach works */ import { spawn, ChildProcess } from 'child_process'; import * as readline from 'readline'; const SESSION_NAME = 'pi-cc'; interface Stats { outputEvents: number; bytesReceived: number; otherEvents: number; startTime: number; } const stats: Stats = { outputEvents: 0, bytesReceived: 0, otherEvents: 0, startTime: Date.now(), }; /** * Decode tmux's octal-escaped output format. * Example: "hello\\012world" -> "hello\nworld" */ function decodeOctalEscapes(input: string): Buffer { const chunks: number[] = []; let i = 0; while (i < input.length) { if (input[i] === '\\' && i + 3 < input.length) { // Check if next 3 chars are octal digits const octalStr = input.substring(i + 1, i + 4); if (/^[0-7]{3}$/.test(octalStr)) { const charCode = parseInt(octalStr, 8); chunks.push(charCode); i += 4; continue; } } // Regular character chunks.push(input.charCodeAt(i)); i++; } return Buffer.from(chunks); } /** * Parse a control mode notification line. */ function parseNotification(line: string): { type: string; data: string } | null { if (!line.startsWith('%')) { return null; } const spaceIdx = line.indexOf(' '); if (spaceIdx === -1) { return { type: line.substring(1), data: '' }; } return { type: line.substring(1, spaceIdx), data: line.substring(spaceIdx + 1), }; } /** * Handle a %output notification. */ function handleOutputEvent(data: string): void { // Format: %output % const parts = data.split(' ', 2); if (parts.length < 2) { console.error('[spike-cc] Invalid %output format:', data); return; } const paneId = parts[0]; // e.g., "%1" const escapedValue = parts[1]; // Decode octal escapes and write to stdout const decoded = decodeOctalEscapes(escapedValue); stats.outputEvents++; stats.bytesReceived += decoded.length; // Write to stdout (this is our streaming output) process.stdout.write(decoded); } /** * Main control mode loop. */ function runControlMode(): void { console.error('[spike-cc] Starting tmux control mode spike...'); console.error('[spike-cc] Session name:', SESSION_NAME); console.error('[spike-cc] Creating detached session first...'); // Step 1: Create detached session with pi const createSession = spawn('tmux', [ 'new-session', '-d', '-s', SESSION_NAME, '-x', '120', '-y', '40', 'pi' ]); createSession.on('close', (code) => { if (code !== 0) { console.error('[spike-cc] Failed to create session, code:', code); console.error('[spike-cc] (This is OK if session already exists)'); } // Step 2: Attach in control mode console.error('[spike-cc] Attaching in control mode...'); console.error('[spike-cc] ---------------------------------------------'); console.error('[spike-cc] Pi output will stream below (raw bytes).'); console.error('[spike-cc] Stats will be written to stderr every 10s.'); console.error('[spike-cc] To attach normally: tmux attach -t', SESSION_NAME); console.error('[spike-cc] ---------------------------------------------'); const tmuxControl = spawn('tmux', ['-C', 'attach', '-t', SESSION_NAME], { stdio: ['pipe', 'pipe', 'pipe'], }); // Keep stdin open (don't send EOF) // This prevents tmux from thinking the control client disconnected // Read control mode output line by line const rl = readline.createInterface({ input: tmuxControl.stdout, crlfDelay: Infinity, }); rl.on('line', (line) => { const notification = parseNotification(line); if (!notification) { // Not a notification, probably command response return; } if (notification.type === 'output') { handleOutputEvent(notification.data); } else if (notification.type === 'exit') { console.error('\n[spike-cc] Received %exit, shutting down...'); process.exit(0); } else { // Other events (layout-change, window-add, etc.) stats.otherEvents++; // Log first few for debugging if (stats.otherEvents <= 5) { console.error(`[spike-cc] Event: %${notification.type}`); } } }); tmuxControl.stderr.on('data', (data) => { console.error('[spike-cc] tmux stderr:', data.toString()); }); tmuxControl.on('close', (code) => { console.error('\n[spike-cc] tmux process exited, code:', code); printFinalStats(); process.exit(code || 0); }); // Stats reporter const statsInterval = setInterval(() => { printStats(); }, 10000); // Cleanup on exit process.on('SIGINT', () => { console.error('\n[spike-cc] Caught SIGINT, cleaning up...'); clearInterval(statsInterval); tmuxControl.kill(); printFinalStats(); process.exit(0); }); }); } function printStats(): void { const elapsed = ((Date.now() - stats.startTime) / 1000).toFixed(1); console.error(`\n[spike-cc] === Stats @ ${elapsed}s ===`); console.error(`[spike-cc] Output events: ${stats.outputEvents}`); console.error(`[spike-cc] Bytes received: ${stats.bytesReceived}`); console.error(`[spike-cc] Other events: ${stats.otherEvents}`); console.error(`[spike-cc] Rate: ${(stats.outputEvents / parseFloat(elapsed)).toFixed(2)} events/s`); } function printFinalStats(): void { const elapsed = ((Date.now() - stats.startTime) / 1000).toFixed(1); console.error('\n[spike-cc] === Final Stats ==='); console.error(`[spike-cc] Total time: ${elapsed}s`); console.error(`[spike-cc] Output events: ${stats.outputEvents}`); console.error(`[spike-cc] Bytes received: ${stats.bytesReceived}`); console.error(`[spike-cc] Other events: ${stats.otherEvents}`); } // Run runControlMode();