diff --git a/extensions/remote-control/spike-cc.ts b/extensions/remote-control/spike-cc.ts new file mode 100644 index 0000000..d4c9e52 --- /dev/null +++ b/extensions/remote-control/spike-cc.ts @@ -0,0 +1,219 @@ +#!/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(); diff --git a/package.json b/package.json index 480c749..4d6fb94 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "scripts": { "lint": "biome check --write .", "lint:check": "biome check .", - "prepare": "node .husky/install.mjs" + "prepare": "node .husky/install.mjs", + "spike-cc": "npx -y ts-node extensions/remote-control/spike-cc.ts" }, "devDependencies": { "@biomejs/biome": "^2.4.12", diff --git a/test-spike-cc.sh b/test-spike-cc.sh new file mode 100755 index 0000000..ac1964b --- /dev/null +++ b/test-spike-cc.sh @@ -0,0 +1,172 @@ +#!/bin/bash +# Test Protocol for Phase 0.5 Control Mode Spike + +set -e + +SESSION="pi-cc" +LOG_FILE="/tmp/spike-cc-test-$(date +%s).log" +DURATION=300 # 5 minutes + +echo "=== Phase 0.5 Control Mode Test Protocol ===" +echo "Session: $SESSION" +echo "Log: $LOG_FILE" +echo "Duration: ${DURATION}s (5 minutes)" +echo "" + +# Clean up any existing session +tmux kill-session -t "$SESSION" 2>/dev/null || true + +echo "[1/6] Starting spike-cc in background..." +cd "$(dirname "$0")" +npm run spike-cc > "$LOG_FILE" 2>&1 & +SPIKE_PID=$! +echo " Spike PID: $SPIKE_PID" +sleep 3 + +# Check if spike is still running +if ! kill -0 $SPIKE_PID 2>/dev/null; then + echo " ERROR: Spike process died!" + tail -50 "$LOG_FILE" + exit 1 +fi + +echo " ✓ Spike running" +echo "" + +echo "[2/6] Verifying session exists..." +if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo " ERROR: Session $SESSION not found!" + kill $SPIKE_PID 2>/dev/null + exit 1 +fi +echo " ✓ Session exists" +echo "" + +echo "[3/6] Testing parallel tmux attach (critical P-2 requirement)..." +echo " Opening a 10-second tmux attach in background..." +(tmux attach -t "$SESSION" 2>&1 | head -20 > /tmp/spike-parallel-attach.log) & +ATTACH_PID=$! +sleep 5 + +if ! kill -0 $ATTACH_PID 2>/dev/null; then + echo " ✓ Attach completed (expected for non-interactive)" +else + echo " ✓ Attach still running" + kill $ATTACH_PID 2>/dev/null || true +fi + +# Check if spike is still running after parallel attach +if ! kill -0 $SPIKE_PID 2>/dev/null; then + echo " ERROR: Spike died during parallel attach!" + tail -50 "$LOG_FILE" + exit 1 +fi +echo " ✓ Control mode survived parallel attach" +echo "" + +echo "[4/6] Triggering alternate-screen failure mode..." +echo " Sending /settings command..." +tmux send-keys -t "$SESSION" "/settings" Enter +sleep 2 + +# Navigate in settings (arrow keys) +echo " Navigating in settings menu..." +tmux send-keys -t "$SESSION" Down Down Up +sleep 1 + +# Exit settings (Escape or q) +echo " Exiting settings..." +tmux send-keys -t "$SESSION" Escape +sleep 2 + +echo " ✓ Alternate-screen sequence complete" +echo "" + +# Check if spike is still receiving output +echo "[5/6] Verifying stream still active after alternate-screen..." +echo " Sending test prompt..." +tmux send-keys -t "$SESSION" "echo 'STREAM_TEST_AFTER_SETTINGS_$(date +%s)'" Enter +sleep 2 + +# Check recent output for our test string +if tail -100 "$LOG_FILE" | grep -q "STREAM_TEST_AFTER_SETTINGS"; then + echo " ✓ Stream still active! Control mode survived alternate-screen." +else + echo " WARNING: Test string not found in recent output." + echo " This could mean a lag or the test was too fast." + echo " Checking if spike is still running..." + if kill -0 $SPIKE_PID 2>/dev/null; then + echo " ✓ Spike process still alive" + else + echo " ERROR: Spike process died!" + tail -50 "$LOG_FILE" + exit 1 + fi +fi +echo "" + +echo "[6/6] Running for full 5-minute duration..." +START_TIME=$(date +%s) +NEXT_REPORT=$((START_TIME + 60)) + +while true; do + CURRENT_TIME=$(date +%s) + ELAPSED=$((CURRENT_TIME - START_TIME)) + + if [ $ELAPSED -ge $DURATION ]; then + break + fi + + # Check if spike is still running + if ! kill -0 $SPIKE_PID 2>/dev/null; then + echo " ERROR: Spike died at ${ELAPSED}s!" + tail -50 "$LOG_FILE" + exit 1 + fi + + # Report every minute + if [ $CURRENT_TIME -ge $NEXT_REPORT ]; then + REMAINING=$((DURATION - ELAPSED)) + echo " Still running... ${ELAPSED}s elapsed, ${REMAINING}s remaining" + NEXT_REPORT=$((NEXT_REPORT + 60)) + + # Send a keepalive message + tmux send-keys -t "$SESSION" "echo 'keepalive_${ELAPSED}s'" Enter + fi + + sleep 5 +done + +echo " ✓ Completed 5-minute test" +echo "" + +echo "=== Test Complete ===" +echo "Stopping spike..." +kill $SPIKE_PID 2>/dev/null || true +wait $SPIKE_PID 2>/dev/null || true + +echo "" +echo "=== Results ===" +echo "Log file: $LOG_FILE" +echo "" +echo "Statistics from log:" +grep "\[spike-cc\].*Stats" "$LOG_FILE" | tail -5 +echo "" + +OUTPUT_EVENTS=$(grep -c "handleOutputEvent\|%output" "$LOG_FILE" || echo "0") +echo "Total output events (approx): $OUTPUT_EVENTS" +echo "" + +if [ $OUTPUT_EVENTS -gt 10 ]; then + echo "✅ VERDICT: Control mode appears to work reliably" + echo " - Survived alternate-screen transition" + echo " - Parallel attach did not interfere" + echo " - Ran for full 5-minute duration" +else + echo "⚠️ VERDICT: Insufficient output events captured" + echo " This may indicate a problem with the spike implementation" +fi + +echo "" +echo "Full log saved to: $LOG_FILE" +echo "To review: cat $LOG_FILE"