Add Orca terminal surface backend

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
This commit is contained in:
jb
2026-08-30 15:54:59 -04:00
parent bc3e3d6049
commit 208cd6f4e7
8 changed files with 421 additions and 23 deletions
+2
View File
@@ -542,6 +542,8 @@ function widgetIcon(
* dropped commands. Keep the historical default at 500ms.
*/
function defaultShellReadyDelayMs(backend: ReturnType<typeof getMuxBackend>): number {
// Orca surfaces block on a readiness marker at create time, so they only
// need the same small buffer as tmux.
return backend === "herdr" ? 1000 : 500;
}
+294
View File
@@ -0,0 +1,294 @@
/**
* 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);
}
+16 -6
View File
@@ -1,17 +1,19 @@
/**
* 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.
* Herdr is selected when Pi is running in a Herdr-managed pane, Orca when Pi
* is running in an Orca-managed terminal. 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 orca from "./orca.ts";
import * as tmux from "./tmux.ts";
export type MuxBackend = "herdr" | "tmux";
export type MuxBackend = "herdr" | "orca" | "tmux";
type Direction = "left" | "right" | "up" | "down";
type SurfaceBackend = {
@@ -25,20 +27,27 @@ type SurfaceBackend = {
export function selectMuxBackend(availability: {
herdr: boolean;
orca: boolean;
tmux: boolean;
}): MuxBackend | null {
if (availability.herdr) return "herdr";
if (availability.orca) return "orca";
if (availability.tmux) return "tmux";
return null;
}
export function getMuxBackend(): MuxBackend | null {
return selectMuxBackend({ herdr: herdr.isAvailable(), tmux: tmux.isAvailable() });
return selectMuxBackend({
herdr: herdr.isAvailable(),
orca: orca.isAvailable(),
tmux: tmux.isAvailable(),
});
}
function getBackend(): SurfaceBackend {
const backend = getMuxBackend();
if (backend === "herdr") return herdr;
if (backend === "orca") return orca;
if (backend === "tmux") return tmux;
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
}
@@ -49,8 +58,9 @@ export function isMuxAvailable(): boolean {
export function muxSetupHint(): string {
if (process.env.HERDR_ENV === "1") return herdr.setupHint();
if (process.env.ORCA_TERMINAL_HANDLE) return orca.setupHint();
if (process.env.TMUX) return tmux.setupHint();
return "Start pi inside Herdr or tmux.";
return "Start pi inside Herdr, Orca, or tmux.";
}
export function createSurface(name: string): string {