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

295 lines
8.8 KiB
TypeScript

/**
* Orca surface backend. Subagents run in unfocused terminal tabs in the
* current Orca worktree.
*
* Detection relies on ORCA_TERMINAL_HANDLE, which Orca exports inside its
* managed terminals. Inside those terminals `orca` resolves to the Orca CLI
* shim; outside them bare `orca` on Linux is usually the GNOME screen reader,
* so resolution explicitly rejects /usr/bin/orca.
*/
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/** GNOME screen reader path — never a valid Orca CLI. */
const GNOME_SCREEN_READER = "/usr/bin/orca";
let cachedCli: string | null | undefined;
/** Terminal handles created as whole tabs (vs splits), mapped for tab close. */
const tabByHandle = new Map<string, string>();
interface OrcaResponse {
ok?: boolean;
error?: { message?: unknown };
}
interface OrcaTerminalCreated extends OrcaResponse {
result?: {
terminal?: { handle?: unknown; tabId?: unknown };
};
}
interface OrcaSplitCreated extends OrcaResponse {
result?: {
split?: { handle?: unknown };
};
}
interface OrcaTerminalRead extends OrcaResponse {
result?: {
terminal?: { tail?: unknown; status?: unknown };
};
}
/**
* Marker echoed by the startup command once the new terminal has cd'd into
* the target directory and is about to exec bash. Written with a quote split
* (`__PI_SURFACE_''READY__`) so the typed command line on screen never
* matches, only the echoed output does.
*/
const READY_MARKER = "__PI_SURFACE_READY__";
function resolveCli(): string | null {
if (cachedCli !== undefined) return cachedCli;
const override = process.env.ORCA_CLI_COMMAND?.trim();
if (override) {
cachedCli = override;
return cachedCli;
}
try {
const resolved = execFileSync("sh", ["-c", "command -v orca"], { encoding: "utf8" }).trim();
cachedCli = resolved && resolved !== GNOME_SCREEN_READER ? "orca" : null;
} catch {
cachedCli = null;
}
return cachedCli;
}
export function isAvailable(): boolean {
return !!process.env.ORCA_TERMINAL_HANDLE && resolveCli() !== null;
}
export function setupHint(): string {
return "Start pi inside an Orca-managed terminal with the `orca` CLI on PATH.";
}
function requireOrca(): string {
const cli = resolveCli();
if (!isAvailable() || !cli) {
throw new Error(`Orca is required for this surface. ${setupHint()}`);
}
return cli;
}
function shellEscape(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
/** Startup command that lands the new terminal in pi's cwd with a bash shell. */
function startupCommand(): string {
return `cd ${shellEscape(process.cwd())} && echo __PI_SURFACE_''READY__ && exec bash`;
}
function sleepSync(seconds: number): void {
try {
execFileSync("sleep", [String(seconds)], { stdio: "ignore" });
} catch {}
}
/** Probe marker proving the shell reads and executes typed input. */
const SHELL_UP_MARKER = "__PI_SHELL_UP__";
/**
* Block until the new terminal's shell is genuinely interactive.
*
* Two races exist at startup: input typed before the initial shell execs bash
* is swallowed with it, and input typed while bash itself initializes can be
* flushed (observed as stray kitty-keyboard residue like `0u` at the prompt).
* So first wait for the startup command's ready marker, then repeatedly send a
* probe echo until its output appears — an executed probe also clears any
* residue from the readline buffer. Falls through after the deadline as
* best-effort.
*/
function waitForSurfaceReady(surface: string, timeoutMs = 15_000): void {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
if (readScreen(surface, 40).includes(READY_MARKER)) break;
} catch {}
sleepSync(0.25);
}
while (Date.now() < deadline) {
try {
sendCommand(surface, "echo __PI_SHELL_''UP__");
} catch {
sleepSync(0.25);
continue;
}
const probeDeadline = Math.min(Date.now() + 2_000, deadline);
while (Date.now() < probeDeadline) {
sleepSync(0.25);
try {
if (readScreen(surface, 40).includes(SHELL_UP_MARKER)) return;
} catch {}
}
}
}
function parseResponse<T extends OrcaResponse>(command: string, output: string): T {
let response: T;
try {
response = JSON.parse(output) as T;
} catch {
throw new Error(`Unexpected Orca ${command} output: ${output.trim() || "(empty)"}`);
}
if (response.ok === false) {
const message =
typeof response.error?.message === "string" ? response.error.message : "unknown error";
throw new Error(`Orca ${command} failed: ${message}`);
}
return response;
}
function requireId(value: unknown, command: string, field: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Orca ${command} did not return ${field}`);
}
return value;
}
export function parseCreatedTerminal(output: string): { handle: string; tabId: string } {
const response = parseResponse<OrcaTerminalCreated>("terminal create", output);
return {
handle: requireId(response.result?.terminal?.handle, "terminal create", "result.terminal.handle"),
tabId: requireId(response.result?.terminal?.tabId, "terminal create", "result.terminal.tabId"),
};
}
export function parseCreatedSplit(output: string): string {
const response = parseResponse<OrcaSplitCreated>("terminal split", output);
return requireId(response.result?.split?.handle, "terminal split", "result.split.handle");
}
export function parseReadTail(output: string): string {
const response = parseResponse<OrcaTerminalRead>("terminal read", output);
const status = response.result?.terminal?.status;
if (typeof status === "string" && status !== "running") {
throw new Error(`Orca terminal read: terminal is not running (status: ${status})`);
}
const tail = response.result?.terminal?.tail;
if (!Array.isArray(tail)) {
throw new Error("Orca terminal read did not return result.terminal.tail");
}
return tail.filter((line): line is string => typeof line === "string").join("\n");
}
export function createSurface(name: string): string {
const cli = requireOrca();
// Target the enclosing terminal's worktree explicitly: pi's cwd (which the
// CLI would otherwise resolve against) may live outside any Orca worktree.
const worktreeId = process.env.ORCA_WORKTREE_ID;
const output = execFileSync(
cli,
[
"terminal",
"create",
...(worktreeId ? ["--worktree", `id:${worktreeId}`] : []),
"--title",
name,
"--command",
startupCommand(),
"--json",
],
{ encoding: "utf8" },
);
const { handle, tabId } = parseCreatedTerminal(output);
tabByHandle.set(handle, tabId);
waitForSurfaceReady(handle);
return handle;
}
export function createSurfaceSplit(
name: string,
direction: "left" | "right" | "up" | "down",
fromSurface?: string,
): string {
void name;
const cli = requireOrca();
if (direction === "left" || direction === "up") {
throw new Error(`Orca terminal splits only support right or down, not ${direction}`);
}
const source = fromSurface ?? process.env.ORCA_TERMINAL_HANDLE!;
const output = execFileSync(
cli,
[
"terminal",
"split",
"--terminal",
source,
"--direction",
direction === "right" ? "horizontal" : "vertical",
"--command",
startupCommand(),
"--json",
],
{ encoding: "utf8" },
);
const handle = parseCreatedSplit(output);
waitForSurfaceReady(handle);
return handle;
}
export function sendCommand(surface: string, command: string): void {
const cli = requireOrca();
const output = execFileSync(
cli,
["terminal", "send", "--terminal", surface, "--text", command, "--enter", "--json"],
{ encoding: "utf8" },
);
parseResponse("terminal send", output);
}
function readScreenArgs(surface: string, lines: number): string[] {
return [
"terminal",
"read",
"--terminal",
surface,
"--screen",
"--limit",
String(Math.max(1, lines)),
"--json",
];
}
export function readScreen(surface: string, lines = 50): string {
const cli = requireOrca();
const output = execFileSync(cli, readScreenArgs(surface, lines), { encoding: "utf8" });
return parseReadTail(output);
}
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
const cli = requireOrca();
const { stdout } = await execFileAsync(cli, readScreenArgs(surface, lines), {
encoding: "utf8",
});
return parseReadTail(stdout);
}
export function closeSurface(surface: string): void {
const cli = requireOrca();
const args = ["terminal", "close", "--terminal", surface];
if (tabByHandle.has(surface)) args.push("--tab");
args.push("--json");
const output = execFileSync(cli, args, { encoding: "utf8" });
parseResponse("terminal close", output);
tabByHandle.delete(surface);
}