201 lines
5.2 KiB
TypeScript
201 lines
5.2 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 async function isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean> {
|
|
try {
|
|
requireHerdr();
|
|
await execFileAsync(
|
|
"herdr",
|
|
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", "1"],
|
|
{ encoding: "utf8", timeout: 5_000, killSignal: "SIGKILL", signal },
|
|
);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function closeSurface(surface: string): void {
|
|
requireHerdr();
|
|
const tabId = tabByPane.get(surface);
|
|
if (tabId) {
|
|
try {
|
|
execFileSync("herdr", ["tab", "close", tabId], { encoding: "utf8" });
|
|
} catch {
|
|
// Replaced-shell launches may close their own tab before watcher cleanup.
|
|
}
|
|
tabByPane.delete(surface);
|
|
return;
|
|
}
|
|
try {
|
|
execFileSync("herdr", ["pane", "close", surface], { encoding: "utf8" });
|
|
} catch {
|
|
// Closing an already-ended pane is idempotent cleanup.
|
|
}
|
|
}
|