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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user