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:
@@ -97,7 +97,42 @@ function parseHerdrJson(command: string, args: string[]): any {
|
||||
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 {
|
||||
if (getMuxBackend() === "orca") {
|
||||
parseOrcaJson(["terminal", "switch", "--terminal", surface, "--json"]);
|
||||
return;
|
||||
}
|
||||
if (getMuxBackend() === "herdr") {
|
||||
const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
|
||||
const tabId = pane.result?.pane?.tab_id;
|
||||
@@ -110,6 +145,19 @@ export function focusSurface(surface: string): void {
|
||||
|
||||
export function getFocusedSurface(): string | null {
|
||||
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") {
|
||||
const workspace = process.env.HERDR_WORKSPACE_ID;
|
||||
if (!workspace) return null;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Costs: ~$0.01-0.05 per test run (haiku).
|
||||
* 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'
|
||||
*
|
||||
* Configuration:
|
||||
@@ -38,8 +38,8 @@ import {
|
||||
const backends = getAvailableBackends();
|
||||
|
||||
if (backends.length === 0) {
|
||||
console.log("⚠️ Herdr/tmux is not available — skipping subagent lifecycle integration tests");
|
||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
||||
console.log("⚠️ Herdr/Orca/tmux is not available — skipping subagent lifecycle integration tests");
|
||||
console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
|
||||
}
|
||||
|
||||
for (const backend of backends) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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'
|
||||
*/
|
||||
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");
|
||||
|
||||
if (backends.length === 0) {
|
||||
console.log("⚠️ Herdr/tmux is not available — skipping surface integration tests");
|
||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
||||
console.log("⚠️ Herdr/Orca/tmux is not available — skipping surface integration tests");
|
||||
console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
|
||||
}
|
||||
|
||||
for (const backend of backends) {
|
||||
|
||||
+46
-5
@@ -36,6 +36,11 @@ import {
|
||||
shellEscape,
|
||||
} from "../pi-extension/subagents/surface.ts";
|
||||
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
||||
import {
|
||||
parseCreatedSplit,
|
||||
parseCreatedTerminal,
|
||||
parseReadTail,
|
||||
} from "../pi-extension/subagents/orca.ts";
|
||||
import {
|
||||
advanceStatusState,
|
||||
capStatusLines,
|
||||
@@ -2613,6 +2618,7 @@ describe("subagent startup delay", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
|
||||
assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
|
||||
assert.equal(testApi.defaultShellReadyDelayMs("orca"), 500);
|
||||
assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
|
||||
});
|
||||
|
||||
@@ -3149,11 +3155,13 @@ describe("subagent display helpers", () => {
|
||||
});
|
||||
|
||||
describe("surface backend selection", () => {
|
||||
it("prefers Herdr and otherwise falls back to tmux", () => {
|
||||
assert.equal(selectMuxBackend({ herdr: true, tmux: true }), "herdr");
|
||||
assert.equal(selectMuxBackend({ herdr: true, tmux: false }), "herdr");
|
||||
assert.equal(selectMuxBackend({ herdr: false, tmux: true }), "tmux");
|
||||
assert.equal(selectMuxBackend({ herdr: false, tmux: false }), null);
|
||||
it("prefers Herdr, then Orca, and otherwise falls back to tmux", () => {
|
||||
assert.equal(selectMuxBackend({ herdr: true, orca: true, tmux: true }), "herdr");
|
||||
assert.equal(selectMuxBackend({ herdr: true, orca: false, tmux: false }), "herdr");
|
||||
assert.equal(selectMuxBackend({ herdr: false, orca: true, tmux: true }), "orca");
|
||||
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", () => {
|
||||
@@ -3172,6 +3180,39 @@ describe("surface backend selection", () => {
|
||||
);
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user