/** * tmux surface backend. * * This module only implements tmux-specific terminal operations. Backend * selection, command scripts, and exit polling live in surface.ts. */ import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const commandAvailability = new Map(); function hasCommand(command: string): boolean { const cached = commandAvailability.get(command); if (cached !== undefined) return cached; let available = false; try { execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" }); available = true; } catch { available = false; } commandAvailability.set(command, available); return available; } export function isAvailable(): boolean { return !!process.env.TMUX && hasCommand("tmux"); } export function setupHint(): string { return "Start pi inside tmux (`tmux new -A -s pi 'pi'`)."; } function requireTmux(): void { if (!isAvailable()) { throw new Error(`tmux is required for this surface. ${setupHint()}`); } } /** tmux layout used to keep sibling panes evenly sized. */ const SUBAGENT_TMUX_LAYOUT = "even-horizontal"; let rebalanceTimer: ReturnType | null = null; function rebalanceSurfaces(hintPane?: string): void { const target = process.env.TMUX_PANE ?? hintPane; if (!target) return; if (rebalanceTimer) clearTimeout(rebalanceTimer); rebalanceTimer = setTimeout(() => { rebalanceTimer = null; try { execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], { encoding: "utf8", }); } catch { // Pane/window may be gone; balancing is best-effort. } }, 120); } export function createSurface(name: string): string { return createSurfaceSplit(name, "right", process.env.TMUX_PANE); } export function createSurfaceSplit( name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string, ): string { void name; requireTmux(); const args = ["split-window", "-d"]; args.push(direction === "left" || direction === "right" ? "-h" : "-v"); if (direction === "left" || direction === "up") args.push("-b"); if (fromSurface) args.push("-t", fromSurface); args.push("-P", "-F", "#{pane_id}"); const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim(); if (!pane.startsWith("%")) { throw new Error(`Unexpected tmux split-window output: ${pane}`); } rebalanceSurfaces(pane); return pane; } export function sendCommand(surface: string, command: string): void { requireTmux(); execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" }); execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" }); } export function readScreen(surface: string, lines = 50): string { requireTmux(); return execFileSync( "tmux", ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`], { encoding: "utf8" }, ); } export async function readScreenAsync(surface: string, lines = 50): Promise { requireTmux(); const { stdout } = await execFileAsync( "tmux", ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`], { encoding: "utf8" }, ); return stdout; } export function closeSurface(surface: string): void { requireTmux(); execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" }); rebalanceSurfaces(); }