Files
harness-tools/extensions/interactive-subagents/pi-extension/subagents/herdr.ts
T

179 lines
4.6 KiB
TypeScript

/** Herdr surface backend. Subagents run in unfocused tabs in the current workspace. */
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const commandAvailability = new Map<string, boolean>();
const tabByPane = new Map<string, string>();
interface HerdrTabCreated {
result?: {
root_pane?: { pane_id?: unknown };
tab?: { tab_id?: unknown };
};
}
interface HerdrPaneCreated {
result?: {
pane?: { pane_id?: unknown };
};
}
function hasCommand(command: string): boolean {
const cached = commandAvailability.get(command);
if (cached !== undefined) return cached;
let available = false;
try {
execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
available = true;
} catch {
available = false;
}
commandAvailability.set(command, available);
return available;
}
export function isAvailable(): boolean {
return (
process.env.HERDR_ENV === "1" &&
!!process.env.HERDR_WORKSPACE_ID &&
!!process.env.HERDR_PANE_ID &&
hasCommand("herdr")
);
}
export function setupHint(): string {
return "Start pi inside a Herdr-managed pane with the `herdr` CLI on PATH.";
}
function requireHerdr(): void {
if (!isAvailable()) {
throw new Error(`Herdr is required for this surface. ${setupHint()}`);
}
}
function parseJson<T>(command: string, output: string): T {
try {
return JSON.parse(output) as T;
} catch {
throw new Error(`Unexpected Herdr ${command} output: ${output.trim() || "(empty)"}`);
}
}
function requireId(value: unknown, command: string, field: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Herdr ${command} did not return ${field}`);
}
return value;
}
export function parseCreatedTab(output: string): { paneId: string; tabId: string } {
const response = parseJson<HerdrTabCreated>("tab create", output);
return {
paneId: requireId(response.result?.root_pane?.pane_id, "tab create", "result.root_pane.pane_id"),
tabId: requireId(response.result?.tab?.tab_id, "tab create", "result.tab.tab_id"),
};
}
export function parseCreatedPane(output: string): string {
const response = parseJson<HerdrPaneCreated>("pane split", output);
return requireId(response.result?.pane?.pane_id, "pane split", "result.pane.pane_id");
}
export function createSurface(name: string): string {
requireHerdr();
const workspace = process.env.HERDR_WORKSPACE_ID!;
const output = execFileSync(
"herdr",
[
"tab",
"create",
"--workspace",
workspace,
"--cwd",
process.cwd(),
"--label",
name,
"--no-focus",
],
{ encoding: "utf8" },
);
const { paneId, tabId } = parseCreatedTab(output);
tabByPane.set(paneId, tabId);
return paneId;
}
export function createSurfaceSplit(
name: string,
direction: "left" | "right" | "up" | "down",
fromSurface?: string,
): string {
requireHerdr();
if (direction === "left" || direction === "up") {
throw new Error(`Herdr pane splits only support right or down, not ${direction}`);
}
const source = fromSurface ?? process.env.HERDR_PANE_ID!;
const output = execFileSync(
"herdr",
[
"pane",
"split",
"--pane",
source,
"--direction",
direction,
"--cwd",
process.cwd(),
"--no-focus",
],
{ encoding: "utf8" },
);
const paneId = parseCreatedPane(output);
try {
execFileSync("herdr", ["pane", "rename", paneId, name], { encoding: "utf8" });
} catch {
// A cosmetic label must not break a successful split.
}
return paneId;
}
export function sendCommand(surface: string, command: string): void {
requireHerdr();
execFileSync("herdr", ["pane", "run", surface, command], { encoding: "utf8" });
}
export function readScreen(surface: string, lines = 50): string {
requireHerdr();
return execFileSync(
"herdr",
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
{ encoding: "utf8" },
);
}
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
requireHerdr();
const { stdout } = await execFileAsync(
"herdr",
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
{ encoding: "utf8" },
);
return stdout;
}
export function closeSurface(surface: string): void {
requireHerdr();
const tabId = tabByPane.get(surface);
if (tabId) {
execFileSync("herdr", ["tab", "close", tabId], { encoding: "utf8" });
tabByPane.delete(surface);
return;
}
execFileSync("herdr", ["pane", "close", surface], { encoding: "utf8" });
}