220 lines
6.1 KiB
TypeScript
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();
|