pi-remote-control/extensions/remote-control/spike-cc.ts

220 lines
6.1 KiB
TypeScript

#!/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 %<pane-id> <value>
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();