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
+8 -5
View File
@@ -2,13 +2,13 @@
# pi-interactive-subagents # pi-interactive-subagents
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking. Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs, Orca terminal tabs, or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking.
The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), otherwise tmux is used when available. The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), then Orca when Pi runs in an Orca-managed terminal (`ORCA_TERMINAL_HANDLE`), otherwise tmux is used when available.
## How it works ## How it works
`subagent()` returns immediately. In Herdr, the sub-agent runs in a new unfocused tab in the current workspace. In tmux, it runs in a right split off the parent Pi pane. A live widget above the input tracks every running sub-agent, and when one finishes, its result is steered into the main session as a notification that triggers a new turn. `subagent()` returns immediately. In Herdr, the sub-agent runs in a new unfocused tab in the current workspace. In Orca, it runs in a new unfocused terminal tab in the current worktree, started in the parent Pi's working directory. In tmux, it runs in a right split off the parent Pi pane. A live widget above the input tracks every running sub-agent, and when one finishes, its result is steered into the main session as a notification that triggers a new turn.
``` ```
Subagents 2 running Subagents 2 running
@@ -20,12 +20,12 @@ Spawn several in parallel — they run concurrently and steer results back indep
Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer. Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer.
tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr subagents use separate tabs, so they do not resize the parent tab. tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr and Orca subagents use separate tabs, so they do not resize the parent tab.
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay: If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
```bash ```bash
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, tmux 500 export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, Orca 500, tmux 500
``` ```
## Tools ## Tools
@@ -199,10 +199,13 @@ Status display is configured via `config.json` in the extension directory (copy
- [pi](https://github.com/badlogic/pi-mono) - [pi](https://github.com/badlogic/pi-mono)
- One supported terminal manager: - One supported terminal manager:
- [Herdr](https://herdr.dev), with Pi launched inside Herdr - [Herdr](https://herdr.dev), with Pi launched inside Herdr
- [Orca](https://orca.build), with Pi launched inside an Orca-managed terminal
- [tmux](https://github.com/tmux/tmux) - [tmux](https://github.com/tmux/tmux)
Herdr requires no extension configuration once its Pi integration is installed. The extension detects `HERDR_ENV=1` and creates subagents as unfocused tabs in the current workspace. Herdr requires no extension configuration once its Pi integration is installed. The extension detects `HERDR_ENV=1` and creates subagents as unfocused tabs in the current workspace.
Orca requires no extension configuration either. The extension detects `ORCA_TERMINAL_HANDLE` and creates subagents as unfocused terminal tabs in the current worktree via the `orca` CLI (`ORCA_CLI_COMMAND` is honored when set). New tabs start in the parent Pi's working directory with a bash shell.
For tmux: For tmux:
```bash ```bash
+2
View File
@@ -542,6 +542,8 @@ function widgetIcon(
* dropped commands. Keep the historical default at 500ms. * dropped commands. Keep the historical default at 500ms.
*/ */
function defaultShellReadyDelayMs(backend: ReturnType<typeof getMuxBackend>): number { 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; 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. * Backend-neutral terminal surface facade.
* *
* Herdr is selected when Pi is running in a Herdr-managed pane. tmux remains * Herdr is selected when Pi is running in a Herdr-managed pane, Orca when Pi
* the fallback when Pi is running inside tmux. Selection is automatic so a * is running in an Orca-managed terminal. tmux remains the fallback when Pi is
* process uses one backend consistently for its whole lifetime. * 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 { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import * as herdr from "./herdr.ts"; import * as herdr from "./herdr.ts";
import * as orca from "./orca.ts";
import * as tmux from "./tmux.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 Direction = "left" | "right" | "up" | "down";
type SurfaceBackend = { type SurfaceBackend = {
@@ -25,20 +27,27 @@ type SurfaceBackend = {
export function selectMuxBackend(availability: { export function selectMuxBackend(availability: {
herdr: boolean; herdr: boolean;
orca: boolean;
tmux: boolean; tmux: boolean;
}): MuxBackend | null { }): MuxBackend | null {
if (availability.herdr) return "herdr"; if (availability.herdr) return "herdr";
if (availability.orca) return "orca";
if (availability.tmux) return "tmux"; if (availability.tmux) return "tmux";
return null; return null;
} }
export function getMuxBackend(): MuxBackend | 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 { function getBackend(): SurfaceBackend {
const backend = getMuxBackend(); const backend = getMuxBackend();
if (backend === "herdr") return herdr; if (backend === "herdr") return herdr;
if (backend === "orca") return orca;
if (backend === "tmux") return tmux; if (backend === "tmux") return tmux;
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`); throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
} }
@@ -49,8 +58,9 @@ export function isMuxAvailable(): boolean {
export function muxSetupHint(): string { export function muxSetupHint(): string {
if (process.env.HERDR_ENV === "1") return herdr.setupHint(); 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(); 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 { export function createSurface(name: string): string {
+48
View File
@@ -97,7 +97,42 @@ function parseHerdrJson(command: string, args: string[]): any {
return JSON.parse(output); return JSON.parse(output);
} }
function orcaCli(): string {
return process.env.ORCA_CLI_COMMAND?.trim() || "orca";
}
function parseOrcaJson(args: string[]): any {
const output = execFileSync(orcaCli(), args, { encoding: "utf8" });
const response = JSON.parse(output);
if (response.ok === false) {
throw new Error(`Orca ${args.slice(0, 2).join(" ")} failed: ${response.error?.message}`);
}
return response;
}
/** Depth-first search of an Orca visual-layout pane tree for the active terminal. */
function findActiveOrcaTerminal(node: any): string | null {
if (!node || typeof node !== "object") return null;
if (node.type === "terminal") return node.active ? node.handle ?? null : null;
for (const child of Object.values(node)) {
if (Array.isArray(child)) {
for (const item of child) {
const found = findActiveOrcaTerminal(item);
if (found) return found;
}
} else if (child && typeof child === "object") {
const found = findActiveOrcaTerminal(child);
if (found) return found;
}
}
return null;
}
export function focusSurface(surface: string): void { export function focusSurface(surface: string): void {
if (getMuxBackend() === "orca") {
parseOrcaJson(["terminal", "switch", "--terminal", surface, "--json"]);
return;
}
if (getMuxBackend() === "herdr") { if (getMuxBackend() === "herdr") {
const pane = parseHerdrJson("herdr", ["pane", "get", surface]); const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
const tabId = pane.result?.pane?.tab_id; const tabId = pane.result?.pane?.tab_id;
@@ -110,6 +145,19 @@ export function focusSurface(surface: string): void {
export function getFocusedSurface(): string | null { export function getFocusedSurface(): string | null {
try { try {
if (getMuxBackend() === "orca") {
const response = parseOrcaJson(["terminal", "list", "--include-visual-layouts", "--json"]);
const layouts = response.result?.visualLayouts ?? [];
for (const layout of layouts) {
const root = layout.root;
if (!root) continue;
const activeTab = root.tabs?.find((tab: any) => tab.tabId === root.activeTabId);
const handle = findActiveOrcaTerminal(activeTab?.panes);
if (handle) return handle;
}
return null;
}
if (getMuxBackend() === "herdr") { if (getMuxBackend() === "herdr") {
const workspace = process.env.HERDR_WORKSPACE_ID; const workspace = process.env.HERDR_WORKSPACE_ID;
if (!workspace) return null; if (!workspace) return null;
+3 -3
View File
@@ -8,7 +8,7 @@
* Costs: ~$0.01-0.05 per test run (haiku). * Costs: ~$0.01-0.05 per test run (haiku).
* Duration: ~30-90s per test. * Duration: ~30-90s per test.
* *
* Run inside Herdr, or inside tmux: * Run inside Herdr, inside an Orca terminal, or inside tmux:
* tmux new 'npm run test:integration' * tmux new 'npm run test:integration'
* *
* Configuration: * Configuration:
@@ -38,8 +38,8 @@ import {
const backends = getAvailableBackends(); const backends = getAvailableBackends();
if (backends.length === 0) { if (backends.length === 0) {
console.log("⚠️ Herdr/tmux is not available — skipping subagent lifecycle integration tests"); console.log("⚠️ Herdr/Orca/tmux is not available — skipping subagent lifecycle integration tests");
console.log(" Run inside Herdr or tmux to enable these tests."); console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
} }
for (const backend of backends) { for (const backend of backends) {
+4 -4
View File
@@ -1,11 +1,11 @@
/** /**
* Integration tests for the selected terminal-surface backend. * Integration tests for the selected terminal-surface backend.
* *
* These tests exercise real Herdr or tmux operations: creating surfaces, * These tests exercise real Herdr, Orca, or tmux operations: creating surfaces,
* sending commands, reading screen output, and closing panes. * sending commands, reading screen output, and closing panes.
* No LLM calls — fast and free. * No LLM calls — fast and free.
* *
* Run inside Herdr, or inside tmux: * Run inside Herdr, inside an Orca terminal, or inside tmux:
* tmux new 'npm run test:integration' * tmux new 'npm run test:integration'
*/ */
import { describe, it, before, after } from "node:test"; import { describe, it, before, after } from "node:test";
@@ -39,8 +39,8 @@ const backends = getAvailableBackends();
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500"); const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
if (backends.length === 0) { if (backends.length === 0) {
console.log("⚠️ Herdr/tmux is not available — skipping surface integration tests"); console.log("⚠️ Herdr/Orca/tmux is not available — skipping surface integration tests");
console.log(" Run inside Herdr or tmux to enable these tests."); console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
} }
for (const backend of backends) { for (const backend of backends) {
+46 -5
View File
@@ -36,6 +36,11 @@ import {
shellEscape, shellEscape,
} from "../pi-extension/subagents/surface.ts"; } from "../pi-extension/subagents/surface.ts";
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts"; import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
import {
parseCreatedSplit,
parseCreatedTerminal,
parseReadTail,
} from "../pi-extension/subagents/orca.ts";
import { import {
advanceStatusState, advanceStatusState,
capStatusLines, capStatusLines,
@@ -2613,6 +2618,7 @@ describe("subagent startup delay", () => {
const testApi = (subagentsModule as any).__test__; const testApi = (subagentsModule as any).__test__;
assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500); assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000); assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
assert.equal(testApi.defaultShellReadyDelayMs("orca"), 500);
assert.equal(testApi.defaultShellReadyDelayMs(null), 500); assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
}); });
@@ -3149,11 +3155,13 @@ describe("subagent display helpers", () => {
}); });
describe("surface backend selection", () => { describe("surface backend selection", () => {
it("prefers Herdr and otherwise falls back to tmux", () => { it("prefers Herdr, then Orca, and otherwise falls back to tmux", () => {
assert.equal(selectMuxBackend({ herdr: true, tmux: true }), "herdr"); assert.equal(selectMuxBackend({ herdr: true, orca: true, tmux: true }), "herdr");
assert.equal(selectMuxBackend({ herdr: true, tmux: false }), "herdr"); assert.equal(selectMuxBackend({ herdr: true, orca: false, tmux: false }), "herdr");
assert.equal(selectMuxBackend({ herdr: false, tmux: true }), "tmux"); assert.equal(selectMuxBackend({ herdr: false, orca: true, tmux: true }), "orca");
assert.equal(selectMuxBackend({ herdr: false, tmux: false }), null); assert.equal(selectMuxBackend({ herdr: false, orca: true, tmux: false }), "orca");
assert.equal(selectMuxBackend({ herdr: false, orca: false, tmux: true }), "tmux");
assert.equal(selectMuxBackend({ herdr: false, orca: false, tmux: false }), null);
}); });
it("parses Herdr tab and pane creation responses", () => { it("parses Herdr tab and pane creation responses", () => {
@@ -3172,6 +3180,39 @@ describe("surface backend selection", () => {
); );
assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/); assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/);
}); });
it("parses Orca terminal create, split, and read responses", () => {
assert.deepEqual(
parseCreatedTerminal(JSON.stringify({
ok: true,
result: { terminal: { handle: "term_abc", tabId: "tab-1" } },
})),
{ handle: "term_abc", tabId: "tab-1" },
);
assert.equal(
parseCreatedSplit(JSON.stringify({ ok: true, result: { split: { handle: "term_def" } } })),
"term_def",
);
assert.equal(
parseReadTail(JSON.stringify({
ok: true,
result: { terminal: { status: "running", tail: ["line one", "line two"] } },
})),
"line one\nline two",
);
assert.throws(
() => parseReadTail(JSON.stringify({
ok: true,
result: { terminal: { status: "exited", tail: [] } },
})),
/not running \(status: exited\)/,
);
assert.throws(() => parseCreatedTerminal("{}"), /result\.terminal\.handle/);
assert.throws(
() => parseCreatedTerminal(JSON.stringify({ ok: false, error: { message: "nope" } })),
/terminal create failed: nope/,
);
});
}); });
describe("subagent completion wrapper", () => { describe("subagent completion wrapper", () => {