271 lines
8.7 KiB
TypeScript
271 lines
8.7 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>;
|
|
isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean>;
|
|
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 isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean> {
|
|
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<boolean>;
|
|
livenessIntervalMs?: number;
|
|
livenessFailureThreshold?: number;
|
|
};
|
|
|
|
/** Avoid launching a terminal CLI reader when a local completion signal is authoritative. */
|
|
function shouldPollSurface(
|
|
options: Pick<PollOptions, "sessionFile" | "sentinelFile" | "completionFile">,
|
|
): 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<string> = readScreenAsync,
|
|
): Promise<PollResult> {
|
|
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<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 });
|
|
});
|
|
}
|
|
}
|