Files
jb 208cd6f4e7 Add Orca terminal surface backend
Subagents run in unfocused Orca terminal tabs in the current worktree,
selected automatically between Herdr, Orca (ORCA_TERMINAL_HANDLE), and tmux.

- Tabs are created against ORCA_WORKTREE_ID since pi's cwd may live outside
  any Orca worktree, and start in pi's cwd via the startup command
- createSurface blocks on two-phase readiness (startup marker, then a
  verified probe echo) because input typed during shell init is swallowed
  by the initial shell's exec or flushed during bash startup
- terminal read reports ok with status exited for closed terminals, so
  reads throw on non-running status to match herdr/tmux semantics
- Integration harness gains Orca focus support via terminal switch and
  visual-layout active-pane lookup
2026-08-30 15:54:59 -04:00

225 lines
6.8 KiB
TypeScript

/**
* 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<string>;
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<string> {
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<PollResult> {
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<void>((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 });
});
}
}