Compare commits

..
6 Commits
Author SHA1 Message Date
jb a3cd328ede Drop web_search/web_fetch from researcher and worker agents
The researcher now relies on its web skills (firecrawl) through safe_bash
instead of the web tool extensions; the worker's toolset shrinks to
read/write/edit/bash.
2026-08-30 15:56:23 -04:00
jb 0b7af601a3 Fix fork-mode lifecycle test and default test model to gpt-5.6-luna
The fork test asked the model to call subagent with fork: true and no
agent, which the tool schema has never supported - it only passed when a
model improvised. It now spawns a real fork-mode agent (test-fork with
session-mode: fork).

The harness default model is now openai-codex/gpt-5.6-luna. The previous
active-session-model fallback broke under pi -ne, where extension-registered
providers do not resolve.
2026-08-30 15:55:15 -04:00
jb 208cd6f4e7 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
2026-08-30 15:54:59 -04:00
jb bc3e3d6049 Expose theme-aware status widget integration 2026-08-30 13:00:14 -04:00
jb 9a8169523b Preserve active subagents across reload 2026-08-27 01:05:46 -04:00
jb 4105b03981 Add forked reference in readme 2026-08-26 11:18:20 -04:00
14 changed files with 2020 additions and 1660 deletions
+32 -10
View File
@@ -1,28 +1,31 @@
## Forked from [amosblomqvist/pi-interactive-subagents](https://github.com/amosblomqvist/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
`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 ─╮
│ 00:23 scout active · bash 7m │
│ 00:45 scout-2 waiting 2m │
╰────────────────────────────────────────────────────╯
Subagents 2 running
⟳ 00:23 scout active · bash 7m
○ 00:45 scout-2 waiting 2m
```
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
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.
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 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:
```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
@@ -144,7 +147,23 @@ Access is **whitelist-only**. Every sub-agent process is launched with `--no-ext
Spawns must name a known agent at **every** depth. A top-level session may spawn anything discoverable; a sub-agent may only spawn the agents in its `subagent_agents` list (enforced via `PI_SUBAGENT_ALLOWED`). There is no agentless spawn route, so a child can never escalate to a full-toolset profile by omitting its agent.
Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global.
Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global. Hosts that compose widgets can optionally use its reload-safe `statusWidget` surface:
```typescript
const { statusWidget } = globalThis.__pi_interactive_subagents;
const onChange = () => rerender();
const release = statusWidget.claim(onChange);
statusWidget.hasContent(); // whether any subagent rows exist
statusWidget.getBodyRowCount(); // uncapped number of running agents
statusWidget.render(width, maxBodyRows, ctx.ui.theme); // compact unboxed lines; cap includes an overflow row
statusWidget.isClaimed(onChange); // true only for the current provider claim
release(); // restore the standalone subagent-status widget
```
`render(width, maxBodyRows?, theme?)` accepts Pi's current theme as an optional structural value. The standalone widget receives and uses its own Pi theme automatically. When no theme is passed, a legacy ANSI fallback keeps status colors usable. The header is a compact unboxed `Subagents <count> running` row; body rows remain width-safe, and `maxBodyRows` counts body rows including the overflow summary, not the header. Semantic row colors match Pi's Todo conventions: accent header, muted count, warning active/running, error stalled, dim starting/provider tags/elapsed time, and accent waiting.
`claim()` replaces any prior claim, and an old release function cannot release a newer claim. Standalone-widget clearing/restoration is deferred to a microtask, so hosts may release and immediately re-claim without a transient fallback widget. `isClaimed(onChange)` checks both callback identity and the current runtime ownership, so hosts can detect invalidation after reload/session rebind. Claims are cleared during session shutdown, including `/reload`, so a fresh host runtime must claim again rather than retaining a stale callback. Calling `render()` directly does not claim or change the standalone widget.
## Role folders
@@ -180,10 +199,13 @@ Status display is configured via `config.json` in the extension directory (copy
- [pi](https://github.com/badlogic/pi-mono)
- One supported terminal manager:
- [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)
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:
```bash
+3 -3
View File
@@ -1,7 +1,7 @@
---
name: researcher
description: Web researcher — searches the web and synthesizes findings
tools: web_search, web_fetch, safe_bash
tools: safe_bash
model: openai-codex/gpt-5.6-luna
thinking: xhigh
system-prompt: append
@@ -14,9 +14,9 @@ You operate in an isolated context with no knowledge of any prior conversation.
Process:
1. Break the question into 2-4 searchable facets
2. Search with `web_search` using varied angles
2. Search with the web using varied angles
3. Read the answers. Identify what's well-covered, what has gaps.
4. For the 2-3 most promising source URLs, use `web_fetch` to get full page content
4. For the 2-3 most promising source URLs, use firecrawl to get full page content
5. Synthesize everything into a brief that directly answers the question
Search strategy — always vary your angles:
+2 -2
View File
@@ -1,7 +1,7 @@
---
name: worker
description: General-purpose worker — reads, writes, and edits code
tools: read, write, edit, bash, web_search, web_fetch
tools: read, write, edit, bash
subagent_agents: scout, researcher
model: openai-codex/gpt-5.6-luna
thinking: xhigh
@@ -26,7 +26,7 @@ Your context is finite. Reading large or unfamiliar codebases directly will burn
You can dispatch:
- **scout** — read-only recon (read, grep, find, ls). Returns a structured map of files, line ranges, and key snippets. Cheap (haiku). Use for *exploring unfamiliar territory*.
- **researcher** — web research (web_search, web_fetch). Returns a sourced brief. Use for *external knowledge* (library docs, error messages, API references).
- **researcher** — web research. Returns a sourced brief. Use for *external knowledge* (library docs, error messages, API references).
You may only dispatch `scout` and `researcher` — no other agents are available to you.
@@ -0,0 +1,5 @@
# Lease the status widget to optional compositors
Subagents exposes its running-status panel through a structural, process-local interface while retaining ownership of the standalone widget. A host claims a callback-identified lease and must verify it before rendering; runtime replacement invalidates the lease and stale releases cannot affect a newer owner. This avoids a package dependency or shared TUI class identity while preserving running subagents across reload and restoring the standalone panel whenever no valid host owns it.
The composable surface is `statusWidget.render(width, maxBodyRows?, theme?)`. The optional third argument accepts the small structural `{ fg(color, text) }` theme contract, allowing hosted output to use the current Pi theme without importing a shared Theme class. Standalone rendering passes its factory-provided Pi theme. Calls without a theme use a legacy ANSI fallback. Both hosted and standalone output use a compact unboxed `Subagents <count> running` header followed by capped, width-safe body rows; the cap includes an overflow row but not the header. Status colors remain semantic: active/running warning, stalled error, starting dim, waiting accent, with provider tags and elapsed times dimmed.
+344 -1352
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -17,8 +17,8 @@
"test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts"
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*",
"@mariozechner/pi-tui": "*",
"@mariozechner/pi-coding-agent": ">=0.68.0",
"@mariozechner/pi-tui": ">=0.68.0",
"@sinclair/typebox": "*"
},
"pi": {
@@ -27,8 +27,8 @@
]
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "^0.65.0",
"@mariozechner/pi-tui": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.68.0",
"@mariozechner/pi-tui": "^0.68.0",
"@sinclair/typebox": "^0.34.49"
}
}
File diff suppressed because it is too large Load Diff
+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 {
+13
View File
@@ -0,0 +1,13 @@
---
name: test-fork
description: Integration test agent — fork-mode child that inherits the parent session
model: anthropic/claude-haiku-4-5
tools: read, bash
session-mode: fork
auto-exit: true
disable-model-invocation: true
---
You are a test agent. Complete the task given to you immediately. Be direct and concise.
When asked to write content to a file, do it right away using the bash tool.
Do not ask questions. Do not explain. Just execute the task.
+56 -7
View File
@@ -73,13 +73,14 @@ const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.
// ── Configuration ──
/** Model used for integration tests. Override with PI_TEST_MODEL env var. */
const activePiModel =
process.env.PI_PROVIDER && process.env.PI_MODEL
? `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}`
: undefined;
export const TEST_MODEL =
process.env.PI_TEST_MODEL ?? activePiModel ?? "anthropic/claude-haiku-4-5";
/**
* Model used for integration tests. Override with PI_TEST_MODEL env var.
*
* The default must be a provider that exists under `pi -ne`: startPi() launches
* with extensions disabled, so extension-registered providers (and therefore
* the parent session's active model) may not resolve in the child.
*/
export const TEST_MODEL = process.env.PI_TEST_MODEL ?? "openai-codex/gpt-5.6-luna";
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
@@ -97,7 +98,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 +146,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;
+60 -8
View File
@@ -1,18 +1,18 @@
/**
* Integration tests for the full subagent lifecycle.
*
* These tests spawn REAL pi sessions with REAL LLM calls (haiku by default).
* These tests spawn REAL pi sessions with REAL LLM calls (gpt-5.6-luna by default).
* Each test creates a terminal surface, runs pi with a task that uses the subagent
* tool, and verifies the outcome via marker files and screen output.
*
* Costs: ~$0.01-0.05 per test run (haiku).
* Costs: ~$0.01-0.05 per test run.
* 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:
* PI_TEST_MODEL — model for all pi sessions (default: anthropic/claude-haiku-4-5)
* PI_TEST_MODEL — model for all pi sessions (default: openai-codex/gpt-5.6-luna)
* PI_TEST_TIMEOUT — per-test timeout in ms (default: 120000)
*/
import { describe, it, before, after } from "node:test";
@@ -30,6 +30,7 @@ import {
uniqueId,
trackTempFile,
readScreen,
sendCommand,
PI_TIMEOUT,
type TestEnv,
} from "./harness.ts";
@@ -37,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) {
@@ -103,6 +104,57 @@ for (const backend of backends) {
}
});
// ── Reload preservation ──
it("keeps a running subagent and fresh tool wrappers across parent extension reload", async () => {
const id = uniqueId();
const startedFile = `/tmp/pi-integ-reload-start-${id}.txt`;
const completedFile = `/tmp/pi-integ-reload-done-${id}.txt`;
const followupFile = `/tmp/pi-integ-reload-followup-${id}.txt`;
trackTempFile(env, startedFile);
trackTempFile(env, completedFile);
trackTempFile(env, followupFile);
const surface = createTrackedSurface(env, `reload-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool once with these EXACT parameters:`,
` name: "Reload-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'START_${id}' > '${startedFile}'; sleep 10; echo 'DONE_${id}' > '${completedFile}'"`,
`Do not run the command yourself.`,
`After that subagent result arrives, call the subagent tool once more with these EXACT parameters:`,
` name: "Reload-followup-${id}"`,
` agent: "test-echo"`,
` task: "Write 'FOLLOWUP_${id}' to '${followupFile}'"`,
`After the second subagent result arrives, say RELOAD_SURVIVED_${id}.`,
].join("\n");
startPi(surface, env.dir, task);
await waitForFile(startedFile, PI_TIMEOUT, /START_/);
sendCommand(surface, "/reload");
const completed = await waitForFile(completedFile, PI_TIMEOUT, /DONE_/);
assert.ok(completed.includes(`DONE_${id}`), "child should finish after parent reload");
const followup = await waitForFile(followupFile, PI_TIMEOUT, /FOLLOWUP_/);
assert.ok(
followup.includes(`FOLLOWUP_${id}`),
"the result-triggered turn should spawn through the fresh tool wrapper",
);
const resultMarker = `RELOAD_SURVIVED_${id}`;
const responseLine = new RegExp(`^ ${resultMarker}\\s*$`, "m");
const screen = await waitForScreen(surface, responseLine, PI_TIMEOUT, 300);
assert.equal(
screen.match(new RegExp(`^ ${resultMarker}\\s*$`, "gm"))?.length,
1,
"the fresh runtime should receive the completion exactly once",
);
assert.doesNotMatch(screen, /Subagent cancelled|ctx is stale after session replacement or reload/i);
});
// ── In-progress activity snapshots ──
it("keeps a long active tool call from surfacing false stalled status", async () => {
@@ -201,9 +253,9 @@ for (const backend of backends) {
const task = [
`Call the subagent tool with these EXACT parameters:`,
` name: "Fork-${id}"`,
` fork: true`,
` agent: "test-fork"`,
` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`,
`Do not set the agent parameter. Just set name, fork, and task.`,
`Do not do anything else. Just call the subagent tool once.`,
`After you receive the result, say FORK_COMPLETE.`,
].join("\n");
+4 -4
View File
@@ -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) {
+527 -7
View File
@@ -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,
@@ -88,16 +93,22 @@ function createMockExtensionApi() {
const registeredTools: Array<any> = [];
const registeredCommands: Array<any> = [];
const registeredMessageRenderers: Array<any> = [];
const eventHandlers = new Map<string, Array<(event: any, ctx: any) => any>>();
const sentUserMessages: string[] = [];
const sentMessages: Array<any> = [];
return {
registeredTools,
registeredCommands,
registeredMessageRenderers,
eventHandlers,
sentUserMessages,
sentMessages,
api: {
on() {},
on(event: string, handler: (event: any, ctx: any) => any) {
const handlers = eventHandlers.get(event) ?? [];
handlers.push(handler);
eventHandlers.set(event, handlers);
},
registerTool(tool: any) {
registeredTools.push(tool);
},
@@ -117,10 +128,55 @@ function createMockExtensionApi() {
getAllTools() {
return [];
},
getActiveTools() {
return [];
},
} as any,
};
}
async function emitMockEvent(
mock: ReturnType<typeof createMockExtensionApi>,
event: string,
payload: Record<string, unknown>,
ctx: Record<string, unknown> = {},
) {
for (const handler of mock.eventHandlers.get(event) ?? []) {
await handler({ type: event, ...payload }, ctx);
}
}
type SharedRuntimeTestState = {
runningSubagents: Map<string, unknown>;
latestCtx: unknown;
latestPi: unknown;
widgetInterval: ReturnType<typeof setInterval> | null;
statusInterval: ReturnType<typeof setInterval> | null;
standaloneWidgetSyncScheduled: boolean;
pendingDeliveries: { length: number };
deliveryFlushScheduled: boolean;
activeRuntimeToken: symbol | null;
registeredRuntimeToken: symbol | null;
statusWidgetClaim: unknown;
statusWidget?: unknown;
};
function resetSharedRuntimeForTest(shared: SharedRuntimeTestState): void {
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
if (shared.statusInterval) clearInterval(shared.statusInterval);
shared.widgetInterval = null;
shared.statusInterval = null;
shared.standaloneWidgetSyncScheduled = false;
shared.runningSubagents.clear();
shared.latestCtx = null;
shared.latestPi = null;
shared.statusWidgetClaim = null;
shared.pendingDeliveries.length = 0;
shared.deliveryFlushScheduled = false;
shared.activeRuntimeToken = null;
shared.registeredRuntimeToken = null;
}
function restoreEnvVar(name: string, value: string | undefined) {
if (value === undefined) {
delete process.env[name];
@@ -1838,6 +1894,79 @@ describe("commands", () => {
});
});
describe("reload lifecycle", () => {
const testApi = (subagentsModule as any).__test__;
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
const ctx = { hasUI: false, ui: { setWidget() {} } };
beforeEach(() => resetSharedRuntimeForTest(shared));
after(() => resetSharedRuntimeForTest(shared));
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
const oldRuntime = createMockExtensionApi();
(subagentsModule as any).default(oldRuntime.api);
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, ctx);
const abortController = new AbortController();
const running: {
id: string;
name: string;
task: string;
surface: string;
startTime: number;
sessionFile: string;
statusState: ReturnType<typeof createStatusState>;
interactive: boolean;
abortController: AbortController;
suppressDelivery?: boolean;
} = {
id: "reload-child",
name: "reload-child",
task: "keep running",
surface: "surface-1",
startTime: Date.now(),
sessionFile: "/tmp/reload-child.jsonl",
statusState: createStatusState({ source: "pi", startTimeMs: Date.now() }),
interactive: false,
abortController,
};
shared.runningSubagents.set(running.id, running);
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, ctx);
assert.equal(abortController.signal.aborted, false, "reload must not cancel the child");
assert.equal(shared.runningSubagents.get(running.id), running);
testApi.sendWhenRuntimeReady((pi: any) => {
pi.sendMessage({ customType: "subagent_result", content: "done", display: true });
});
assert.equal(oldRuntime.sentMessages.length, 0, "the stale runtime must not receive completion");
assert.equal(shared.pendingDeliveries.length, 1);
const freshRuntime = createMockExtensionApi();
(subagentsModule as any).default(freshRuntime.api);
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, ctx);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(freshRuntime.sentMessages.length, 1);
assert.equal(freshRuntime.sentMessages[0].message.customType, "subagent_result");
assert.equal(oldRuntime.sentMessages.length, 0);
assert.equal(shared.pendingDeliveries.length, 0);
assert.equal(shared.runningSubagents.get(running.id), running);
testApi.flushPendingDeliveries();
assert.equal(freshRuntime.sentMessages.length, 1, "a queued completion must flush exactly once");
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "quit" }, ctx);
assert.equal(abortController.signal.aborted, false, "a stale runtime cannot cancel rebound state");
assert.equal(shared.runningSubagents.get(running.id), running);
await emitMockEvent(freshRuntime, "session_shutdown", { reason: "quit" }, ctx);
assert.equal(abortController.signal.aborted, true, "real shutdown still cancels the child");
assert.equal(running.suppressDelivery, true);
assert.equal(shared.runningSubagents.size, 0);
});
});
describe("tool registration", () => {
it("always resumes subagents as autonomous (auto-exit, non-interactive tracking)", () => {
const testApi = (subagentsModule as any).__test__;
@@ -2489,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);
});
@@ -2508,6 +2638,71 @@ describe("subagent startup delay", () => {
});
});
describe("subagents widget rendering", () => {
function createThemeSpy() {
const calls: Array<{ color: string; text: string }> = [];
return {
calls,
theme: {
fg(color: string, text: string) {
calls.push({ color, text });
return `\x1b[38;5;${calls.length}m${text}\x1b[0m`;
},
},
};
}
function createWidgetAgent(
id: string,
kind: "active" | "running" | "stalled" | "starting" | "waiting",
) {
const startTime = kind === "starting" ? 1_061_000 - 5_000 : 1_000_000 - 5_000;
let statusState = createStatusState({
source: kind === "running" ? "claude" : "pi",
startTimeMs: startTime,
});
if (kind === "active" || kind === "waiting") {
statusState = observeStatus(
statusState,
{
snapshot: "present",
updatedAt: startTime + 1_000,
sequence: 1,
phase: kind,
...(kind === "active"
? {
active: true,
activeScope: "tool",
activeSince: startTime + 1_000,
activityLabel: "bash",
}
: { waitingSince: startTime + 1_000 }),
},
startTime + 1_000,
);
} else if (kind === "stalled") {
statusState = observeStatus(
statusState,
{ snapshot: "missing" },
startTime + 1_000,
);
}
return {
id,
name: id,
agent: "worker",
task: "",
surface: "surface",
startTime,
sessionFile: `${id}.jsonl`,
statusState,
};
}
function createActiveWidgetAgent(id: string) {
return createWidgetAgent(id, "active");
}
it("keeps every rendered line within a very narrow width", () => {
const testApi = (subagentsModule as any).__test__;
assert.ok(testApi, "expected subagents test helpers to be exported");
@@ -2548,13 +2743,33 @@ describe("subagents widget rendering", () => {
assert.deepEqual(
lines.map((line: string) => visibleWidth(line)),
[16, 16, 16, 16, 16],
[16, 16, 16, 16],
);
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
} finally {
Date.now = originalNow;
}
});
it("sanitizes whitespace in agent names before rendering rows", () => {
const testApi = (subagentsModule as any).__test__;
const startTime = Date.now() - 5_000;
const lines = testApi.renderSubagentWidgetLines([
{
id: "unsafe",
name: "worker\nwith\ttabs",
task: "",
surface: "s1",
startTime,
sessionFile: "sess1",
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
},
], 80);
assert.ok(lines.every((line: string) => !/[\r\n]/.test(line)));
assert.match(lines[1] ?? "", /worker with tabs/);
});
it("truncates the right-hand status instead of overflowing when it alone is too wide", () => {
const testApi = (subagentsModule as any).__test__;
assert.ok(testApi, "expected subagents test helpers to be exported");
@@ -2592,6 +2807,276 @@ describe("subagents widget rendering", () => {
}
}
});
it("uses the standalone widget factory theme for semantic status colors", () => {
const testApi = (subagentsModule as any).__test__;
const widgetCalls: any[] = [];
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
shared.latestCtx = {
hasUI: true,
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
};
shared.runningSubagents.set("active", createActiveWidgetAgent("active"));
testApi.updateWidget();
const { theme, calls } = createThemeSpy();
const component = widgetCalls[0][1](undefined, theme);
const lines = withMockedNow(1_000_000, () => component.render(80));
assert.equal(lines.length, 2, "header plus one body row");
assert.equal(calls.find((call) => call.text === "Subagents")?.color, "accent");
assert.equal(calls.find((call) => call.text === "1 running")?.color, "muted");
assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active")));
assert.equal(calls.find((call) => call.text === " (worker)")?.color, "dim");
assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05"));
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
});
it("uses the optional theme passed to hosted rendering", () => {
const testApi = (subagentsModule as any).__test__;
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
shared.runningSubagents.set("active", createActiveWidgetAgent("active"));
const { theme, calls } = createThemeSpy();
const lines = withMockedNow(1_000_000, () => testApi.statusWidget.render(80, undefined, theme));
assert.equal(lines.length, 2, "header plus one body row");
assert.ok(calls.some((call) => call.color === "accent" && call.text === "Subagents"));
assert.ok(calls.some((call) => call.color === "muted" && call.text === "1 running"));
assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active")));
assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05"));
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
});
it("maps every status kind to a semantic row color", () => {
const testApi = (subagentsModule as any).__test__;
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
for (const [id, kind] of [
["active", "active"],
["running", "running"],
["stalled", "stalled"],
["starting", "starting"],
["waiting", "waiting"],
] as const) {
shared.runningSubagents.set(id, createWidgetAgent(id, kind));
}
const { theme, calls } = createThemeSpy();
withMockedNow(1_061_000, () => testApi.statusWidget.render(120, undefined, theme));
const expected = {
active: "warning",
running: "warning",
stalled: "error",
starting: "dim",
waiting: "accent",
} as const;
for (const [id, kind] of [
["active", "active"],
["running", "running"],
["stalled", "stalled"],
["starting", "starting"],
["waiting", "waiting"],
] as const) {
assert.ok(
calls.some((call) => call.text === id && call.color === expected[kind]),
`${id} should use ${expected[kind]}`,
);
}
});
});
describe("status widget host integration", () => {
const testApi = (subagentsModule as any).__test__;
const statusWidget = (globalThis as any).__pi_interactive_subagents.statusWidget;
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
function addRunning(id: string) {
const startTime = Date.now();
shared.runningSubagents.set(id, {
id,
name: id,
task: "",
surface: "surface",
startTime,
sessionFile: `${id}.jsonl`,
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
});
}
beforeEach(() => resetSharedRuntimeForTest(shared));
after(() => resetSharedRuntimeForTest(shared));
it("falls back to the standalone widget when unclaimed", () => {
const widgetCalls: any[] = [];
shared.latestCtx = {
hasUI: true,
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
};
addRunning("worker");
testApi.updateWidget();
assert.equal(widgetCalls.length, 1);
assert.equal(widgetCalls[0][0], "subagent-status");
assert.equal(typeof widgetCalls[0][1], "function");
});
it("suppresses, notifies, and restores the standalone widget around a claim", async () => {
const widgetCalls: any[] = [];
shared.latestCtx = {
hasUI: true,
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
};
addRunning("worker");
testApi.updateWidget();
let changes = 0;
const onChange = () => changes++;
const release = statusWidget.claim(onChange);
assert.equal(statusWidget.isClaimed(onChange), true);
assert.equal(statusWidget.isClaimed(() => {}), false);
assert.equal(typeof widgetCalls.at(-1)[1], "function", "claim sync is deferred");
await Promise.resolve();
assert.equal(widgetCalls.at(-1)[1], undefined);
testApi.updateWidget();
assert.equal(changes, 1);
release();
assert.equal(statusWidget.isClaimed(onChange), false);
assert.equal(widgetCalls.at(-1)[1], undefined, "release sync is deferred");
await Promise.resolve();
assert.equal(typeof widgetCalls.at(-1)[1], "function");
release();
assert.equal(widgetCalls.length, 4, "release is idempotent");
});
it("does not restore standalone UI between an immediate release and re-claim", async () => {
const widgetCalls: any[] = [];
shared.latestCtx = {
hasUI: true,
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
};
addRunning("worker");
testApi.updateWidget();
const firstChange = () => {};
const releaseFirst = statusWidget.claim(firstChange);
releaseFirst();
const secondChange = () => {};
const releaseSecond = statusWidget.claim(secondChange);
await Promise.resolve();
assert.equal(statusWidget.isClaimed(firstChange), false);
assert.equal(statusWidget.isClaimed(secondChange), true);
assert.equal(widgetCalls.at(-1)[1], undefined);
releaseSecond();
await Promise.resolve();
assert.equal(typeof widgetCalls.at(-1)[1], "function");
});
it("caps body rows with an honest overflow row", () => {
addRunning("worker");
addRunning("scout");
addRunning("reviewer");
const lines = statusWidget.render(40, 2);
assert.equal(lines.length, 3, "header + one agent + overflow");
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
assert.match(lines[2], /\+2 more running\./);
assert.equal(statusWidget.getBodyRowCount(), 3);
assert.equal(statusWidget.hasContent(), true);
});
it("keeps capped output within narrow widths", () => {
addRunning("worker");
addRunning("scout");
for (const width of [0, 1, 2, 4, 8]) {
for (const line of statusWidget.render(width, 1)) {
assert.ok(
visibleWidth(line) <= width,
`expected line width <= ${width}, got ${visibleWidth(line)}`,
);
}
}
});
it("drops old claims during reload and allows a fresh runtime to claim", async () => {
const oldRuntime = createMockExtensionApi();
(subagentsModule as any).default(oldRuntime.api);
const oldCtx = {
hasUI: true,
ui: { setWidget() {} },
};
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, oldCtx);
addRunning("worker");
let oldChanges = 0;
const oldOnChange = () => oldChanges++;
const oldRelease = statusWidget.claim(oldOnChange);
// Bind the replacement before the old runtime emits shutdown. The old
// shutdown must not undo the fresh binding or retain its old callback.
const freshRuntime = createMockExtensionApi();
(subagentsModule as any).default(freshRuntime.api);
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, oldCtx);
// A delayed old start must also be ignored once the fresh runtime is bound.
await emitMockEvent(oldRuntime, "session_start", { reason: "reload" }, oldCtx);
assert.equal(shared.latestPi, freshRuntime.api);
assert.equal(statusWidget.isClaimed(oldOnChange), false);
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, oldCtx);
testApi.updateWidget();
assert.equal(oldChanges, 0);
assert.equal(shared.statusWidgetClaim, null);
assert.equal(shared.runningSubagents.size, 1);
let freshChanges = 0;
const freshRelease = statusWidget.claim(() => freshChanges++);
oldRelease();
testApi.updateWidget();
assert.equal(freshChanges, 1, "a stale release cannot affect the fresh claim");
freshRelease();
});
it("replaces malformed and old widget contracts on a duplicate load", async () => {
const globalApi = (globalThis as any).__pi_interactive_subagents;
(globalThis as any).__pi_interactive_subagents = "old-api";
shared.statusWidgetClaim = { id: 1, onChange: () => {} };
await import(`../pi-extension/subagents/index.ts?malformed-global=${Date.now()}`);
const repairedApi = (globalThis as any).__pi_interactive_subagents;
assert.equal(typeof repairedApi.registerToolExtension, "function");
assert.equal(repairedApi.statusWidget, globalApi.statusWidget);
shared.statusWidgetClaim = { id: 1, onChange: () => {} };
await import(`../pi-extension/subagents/index.ts?old-widget-claim=${Date.now()}`);
assert.equal(shared.statusWidgetClaim, null);
const oldContract = {
hasContent: () => true,
getBodyRowCount: () => 1,
render: () => [],
claim: () => () => {},
};
shared.statusWidget = oldContract;
(globalThis as any).__pi_interactive_subagents.statusWidget = oldContract;
await import(`../pi-extension/subagents/index.ts?old-widget-contract=${Date.now()}`);
const currentApi = (globalThis as any).__pi_interactive_subagents;
const current = currentApi.statusWidget;
assert.equal(currentApi, repairedApi);
assert.notEqual(current, oldContract);
assert.equal(typeof current.hasContent, "function");
assert.equal(typeof current.getBodyRowCount, "function");
assert.equal(typeof current.render, "function");
assert.equal(typeof current.claim, "function");
assert.equal(typeof current.isClaimed, "function");
assert.equal(shared.statusWidget, current);
});
});
describe("subagent display helpers", () => {
@@ -2670,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", () => {
@@ -2693,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", () => {