/** * 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; isSurfaceAlive(surface: string, signal?: AbortSignal): 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 isSurfaceAlive(surface: string, signal?: AbortSignal): Promise { return getBackend().isSurfaceAlive(surface, signal); } 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; replaceShell?: boolean }, ): 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 }); const launch = `${options?.replaceShell ? "exec " : ""}bash ${shellEscape(scriptPath)}`; sendCommand(surface, launch); 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 }; } type PollOptions = { interval: number; sessionFile?: string; sentinelFile?: string; completionFile?: string; onTick?: (elapsed: number) => void; onExitDetected?: () => void; livenessCheck?: (surface: string, signal?: AbortSignal) => Promise; livenessIntervalMs?: number; livenessFailureThreshold?: number; }; /** Avoid launching a terminal CLI reader when a local completion signal is authoritative. */ function shouldPollSurface( options: Pick, ): boolean { return !options.sessionFile && !options.sentinelFile && !options.completionFile; } export const __pollForExitTest__ = { interpretExitSidecar, readCompletionFile }; export async function pollForExit( surface: string, signal: AbortSignal, options: PollOptions, readSurface: (surface: string, lines?: number) => Promise = readScreenAsync, ): Promise { const start = Date.now(); const livenessCheck = options.livenessCheck ?? isSurfaceAlive; const livenessIntervalMs = options.livenessIntervalMs ?? 5_000; const livenessFailureThreshold = options.livenessFailureThreshold ?? 3; let nextLivenessCheck = start + livenessIntervalMs; let livenessFailures = 0; 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"))); options.onExitDetected?.(); rmSync(exitFile, { force: true }); return result; } } catch {} } if (options.sentinelFile) { try { if (existsSync(options.sentinelFile)) { options.onExitDetected?.(); return { reason: "sentinel", exitCode: 0 }; } } catch {} } if (options.completionFile) { try { const result = readCompletionFile(options.completionFile); if (result) { options.onExitDetected?.(); return result; } } catch {} } if (shouldPollSurface(options)) { try { const screen = await readSurface(surface, 5); const match = screen.match(/__SUBAGENT_DONE_(\d+)__/); if (match) { options.onExitDetected?.(); return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) }; } } catch { // Surface reads are a best-effort fallback when no completion file is available. } } else if (Date.now() >= nextLivenessCheck) { nextLivenessCheck = Date.now() + livenessIntervalMs; try { livenessFailures = await livenessCheck(surface, signal) ? 0 : livenessFailures + 1; } catch { livenessFailures += 1; } if (signal.aborted) throw new Error("Aborted while checking subagent surface liveness"); if (livenessFailures >= livenessFailureThreshold) { throw new Error("Subagent terminal surface closed before completion."); } } 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 }); }); } }