Adding support for herdr - agents in new tabs
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Backend-neutral terminal surface facade.
|
||||
*
|
||||
* Herdr is selected when Pi is running in a Herdr-managed pane. 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 tmux from "./tmux.ts";
|
||||
|
||||
export type MuxBackend = "herdr" | "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;
|
||||
tmux: boolean;
|
||||
}): MuxBackend | null {
|
||||
if (availability.herdr) return "herdr";
|
||||
if (availability.tmux) return "tmux";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMuxBackend(): MuxBackend | null {
|
||||
return selectMuxBackend({ herdr: herdr.isAvailable(), tmux: tmux.isAvailable() });
|
||||
}
|
||||
|
||||
function getBackend(): SurfaceBackend {
|
||||
const backend = getMuxBackend();
|
||||
if (backend === "herdr") return herdr;
|
||||
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.TMUX) return tmux.setupHint();
|
||||
return "Start pi inside Herdr 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user