Subagents run in unfocused Orca terminal tabs in the current worktree, selected automatically between Herdr, Orca (ORCA_TERMINAL_HANDLE), and tmux. - Tabs are created against ORCA_WORKTREE_ID since pi's cwd may live outside any Orca worktree, and start in pi's cwd via the startup command - createSurface blocks on two-phase readiness (startup marker, then a verified probe echo) because input typed during shell init is swallowed by the initial shell's exec or flushed during bash startup - terminal read reports ok with status exited for closed terminals, so reads throw on non-running status to match herdr/tmux semantics - Integration harness gains Orca focus support via terminal switch and visual-layout active-pane lookup
295 lines
8.8 KiB
TypeScript
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);
|
|
}
|