/** * Backend-neutral terminal surface facade. * * Herdr is selected when Pi is running in a Herdr-managed pane, Orca when Pi * is running in an Orca-managed terminal. tmux remains the fallback when Pi is * running inside tmux. Selection is automatic so a process uses one backend * consistently for its whole lifetime. */ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import * as herdr from "./herdr.ts"; import * as orca from "./orca.ts"; import * as tmux from "./tmux.ts"; export type MuxBackend = "herdr" | "orca" | "tmux"; type Direction = "left" | "right" | "up" | "down"; type SurfaceBackend = { createSurface(name: string): string; createSurfaceSplit(name: string, direction: Direction, fromSurface?: string): string; sendCommand(surface: string, command: string): void; readScreen(surface: string, lines?: number): string; readScreenAsync(surface: string, lines?: number): Promise; closeSurface(surface: string): void; }; export function selectMuxBackend(availability: { herdr: boolean; orca: boolean; tmux: boolean; }): MuxBackend | null { if (availability.herdr) return "herdr"; if (availability.orca) return "orca"; if (availability.tmux) return "tmux"; return null; } export function getMuxBackend(): MuxBackend | null { return selectMuxBackend({ herdr: herdr.isAvailable(), orca: orca.isAvailable(), tmux: tmux.isAvailable(), }); } function getBackend(): SurfaceBackend { const backend = getMuxBackend(); if (backend === "herdr") return herdr; if (backend === "orca") return orca; if (backend === "tmux") return tmux; throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`); } export function isMuxAvailable(): boolean { return getMuxBackend() !== null; } export function muxSetupHint(): string { if (process.env.HERDR_ENV === "1") return herdr.setupHint(); if (process.env.ORCA_TERMINAL_HANDLE) return orca.setupHint(); if (process.env.TMUX) return tmux.setupHint(); return "Start pi inside Herdr, Orca, or tmux."; } export function createSurface(name: string): string { return getBackend().createSurface(name); } export function createSurfaceSplit( name: string, direction: Direction, fromSurface?: string, ): string { return getBackend().createSurfaceSplit(name, direction, fromSurface); } export function sendCommand(surface: string, command: string): void { getBackend().sendCommand(surface, command); } export function readScreen(surface: string, lines = 50): string { return getBackend().readScreen(surface, lines); } export function readScreenAsync(surface: string, lines = 50): Promise { return getBackend().readScreenAsync(surface, lines); } export function closeSurface(surface: string): void { getBackend().closeSurface(surface); } export function shellEscape(value: string): string { return "'" + value.replace(/'/g, "'\\''") + "'"; } export function wrapWithCompletionFile(command: string, completionFile: string): string { return ( `${command}; exit_code=$?; ` + `printf '%s\\n' "$exit_code" > ${shellEscape(completionFile)}; ` + `echo "__SUBAGENT_DONE_\${exit_code}__"` ); } export function sendLongCommand( surface: string, command: string, options?: { scriptPath?: string; scriptPreamble?: string }, ): string { const scriptPath = options?.scriptPath ?? join( tmpdir(), "pi-subagent-scripts", `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`, ); mkdirSync(dirname(scriptPath), { recursive: true }); const scriptParts = ["#!/bin/bash"]; if (options?.scriptPreamble) scriptParts.push(options.scriptPreamble.trimEnd()); scriptParts.push(command); writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 }); sendCommand(surface, `bash ${shellEscape(scriptPath)}`); return scriptPath; } export interface PollResult { reason: "done" | "sentinel" | "error"; exitCode: number; errorMessage?: string; } function interpretExitSidecar(data: unknown): PollResult { const payload = data as { type?: unknown; errorMessage?: unknown } | null; if (payload?.type === "error") { const errorMessage = typeof payload.errorMessage === "string" && payload.errorMessage.trim() !== "" ? payload.errorMessage : "Subagent exited with stopReason=error (no errorMessage in sidecar)."; return { reason: "error", exitCode: 1, errorMessage }; } return { reason: "done", exitCode: 0 }; } function readCompletionFile(path: string): PollResult | null { if (!existsSync(path)) return null; const raw = readFileSync(path, "utf8").trim(); const exitCode = Number.parseInt(raw, 10); if (!/^\d+$/.test(raw) || !Number.isSafeInteger(exitCode)) return null; rmSync(path, { force: true }); return { reason: "sentinel", exitCode }; } export const __pollForExitTest__ = { interpretExitSidecar, readCompletionFile }; export async function pollForExit( surface: string, signal: AbortSignal, options: { interval: number; sessionFile?: string; sentinelFile?: string; completionFile?: string; onTick?: (elapsed: number) => void; }, ): Promise { const start = Date.now(); for (;;) { if (signal.aborted) throw new Error("Aborted while waiting for subagent to finish"); if (options.sessionFile) { try { const exitFile = `${options.sessionFile}.exit`; if (existsSync(exitFile)) { const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8"))); rmSync(exitFile, { force: true }); return result; } } catch {} } if (options.sentinelFile) { try { if (existsSync(options.sentinelFile)) { return { reason: "sentinel", exitCode: 0 }; } } catch {} } if (options.completionFile) { try { const result = readCompletionFile(options.completionFile); if (result) return result; } catch {} } try { const screen = await readScreenAsync(surface, 5); const match = screen.match(/__SUBAGENT_DONE_(\d+)__/); if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) }; } catch { // Surface reads are best-effort. Filesystem completion remains authoritative. } const elapsed = Math.floor((Date.now() - start) / 1000); options.onTick?.(elapsed); await new Promise((resolve, reject) => { if (signal.aborted) return reject(new Error("Aborted")); const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, options.interval); function onAbort() { clearTimeout(timer); reject(new Error("Aborted")); } signal.addEventListener("abort", onAbort, { once: true }); }); } }