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
225 lines
6.8 KiB
TypeScript
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 });
|
|
});
|
|
}
|
|
}
|