Adding support for herdr - agents in new tabs

This commit is contained in:
jb
2026-08-26 10:57:45 -04:00
parent c3e8b53c07
commit b3884f6c35
14 changed files with 660 additions and 331 deletions
+2
View File
@@ -2,3 +2,5 @@ node_modules/
config.json config.json
.DS_Store .DS_Store
.memory/ .memory/
.pi/
.worktrees/
+18 -12
View File
@@ -1,12 +1,12 @@
# pi-interactive-subagents # pi-interactive-subagents
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in 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 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.
**tmux-only fork.** See [Acknowledgements](#acknowledgements) for the upstream project, which also supports cmux, zellij, and WezTerm. 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.
## How it works ## How it works
`subagent()` returns immediately. The sub-agent runs in its own tmux pane — a right split off the parent pi pane, so pane creation never steals keyboard focus. 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 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 ─╮
@@ -17,19 +17,19 @@ Async subagents for [pi](https://github.com/badlogic/pi-mono), running in tmux p
Spawn several in parallel — they run concurrently and steer results back independently as each finishes. Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
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`, …). 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.
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 # default: 500 export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, tmux 500
``` ```
## Tools ## Tools
| Tool | Description | | Tool | Description |
| --- | --- | | --- | --- |
| `subagent` | Spawn a sub-agent in a dedicated tmux pane (async) | | `subagent` | Spawn a sub-agent in a dedicated terminal surface (async) |
| `subagent_message` | Message a sub-agent by name — steers it if running, resumes its session if finished | | `subagent_message` | Message a sub-agent by name — steers it if running, resumes its session if finished |
| `subagents_list` | List available agent definitions | | `subagents_list` | List available agent definitions |
| `ask_question` | *(sub-agent sessions only)* Ask the orchestrator a question and wait for the reply | | `ask_question` | *(sub-agent sessions only)* Ask the orchestrator a question and wait for the reply |
@@ -76,9 +76,9 @@ If the reply arrives while the sub-agent is still mid-turn, it is absorbed into
| Agent | Model | Tools | Role | | Agent | Model | Tools | Role |
| ----- | ----- | ----- | ---- | | ----- | ----- | ----- | ---- |
| **scout** | `openrouter/z-ai/glm-5.3` | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon | | **scout** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon |
| **researcher** | `openrouter/z-ai/glm-5.3` | `web_search`, `web_fetch`, `safe_bash` | Web research, synthesized into a sourced brief | | **researcher** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `web_search`, `web_fetch`, `safe_bash` | Web research, synthesized into a sourced brief |
| **worker** | `openrouter/z-ai/glm-5.3` | `read`, `write`, `edit`, `bash`, `web_search`, `web_fetch` + spawning | General implementer; may spawn `scout` and `researcher` | | **worker** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `write`, `edit`, `bash`, `web_search`, `web_fetch` + spawning | General implementer; may spawn `scout` and `researcher` |
All three are autonomous (`auto-exit: true`) and carry their identity in the system prompt (`system-prompt: append`). All three are autonomous (`auto-exit: true`) and carry their identity in the system prompt (`system-prompt: append`).
@@ -90,8 +90,8 @@ Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global).
--- ---
name: my-agent name: my-agent
description: Does something specific description: Does something specific
model: openrouter/z-ai/glm-5.3 model: openai-codex/gpt-5.6-luna
thinking: medium thinking: xhigh
tools: read, edit, write, safe_bash, web_search tools: read, edit, write, safe_bash, web_search
session-mode: lineage-only session-mode: lineage-only
auto-exit: true auto-exit: true
@@ -178,7 +178,13 @@ Status display is configured via `config.json` in the extension directory (copy
## Requirements ## Requirements
- [pi](https://github.com/badlogic/pi-mono) - [pi](https://github.com/badlogic/pi-mono)
- [tmux](https://github.com/tmux/tmux) - One supported terminal manager:
- [Herdr](https://herdr.dev), with Pi launched inside Herdr
- [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.
For tmux:
```bash ```bash
tmux new -A -s pi 'pi' tmux new -A -s pi 'pi'
+2 -2
View File
@@ -2,8 +2,8 @@
name: researcher name: researcher
description: Web researcher — searches the web and synthesizes findings description: Web researcher — searches the web and synthesizes findings
tools: web_search, web_fetch, safe_bash tools: web_search, web_fetch, safe_bash
model: openrouter/z-ai/glm-5.3 model: openai-codex/gpt-5.6-luna
thinking: medium thinking: xhigh
system-prompt: append system-prompt: append
auto-exit: true auto-exit: true
--- ---
+2 -2
View File
@@ -2,8 +2,8 @@
name: scout name: scout
description: Fast codebase recon — explores files, finds patterns, maps architecture description: Fast codebase recon — explores files, finds patterns, maps architecture
tools: read, grep, find, ls tools: read, grep, find, ls
model: openrouter/z-ai/glm-5.3 model: openai-codex/gpt-5.6-luna
thinking: low thinking: xhigh
system-prompt: append system-prompt: append
auto-exit: true auto-exit: true
--- ---
+2 -2
View File
@@ -3,8 +3,8 @@ name: worker
description: General-purpose worker — reads, writes, and edits code description: General-purpose worker — reads, writes, and edits code
tools: read, write, edit, bash, web_search, web_fetch tools: read, write, edit, bash, web_search, web_fetch
subagent_agents: scout, researcher subagent_agents: scout, researcher
model: openrouter/z-ai/glm-5.3 model: openai-codex/gpt-5.6-luna
thinking: high thinking: xhigh
system-prompt: append system-prompt: append
auto-exit: true auto-exit: true
--- ---
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "pi-interactive-subagents", "name": "pi-interactive-subagents",
"version": "3.7.2", "version": "3.7.2",
"description": "Interactive subagents for pi - spawn, orchestrate, and manage sub-agent sessions in tmux", "description": "Interactive subagents for pi - spawn, orchestrate, and manage sub-agent sessions in Herdr or tmux",
"keywords": [ "keywords": [
"pi-package" "pi-package"
], ],
+178
View File
@@ -0,0 +1,178 @@
/** Herdr surface backend. Subagents run in unfocused tabs in the current workspace. */
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const commandAvailability = new Map<string, boolean>();
const tabByPane = new Map<string, string>();
interface HerdrTabCreated {
result?: {
root_pane?: { pane_id?: unknown };
tab?: { tab_id?: unknown };
};
}
interface HerdrPaneCreated {
result?: {
pane?: { pane_id?: unknown };
};
}
function hasCommand(command: string): boolean {
const cached = commandAvailability.get(command);
if (cached !== undefined) return cached;
let available = false;
try {
execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
available = true;
} catch {
available = false;
}
commandAvailability.set(command, available);
return available;
}
export function isAvailable(): boolean {
return (
process.env.HERDR_ENV === "1" &&
!!process.env.HERDR_WORKSPACE_ID &&
!!process.env.HERDR_PANE_ID &&
hasCommand("herdr")
);
}
export function setupHint(): string {
return "Start pi inside a Herdr-managed pane with the `herdr` CLI on PATH.";
}
function requireHerdr(): void {
if (!isAvailable()) {
throw new Error(`Herdr is required for this surface. ${setupHint()}`);
}
}
function parseJson<T>(command: string, output: string): T {
try {
return JSON.parse(output) as T;
} catch {
throw new Error(`Unexpected Herdr ${command} output: ${output.trim() || "(empty)"}`);
}
}
function requireId(value: unknown, command: string, field: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Herdr ${command} did not return ${field}`);
}
return value;
}
export function parseCreatedTab(output: string): { paneId: string; tabId: string } {
const response = parseJson<HerdrTabCreated>("tab create", output);
return {
paneId: requireId(response.result?.root_pane?.pane_id, "tab create", "result.root_pane.pane_id"),
tabId: requireId(response.result?.tab?.tab_id, "tab create", "result.tab.tab_id"),
};
}
export function parseCreatedPane(output: string): string {
const response = parseJson<HerdrPaneCreated>("pane split", output);
return requireId(response.result?.pane?.pane_id, "pane split", "result.pane.pane_id");
}
export function createSurface(name: string): string {
requireHerdr();
const workspace = process.env.HERDR_WORKSPACE_ID!;
const output = execFileSync(
"herdr",
[
"tab",
"create",
"--workspace",
workspace,
"--cwd",
process.cwd(),
"--label",
name,
"--no-focus",
],
{ encoding: "utf8" },
);
const { paneId, tabId } = parseCreatedTab(output);
tabByPane.set(paneId, tabId);
return paneId;
}
export function createSurfaceSplit(
name: string,
direction: "left" | "right" | "up" | "down",
fromSurface?: string,
): string {
requireHerdr();
if (direction === "left" || direction === "up") {
throw new Error(`Herdr pane splits only support right or down, not ${direction}`);
}
const source = fromSurface ?? process.env.HERDR_PANE_ID!;
const output = execFileSync(
"herdr",
[
"pane",
"split",
"--pane",
source,
"--direction",
direction,
"--cwd",
process.cwd(),
"--no-focus",
],
{ encoding: "utf8" },
);
const paneId = parseCreatedPane(output);
try {
execFileSync("herdr", ["pane", "rename", paneId, name], { encoding: "utf8" });
} catch {
// A cosmetic label must not break a successful split.
}
return paneId;
}
export function sendCommand(surface: string, command: string): void {
requireHerdr();
execFileSync("herdr", ["pane", "run", surface, command], { encoding: "utf8" });
}
export function readScreen(surface: string, lines = 50): string {
requireHerdr();
return execFileSync(
"herdr",
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
{ encoding: "utf8" },
);
}
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
requireHerdr();
const { stdout } = await execFileAsync(
"herdr",
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
{ encoding: "utf8" },
);
return stdout;
}
export function closeSurface(surface: string): void {
requireHerdr();
const tabId = tabByPane.get(surface);
if (tabId) {
execFileSync("herdr", ["tab", "close", tabId], { encoding: "utf8" });
tabByPane.delete(surface);
return;
}
execFileSync("herdr", ["pane", "close", surface], { encoding: "utf8" });
}
+54 -11
View File
@@ -24,7 +24,9 @@ import {
closeSurface, closeSurface,
shellEscape, shellEscape,
readScreen, readScreen,
} from "./tmux.ts"; getMuxBackend,
wrapWithCompletionFile,
} from "./surface.ts";
import { import {
countSessionEntryLines, countSessionEntryLines,
@@ -497,10 +499,15 @@ function widgetIcon(kind: StatusSnapshot["kind"]): string {
* (for example direnv/devenv), so the delay is configurable for users who hit * (for example direnv/devenv), so the delay is configurable for users who hit
* dropped commands. Keep the historical default at 500ms. * dropped commands. Keep the historical default at 500ms.
*/ */
function defaultShellReadyDelayMs(backend: ReturnType<typeof getMuxBackend>): number {
return backend === "herdr" ? 1000 : 500;
}
function getShellReadyDelayMs(): number { function getShellReadyDelayMs(): number {
const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim(); const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500; if (Number.isFinite(parsed) && parsed >= 0) return parsed;
return defaultShellReadyDelayMs(getMuxBackend());
} }
function muxUnavailableResult() { function muxUnavailableResult() {
@@ -508,10 +515,10 @@ function muxUnavailableResult() {
content: [ content: [
{ {
type: "text" as const, type: "text" as const,
text: `Subagents require tmux. ${muxSetupHint()}`, text: `Subagents require a supported terminal multiplexer. ${muxSetupHint()}`,
}, },
], ],
details: { error: "tmux not available" }, details: { error: "terminal multiplexer not available" },
}; };
} }
@@ -618,6 +625,7 @@ interface RunningSubagent {
abortController?: AbortController; abortController?: AbortController;
cli?: string; cli?: string;
sentinelFile?: string; sentinelFile?: string;
completionFile?: string;
statusState: SubagentStatusState; statusState: SubagentStatusState;
/** /**
* When true, status transitions (stalled/recovered) do not wake the parent * When true, status transitions (stalled/recovered) do not wake the parent
@@ -1008,7 +1016,7 @@ function steerSubagent(
} catch (error: any) { } catch (error: any) {
return { return {
error: error:
`Failed to deliver message to subagent "${running.name}" via tmux: ` + `Failed to deliver message to subagent "${running.name}" via the terminal surface: ` +
`${error?.message ?? String(error)}`, `${error?.message ?? String(error)}`,
}; };
} }
@@ -1121,6 +1129,7 @@ function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolea
export const __test__ = { export const __test__ = {
borderLine, borderLine,
defaultShellReadyDelayMs,
getShellReadyDelayMs, getShellReadyDelayMs,
renderSubagentWidgetLines, renderSubagentWidgetLines,
loadAgentDefaults, loadAgentDefaults,
@@ -1141,6 +1150,7 @@ export const __test__ = {
handleSubagentSteer, handleSubagentSteer,
resolveResultPresentation, resolveResultPresentation,
resolveResumeLaunchBehavior, resolveResumeLaunchBehavior,
wrapWithCompletionFile,
runningSubagents, runningSubagents,
formatElapsed, formatElapsed,
formatTokens, formatTokens,
@@ -1160,7 +1170,7 @@ function startWidgetRefresh() {
} }
/** /**
* Launch a subagent: creates the multiplexer pane, builds the command, and * Launch a subagent: creates a terminal surface, builds the command, and
* sends it. Returns a RunningSubagent — does NOT poll. * sends it. Returns a RunningSubagent — does NOT poll.
* *
* Call watchSubagent() on the returned object to observe completion. * Call watchSubagent() on the returned object to observe completion.
@@ -1184,6 +1194,8 @@ async function launchSubagent(
if (!sessionFile) throw new Error("No session file"); if (!sessionFile) throw new Error("No session file");
const sessionId = ctx.sessionManager.getSessionId(); const sessionId = ctx.sessionManager.getSessionId();
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId); const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
mkdirSync(dirname(completionFile), { recursive: true });
const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs); const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs);
const targetCwdForSession = effectiveCwd ?? ctx.cwd; const targetCwdForSession = effectiveCwd ?? ctx.cwd;
@@ -1201,14 +1213,15 @@ async function launchSubagent(
].join("-"); ].join("-");
const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`); const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
// Use pre-created surface (parallel mode) or create a new one. // Use a pre-created surface or create a new one. New shells need a brief
// For new surfaces, pause briefly so the shell is ready before sending the command. // startup window before terminal input is reliable.
const surfacePreCreated = !!options?.surface; const surfacePreCreated = !!options?.surface;
const surface = options?.surface ?? createSurface(params.name); const surface = options?.surface ?? createSurface(params.name);
if (!surfacePreCreated) { if (!surfacePreCreated) {
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs())); await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
} }
try {
const launchBehavior = resolveLaunchBehavior(params, agentDefs); const launchBehavior = resolveLaunchBehavior(params, agentDefs);
if (launchBehavior.seededSessionMode) { if (launchBehavior.seededSessionMode) {
@@ -1271,7 +1284,7 @@ async function launchSubagent(
cmdParts.push(shellEscape(params.task)); cmdParts.push(shellEscape(params.task));
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : ""; const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
const command = `${cdPrefix}${cmdParts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`; const command = wrapWithCompletionFile(`${cdPrefix}${cmdParts.join(" ")}`, completionFile);
const launchScriptName = `${(params.name || "subagent") const launchScriptName = `${(params.name || "subagent")
.toLowerCase() .toLowerCase()
@@ -1301,6 +1314,7 @@ async function launchSubagent(
launchScriptFile, launchScriptFile,
cli: "claude", cli: "claude",
sentinelFile, sentinelFile,
completionFile,
interactive: effectiveInteractive, interactive: effectiveInteractive,
statusState: createStatusState({ statusState: createStatusState({
source: "claude", source: "claude",
@@ -1414,7 +1428,7 @@ async function launchSubagent(
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : ""; const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
const piCommand = cdPrefix + envPrefix + parts.join(" "); const piCommand = cdPrefix + envPrefix + parts.join(" ");
const command = `${piCommand}; echo '__SUBAGENT_DONE_'$?'__'`; const command = wrapWithCompletionFile(piCommand, completionFile);
const launchScriptName = `${(params.name || "subagent") const launchScriptName = `${(params.name || "subagent")
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9\s-]/g, "") .replace(/[^a-z0-9\s-]/g, "")
@@ -1442,6 +1456,7 @@ async function launchSubagent(
sessionFile: subagentSessionFile, sessionFile: subagentSessionFile,
launchScriptFile, launchScriptFile,
activityFile, activityFile,
completionFile,
interactive: effectiveInteractive, interactive: effectiveInteractive,
statusState: createStatusState({ statusState: createStatusState({
source: "pi", source: "pi",
@@ -1451,6 +1466,17 @@ async function launchSubagent(
runningSubagents.set(id, running); runningSubagents.set(id, running);
return running; return running;
} catch (error) {
runningSubagents.delete(id);
if (!surfacePreCreated) {
try {
closeSurface(surface);
} catch {
// Preserve the launch error; cleanup is best-effort.
}
}
throw error;
}
} }
/** /**
@@ -1532,6 +1558,7 @@ async function watchSubagent(
interval: 1000, interval: 1000,
sessionFile, sessionFile,
sentinelFile: running.sentinelFile, sentinelFile: running.sentinelFile,
completionFile: running.completionFile,
onTick() { onTick() {
observeRunningSubagent(running); observeRunningSubagent(running);
deliverPendingQuestion(running); deliverPendingQuestion(running);
@@ -2152,6 +2179,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
const surface = createSurface(name); const surface = createSurface(name);
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs())); await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
try {
// Build pi resume command // Build pi resume command
const parts = ["pi", "--session", shellEscape(sessionPath)]; const parts = ["pi", "--session", shellEscape(sessionPath)];
@@ -2162,7 +2190,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
const sessionId = ctx.sessionManager.getSessionId(); const sessionId = ctx.sessionManager.getSessionId();
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId); const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
const activityFile = getSubagentActivityFile(artifactDir, id); const activityFile = getSubagentActivityFile(artifactDir, id);
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
mkdirSync(dirname(activityFile), { recursive: true }); mkdirSync(dirname(activityFile), { recursive: true });
mkdirSync(dirname(completionFile), { recursive: true });
// Replay the model, identity, and default-deny tool/extension sandbox. // Replay the model, identity, and default-deny tool/extension sandbox.
applySandboxToParts(parts, loadout, { artifactDir, name }); applySandboxToParts(parts, loadout, { artifactDir, name });
@@ -2212,7 +2242,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
// operate where they did before. // operate where they did before.
const resumeCdPrefix = loadout.cwd ? `cd ${shellEscape(loadout.cwd)} && ` : ""; const resumeCdPrefix = loadout.cwd ? `cd ${shellEscape(loadout.cwd)} && ` : "";
const command = `${resumeCdPrefix}${resumeEnvPrefix}${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`; const command = wrapWithCompletionFile(
`${resumeCdPrefix}${resumeEnvPrefix}${parts.join(" ")}`,
completionFile,
);
const launchScriptFile = join( const launchScriptFile = join(
artifactDir, artifactDir,
"subagent-scripts", "subagent-scripts",
@@ -2244,6 +2277,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
sessionFile: sessionPath, sessionFile: sessionPath,
launchScriptFile, launchScriptFile,
activityFile, activityFile,
completionFile,
interactive, interactive,
statusState: createStatusState({ statusState: createStatusState({
source: "pi", source: "pi",
@@ -2316,6 +2350,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
status: "started", status: "started",
}, },
}; };
} catch (error) {
runningSubagents.delete(id);
try {
closeSurface(surface);
} catch {
// Preserve the resume error; cleanup is best-effort.
}
throw error;
}
}, },
}); });
+214
View File
@@ -0,0 +1,214 @@
/**
* 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.
*/
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 tmux from "./tmux.ts";
export type MuxBackend = "herdr" | "tmux";
type Direction = "left" | "right" | "up" | "down";
type SurfaceBackend = {
createSurface(name: string): string;
createSurfaceSplit(name: string, direction: Direction, fromSurface?: string): string;
sendCommand(surface: string, command: string): void;
readScreen(surface: string, lines?: number): string;
readScreenAsync(surface: string, lines?: number): Promise<string>;
closeSurface(surface: string): void;
};
export function selectMuxBackend(availability: {
herdr: boolean;
tmux: boolean;
}): MuxBackend | null {
if (availability.herdr) return "herdr";
if (availability.tmux) return "tmux";
return null;
}
export function getMuxBackend(): MuxBackend | null {
return selectMuxBackend({ herdr: herdr.isAvailable(), tmux: tmux.isAvailable() });
}
function getBackend(): SurfaceBackend {
const backend = getMuxBackend();
if (backend === "herdr") return herdr;
if (backend === "tmux") return tmux;
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
}
export function isMuxAvailable(): boolean {
return getMuxBackend() !== null;
}
export function muxSetupHint(): string {
if (process.env.HERDR_ENV === "1") return herdr.setupHint();
if (process.env.TMUX) return tmux.setupHint();
return "Start pi inside Herdr or tmux.";
}
export function createSurface(name: string): string {
return getBackend().createSurface(name);
}
export function createSurfaceSplit(
name: string,
direction: Direction,
fromSurface?: string,
): string {
return getBackend().createSurfaceSplit(name, direction, fromSurface);
}
export function sendCommand(surface: string, command: string): void {
getBackend().sendCommand(surface, command);
}
export function readScreen(surface: string, lines = 50): string {
return getBackend().readScreen(surface, lines);
}
export function readScreenAsync(surface: string, lines = 50): Promise<string> {
return getBackend().readScreenAsync(surface, lines);
}
export function closeSurface(surface: string): void {
getBackend().closeSurface(surface);
}
export function shellEscape(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
export function wrapWithCompletionFile(command: string, completionFile: string): string {
return (
`${command}; exit_code=$?; ` +
`printf '%s\\n' "$exit_code" > ${shellEscape(completionFile)}; ` +
`echo "__SUBAGENT_DONE_\${exit_code}__"`
);
}
export function sendLongCommand(
surface: string,
command: string,
options?: { scriptPath?: string; scriptPreamble?: string },
): string {
const scriptPath =
options?.scriptPath ??
join(
tmpdir(),
"pi-subagent-scripts",
`cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
);
mkdirSync(dirname(scriptPath), { recursive: true });
const scriptParts = ["#!/bin/bash"];
if (options?.scriptPreamble) scriptParts.push(options.scriptPreamble.trimEnd());
scriptParts.push(command);
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
return scriptPath;
}
export interface PollResult {
reason: "done" | "sentinel" | "error";
exitCode: number;
errorMessage?: string;
}
function interpretExitSidecar(data: unknown): PollResult {
const payload = data as { type?: unknown; errorMessage?: unknown } | null;
if (payload?.type === "error") {
const errorMessage =
typeof payload.errorMessage === "string" && payload.errorMessage.trim() !== ""
? payload.errorMessage
: "Subagent exited with stopReason=error (no errorMessage in sidecar).";
return { reason: "error", exitCode: 1, errorMessage };
}
return { reason: "done", exitCode: 0 };
}
function readCompletionFile(path: string): PollResult | null {
if (!existsSync(path)) return null;
const raw = readFileSync(path, "utf8").trim();
const exitCode = Number.parseInt(raw, 10);
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(exitCode)) return null;
rmSync(path, { force: true });
return { reason: "sentinel", exitCode };
}
export const __pollForExitTest__ = { interpretExitSidecar, readCompletionFile };
export async function pollForExit(
surface: string,
signal: AbortSignal,
options: {
interval: number;
sessionFile?: string;
sentinelFile?: string;
completionFile?: string;
onTick?: (elapsed: number) => void;
},
): Promise<PollResult> {
const start = Date.now();
for (;;) {
if (signal.aborted) throw new Error("Aborted while waiting for subagent to finish");
if (options.sessionFile) {
try {
const exitFile = `${options.sessionFile}.exit`;
if (existsSync(exitFile)) {
const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8")));
rmSync(exitFile, { force: true });
return result;
}
} catch {}
}
if (options.sentinelFile) {
try {
if (existsSync(options.sentinelFile)) {
return { reason: "sentinel", exitCode: 0 };
}
} catch {}
}
if (options.completionFile) {
try {
const result = readCompletionFile(options.completionFile);
if (result) return result;
} catch {}
}
try {
const screen = await readScreenAsync(surface, 5);
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
} catch {
// Surface reads are best-effort. Filesystem completion remains authoritative.
}
const elapsed = Math.floor((Date.now() - start) / 1000);
options.onTick?.(elapsed);
await new Promise<void>((resolve, reject) => {
if (signal.aborted) return reject(new Error("Aborted"));
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, options.interval);
function onAbort() {
clearTimeout(timer);
reject(new Error("Aborted"));
}
signal.addEventListener("abort", onAbort, { once: true });
});
}
}
+14 -249
View File
@@ -1,31 +1,18 @@
/** /**
* tmux surface layer — the only terminal multiplexer this extension supports. * tmux surface backend.
* *
* Everything the extension does to a pane goes through the small API in this * This module only implements tmux-specific terminal operations. Backend
* file: create/split a pane, type a command into it, read its screen, close * selection, command scripts, and exit polling live in surface.ts.
* it, and poll for exit. Keeping the tmux calls isolated here means index.ts
* stays testable without a multiplexer running.
*
* Panes are identified by tmux pane ids (e.g. `%12`). Splits always target
* the parent pi's pane (`$TMUX_PANE`) so they follow the agent rather than
* the user's focus.
*/ */
import { execFile, execFileSync } from "node:child_process"; import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
// ── Availability ──
const commandAvailability = new Map<string, boolean>(); const commandAvailability = new Map<string, boolean>();
function hasCommand(command: string): boolean { function hasCommand(command: string): boolean {
if (commandAvailability.has(command)) { const cached = commandAvailability.get(command);
return commandAvailability.get(command)!; if (cached !== undefined) return cached;
}
let available = false; let available = false;
try { try {
@@ -39,62 +26,31 @@ function hasCommand(command: string): boolean {
return available; return available;
} }
/** export function isAvailable(): boolean {
* True when running inside tmux with the tmux binary on PATH.
* `TMUX` is set by tmux in every process it spawns (shell or pane).
*/
export function isTmuxAvailable(): boolean {
return !!process.env.TMUX && hasCommand("tmux"); return !!process.env.TMUX && hasCommand("tmux");
} }
export function isMuxAvailable(): boolean { export function setupHint(): string {
return isTmuxAvailable();
}
export function muxSetupHint(): string {
return "Start pi inside tmux (`tmux new -A -s pi 'pi'`)."; return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
} }
function requireTmux(): void { function requireTmux(): void {
if (!isTmuxAvailable()) { if (!isAvailable()) {
throw new Error(`tmux is required for subagents. ${muxSetupHint()}`); throw new Error(`tmux is required for this surface. ${setupHint()}`);
} }
} }
// ── Shell helpers ── /** tmux layout used to keep sibling panes evenly sized. */
export function shellEscape(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
// ── Pane layout ──
/**
* tmux layout applied to the subagent window to keep panes evenly sized.
* Switchable: "even-horizontal" (equal columns, matches Ctrl+b Alt+1),
* "main-vertical" (big main pane + tiled column), "tiled" (grid).
*/
const SUBAGENT_TMUX_LAYOUT = "even-horizontal"; const SUBAGENT_TMUX_LAYOUT = "even-horizontal";
let rebalanceTimer: ReturnType<typeof setTimeout> | null = null; let rebalanceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Re-balance subagent panes so repeated splits don't leave them lopsided.
* tmux halves the target pane on every split and dumps freed space onto a
* neighbor on close, so without this panes drift to wildly uneven widths.
* Applies SUBAGENT_TMUX_LAYOUT to the parent pi window. Debounced so a burst
* of parallel spawns or staggered exits collapses into a single layout call,
* and non-fatal: a cosmetic resize must never break spawning or watching.
*/
function rebalanceSurfaces(hintPane?: string): void { function rebalanceSurfaces(hintPane?: string): void {
// Prefer the parent pi pane (stable; survives a closing subagent pane).
const target = process.env.TMUX_PANE ?? hintPane; const target = process.env.TMUX_PANE ?? hintPane;
if (!target) return; if (!target) return;
if (rebalanceTimer) clearTimeout(rebalanceTimer); if (rebalanceTimer) clearTimeout(rebalanceTimer);
rebalanceTimer = setTimeout(() => { rebalanceTimer = setTimeout(() => {
rebalanceTimer = null; rebalanceTimer = null;
try { try {
// -t <pane> resolves to that pane's window; does not change focus.
execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], { execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], {
encoding: "utf8", encoding: "utf8",
}); });
@@ -104,24 +60,10 @@ function rebalanceSurfaces(hintPane?: string): void {
}, 120); }, 120);
} }
// ── Surface primitives ──
/**
* Create a new pane for a subagent: a right split off the parent pi's pane,
* so new panes follow the agent rather than the user's focus.
* See https://github.com/HazAT/pi-interactive-subagents/issues/12
*
* Returns the new pane id (e.g. `%12`).
*/
export function createSurface(name: string): string { export function createSurface(name: string): string {
void name; // tmux panes are not named; the pi process inside shows its own title.
return createSurfaceSplit(name, "right", process.env.TMUX_PANE); return createSurfaceSplit(name, "right", process.env.TMUX_PANE);
} }
/**
* Create a new split in the given direction from an optional source pane.
* Returns the new pane id (e.g. `%12`).
*/
export function createSurfaceSplit( export function createSurfaceSplit(
name: string, name: string,
direction: "left" | "right" | "up" | "down", direction: "left" | "right" | "up" | "down",
@@ -131,17 +73,9 @@ export function createSurfaceSplit(
requireTmux(); requireTmux();
const args = ["split-window", "-d"]; const args = ["split-window", "-d"];
if (direction === "left" || direction === "right") { args.push(direction === "left" || direction === "right" ? "-h" : "-v");
args.push("-h"); if (direction === "left" || direction === "up") args.push("-b");
} else { if (fromSurface) args.push("-t", fromSurface);
args.push("-v");
}
if (direction === "left" || direction === "up") {
args.push("-b");
}
if (fromSurface) {
args.push("-t", fromSurface);
}
args.push("-P", "-F", "#{pane_id}"); args.push("-P", "-F", "#{pane_id}");
const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim(); const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
@@ -153,72 +87,21 @@ export function createSurfaceSplit(
return pane; return pane;
} }
/**
* Send a command string to a pane and execute it.
* Typed literally (`-l`) so special characters are not interpreted as keys,
* then submitted with Enter.
*/
export function sendCommand(surface: string, command: string): void { export function sendCommand(surface: string, command: string): void {
requireTmux(); requireTmux();
execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" }); execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" }); execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" });
} }
/**
* Send a long command to a pane by writing it to a script file first.
* This avoids terminal line-wrapping issues that break commands exceeding the
* pane's column width when sent character-by-character via sendCommand.
*
* By default the script is written to a temp directory, but callers can pass a
* stable path (for example under session artifacts) so the exact invocation is
* preserved for debugging.
*
* Returns the script path.
*/
export function sendLongCommand(
surface: string,
command: string,
options?: { scriptPath?: string; scriptPreamble?: string },
): string {
const scriptPath =
options?.scriptPath ??
join(
tmpdir(),
"pi-subagent-scripts",
`cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
);
mkdirSync(dirname(scriptPath), { recursive: true });
const scriptParts = ["#!/bin/bash"];
if (options?.scriptPreamble) {
scriptParts.push(options.scriptPreamble.trimEnd());
}
scriptParts.push(command);
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", {
mode: 0o755,
});
sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
return scriptPath;
}
/**
* Read the screen contents of a pane (sync).
*/
export function readScreen(surface: string, lines = 50): string { export function readScreen(surface: string, lines = 50): string {
requireTmux(); requireTmux();
return execFileSync( return execFileSync(
"tmux", "tmux",
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`], ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
{ { encoding: "utf8" },
encoding: "utf8",
},
); );
} }
/**
* Read the screen contents of a pane (async).
*/
export async function readScreenAsync(surface: string, lines = 50): Promise<string> { export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
requireTmux(); requireTmux();
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
@@ -229,126 +112,8 @@ export async function readScreenAsync(surface: string, lines = 50): Promise<stri
return stdout; return stdout;
} }
/**
* Close a pane.
*/
export function closeSurface(surface: string): void { export function closeSurface(surface: string): void {
requireTmux(); requireTmux();
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" }); execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
rebalanceSurfaces(); rebalanceSurfaces();
} }
// ── Exit polling ──
export interface PollResult {
/** How the subagent exited */
reason: "done" | "sentinel" | "error";
/** Shell exit code (from sentinel). 0 for file-based exits. */
exitCode: number;
/** Error message if reason is "error" (auto-retry exhausted, provider overload, etc.) */
errorMessage?: string;
}
/**
* Interpret an `.exit` sidecar payload (written by the error path in
* subagent-done.ts). Centralized so both the fast and slow paths in
* pollForExit decode the payload the same way. Clean completions write no
* sidecar and are detected via the terminal sentinel instead.
*
* Note: ask_question does NOT write a `.exit` sidecar — it keeps the session
* open and signals the parent via a separate `.ask` file (see deliverPendingQuestion).
*/
function interpretExitSidecar(data: any): PollResult {
if (data?.type === "error") {
const errorMessage =
typeof data.errorMessage === "string" && data.errorMessage.trim() !== ""
? data.errorMessage
: "Subagent exited with stopReason=error (no errorMessage in sidecar).";
return { reason: "error", exitCode: 1, errorMessage };
}
return { reason: "done", exitCode: 0 };
}
export const __pollForExitTest__ = { interpretExitSidecar };
/**
* Poll until the subagent exits. Checks for a `.exit` sidecar file first
* (written by the error path), falling back to the terminal sentinel for
* clean-completion and crash detection.
*/
export async function pollForExit(
surface: string,
signal: AbortSignal,
options: {
interval: number;
sessionFile?: string;
sentinelFile?: string;
onTick?: (elapsed: number) => void;
},
): Promise<PollResult> {
const start = Date.now();
for (;;) {
if (signal.aborted) {
throw new Error("Aborted while waiting for subagent to finish");
}
// Fast path: check for .exit sidecar file (written by the error path)
if (options.sessionFile) {
try {
const exitFile = `${options.sessionFile}.exit`;
if (existsSync(exitFile)) {
const data = JSON.parse(readFileSync(exitFile, "utf-8"));
rmSync(exitFile, { force: true });
return interpretExitSidecar(data);
}
} catch {}
}
// Check Claude sentinel file (written by plugin Stop hook)
if (options.sentinelFile) {
try {
if (existsSync(options.sentinelFile)) {
return { reason: "sentinel", exitCode: 0 };
}
} catch {}
}
// Slow path: read terminal screen for sentinel (crash detection)
try {
const screen = await readScreenAsync(surface, 5);
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
if (match) {
return { reason: "sentinel", exitCode: parseInt(match[1], 10) };
}
} catch {
// Surface may have been destroyed — check if .exit file appeared in the meantime
if (options.sessionFile) {
try {
const exitFile = `${options.sessionFile}.exit`;
if (existsSync(exitFile)) {
const data = JSON.parse(readFileSync(exitFile, "utf-8"));
rmSync(exitFile, { force: true });
return interpretExitSidecar(data);
}
} catch {}
}
}
const elapsed = Math.floor((Date.now() - start) / 1000);
options.onTick?.(elapsed);
await new Promise<void>((resolve, reject) => {
if (signal.aborted) return reject(new Error("Aborted"));
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, options.interval);
function onAbort() {
clearTimeout(timer);
reject(new Error("Aborted"));
}
signal.addEventListener("abort", onAbort, { once: true });
});
}
}
+51 -14
View File
@@ -2,9 +2,9 @@
* Integration test harness for pi-interactive-subagents. * Integration test harness for pi-interactive-subagents.
* *
* Provides utilities to: * Provides utilities to:
* - Detect whether tmux is available * - Detect the active terminal-surface backend
* - Create isolated test environments with test agent definitions * - Create isolated test environments with test agent definitions
* - Start real pi sessions in tmux panes * - Start real pi sessions in Herdr tabs or tmux panes
* - Poll for file creation and screen output * - Poll for file creation and screen output
* - Clean up panes and temp files after tests * - Clean up panes and temp files after tests
*/ */
@@ -18,12 +18,13 @@ import {
existsSync, existsSync,
readFileSync, readFileSync,
unlinkSync, unlinkSync,
writeFileSync,
} from "node:fs"; } from "node:fs";
import { join, resolve, dirname } from "node:path"; import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { import {
isMuxAvailable, getMuxBackend,
createSurface, createSurface,
createSurfaceSplit, createSurfaceSplit,
sendCommand, sendCommand,
@@ -31,10 +32,13 @@ import {
readScreen, readScreen,
readScreenAsync, readScreenAsync,
closeSurface, closeSurface,
pollForExit,
shellEscape, shellEscape,
} from "../../pi-extension/subagents/tmux.ts"; wrapWithCompletionFile,
type MuxBackend,
} from "../../pi-extension/subagents/surface.ts";
// Re-export tmux primitives for tests // Re-export backend-neutral surface primitives for tests
export { export {
createSurface, createSurface,
createSurfaceSplit, createSurfaceSplit,
@@ -43,7 +47,9 @@ export {
readScreen, readScreen,
readScreenAsync, readScreenAsync,
closeSurface, closeSurface,
pollForExit,
shellEscape, shellEscape,
wrapWithCompletionFile,
}; };
// ── Paths ── // ── Paths ──
@@ -68,27 +74,49 @@ const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.
// ── Configuration ── // ── Configuration ──
/** Model used for integration tests. Override with PI_TEST_MODEL env var. */ /** Model used for integration tests. Override with PI_TEST_MODEL env var. */
export const TEST_MODEL = process.env.PI_TEST_MODEL ?? "anthropic/claude-haiku-4-5"; 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";
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */ /** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000"); export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
// ── Backend detection ── // ── Backend detection ──
/** /** Return the one backend selected automatically for this process. */
* Detect whether tmux is available in the current environment. export function getAvailableBackends(): MuxBackend[] {
* Returns ["tmux"] or []. const backend = getMuxBackend();
*/ return backend ? [backend] : [];
export function getAvailableBackends(): string[] { }
return isMuxAvailable() ? ["tmux"] : [];
function parseHerdrJson(command: string, args: string[]): any {
const output = execFileSync(command, args, { encoding: "utf8" });
return JSON.parse(output);
} }
export function focusSurface(surface: string): void { export function focusSurface(surface: string): void {
if (getMuxBackend() === "herdr") {
const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
const tabId = pane.result?.pane?.tab_id;
if (!tabId) throw new Error(`Herdr pane ${surface} has no tab id`);
execFileSync("herdr", ["tab", "focus", tabId], { encoding: "utf8" });
return;
}
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" }); execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
} }
export function getFocusedSurface(): string | null { export function getFocusedSurface(): string | null {
try { try {
if (getMuxBackend() === "herdr") {
const workspace = process.env.HERDR_WORKSPACE_ID;
if (!workspace) return null;
const response = parseHerdrJson("herdr", ["pane", "list", "--workspace", workspace]);
return response.result?.panes?.find((pane: any) => pane.focused)?.pane_id ?? null;
}
const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], { const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], {
encoding: "utf8", encoding: "utf8",
}); });
@@ -110,7 +138,7 @@ export async function waitForFocusedSurface(
} }
throw new Error( throw new Error(
`Timeout (${timeout}ms) waiting for focused tmux pane ${surface}; ` + `Timeout (${timeout}ms) waiting for focused terminal surface ${surface}; ` +
`current focus is ${getFocusedSurface() ?? "unknown"}`, `current focus is ${getFocusedSurface() ?? "unknown"}`,
); );
} }
@@ -139,7 +167,13 @@ export function createTestEnv(): TestEnv {
if (existsSync(TEST_AGENTS_SRC)) { if (existsSync(TEST_AGENTS_SRC)) {
for (const file of readdirSync(TEST_AGENTS_SRC)) { for (const file of readdirSync(TEST_AGENTS_SRC)) {
if (file.endsWith(".md")) { if (file.endsWith(".md")) {
cpSync(join(TEST_AGENTS_SRC, file), join(agentsDir, file)); const destination = join(agentsDir, file);
cpSync(join(TEST_AGENTS_SRC, file), destination);
const definition = readFileSync(destination, "utf8").replace(
/^model:\s*.+$/m,
`model: ${TEST_MODEL}`,
);
writeFileSync(destination, definition);
} }
} }
} }
@@ -227,6 +261,9 @@ export function startPi(
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
// Herdr does not retain terminal output for tabs that have never been shown.
// Focus the test runner surface so waitForScreen can observe its transcript.
focusSurface(surface);
sendLongCommand(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, { sendLongCommand(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, {
scriptPath: join(testDir, `test-launch-${Date.now()}.sh`), scriptPath: join(testDir, `test-launch-${Date.now()}.sh`),
}); });
+4 -4
View File
@@ -2,13 +2,13 @@
* Integration tests for the full subagent lifecycle. * 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 (haiku by default).
* Each test creates a tmux pane, runs pi with a task that uses the subagent * 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. * 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 (haiku).
* Duration: ~30-90s per test. * Duration: ~30-90s per test.
* *
* Run inside tmux: * Run inside Herdr, or inside tmux:
* tmux new 'npm run test:integration' * tmux new 'npm run test:integration'
* *
* Configuration: * Configuration:
@@ -37,8 +37,8 @@ import {
const backends = getAvailableBackends(); const backends = getAvailableBackends();
if (backends.length === 0) { if (backends.length === 0) {
console.log("⚠️ tmux is not available — skipping subagent lifecycle integration tests"); console.log("⚠️ Herdr/tmux is not available — skipping subagent lifecycle integration tests");
console.log(" Run inside tmux to enable these tests."); console.log(" Run inside Herdr or tmux to enable these tests.");
} }
for (const backend of backends) { for (const backend of backends) {
@@ -1,11 +1,11 @@
/** /**
* Integration tests for the tmux surface layer. * Integration tests for the selected terminal-surface backend.
* *
* These tests exercise real tmux operations: creating panes, * These tests exercise real Herdr 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 tmux: * Run inside Herdr, 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";
@@ -16,7 +16,6 @@ import {
createTestEnv, createTestEnv,
cleanupTestEnv, cleanupTestEnv,
createTrackedSurface, createTrackedSurface,
createTrackedSurfaceSplit,
focusSurface, focusSurface,
getFocusedSurface, getFocusedSurface,
waitForFocusedSurface, waitForFocusedSurface,
@@ -26,6 +25,8 @@ import {
readScreen, readScreen,
readScreenAsync, readScreenAsync,
closeSurface, closeSurface,
pollForExit,
wrapWithCompletionFile,
sleep, sleep,
uniqueId, uniqueId,
trackTempFile, trackTempFile,
@@ -38,12 +39,12 @@ 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("⚠️ tmux is not available — skipping tmux-surface integration tests"); console.log("⚠️ Herdr/tmux is not available — skipping surface integration tests");
console.log(" Run inside tmux to enable these tests."); console.log(" Run inside Herdr or tmux to enable these tests.");
} }
for (const backend of backends) { for (const backend of backends) {
describe(`tmux-surface [${backend}]`, { timeout: 60_000 }, () => { describe(`surface [${backend}]`, { timeout: 60_000 }, () => {
let env: TestEnv; let env: TestEnv;
before(() => { before(() => {
@@ -55,7 +56,7 @@ for (const backend of backends) {
}); });
it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => { it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => {
const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right"); const anchor = createTrackedSurface(env, "focus-anchor");
await sleep(1000); await sleep(1000);
focusSurface(anchor); focusSurface(anchor);
@@ -71,19 +72,40 @@ for (const backend of backends) {
const markerA = uniqueId(); const markerA = uniqueId();
const markerB = uniqueId(); const markerB = uniqueId();
focusSurface(childA);
sendCommand(childA, `echo "FOCUS_A_${markerA}"`); sendCommand(childA, `echo "FOCUS_A_${markerA}"`);
await waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50);
focusSurface(childB);
sendCommand(childB, `echo "FOCUS_B_${markerB}"`); sendCommand(childB, `echo "FOCUS_B_${markerB}"`);
await waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50);
focusSurface(anchor);
await waitForFocusedSurface(anchor, 10_000);
});
await Promise.all([ it("polls file-based completion and closes an unfocused surface", async () => {
waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50), const focusedBefore = getFocusedSurface();
waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50), const surface = createTrackedSurface(env, "background-completion");
]); await sleep(1000);
assert.equal(getFocusedSurface(), anchor);
const completionFile = `${env.dir}/background.exit-code`;
sendLongCommand(surface, wrapWithCompletionFile("false", completionFile));
const result = await pollForExit(surface, new AbortController().signal, {
interval: 100,
completionFile,
});
assert.deepEqual(result, { reason: "sentinel", exitCode: 1 });
assert.equal(getFocusedSurface(), focusedBefore);
closeSurface(surface);
untrackSurface(env, surface);
assert.throws(() => readScreen(surface, 5));
}); });
it("creates a surface, sends a command, reads output, and closes it", async () => { it("creates a surface, sends a command, reads output, and closes it", async () => {
const surface = createTrackedSurface(env, "echo-test"); const surface = createTrackedSurface(env, "echo-test");
await sleep(1000); await sleep(1000);
focusSurface(surface);
const marker = uniqueId(); const marker = uniqueId();
sendCommand(surface, `echo "MARKER_${marker}"`); sendCommand(surface, `echo "MARKER_${marker}"`);
@@ -102,6 +124,7 @@ for (const backend of backends) {
it("preserves shell special characters in echo output", async () => { it("preserves shell special characters in echo output", async () => {
const surface = createTrackedSurface(env, "escape-test"); const surface = createTrackedSurface(env, "escape-test");
await sleep(1000); await sleep(1000);
focusSurface(surface);
const marker = uniqueId(); const marker = uniqueId();
// Single-quoted string — $ and " are literal inside single quotes // Single-quoted string — $ and " are literal inside single quotes
@@ -123,6 +146,7 @@ for (const backend of backends) {
it("sends a long command via script file without truncation", async () => { it("sends a long command via script file without truncation", async () => {
const surface = createTrackedSurface(env, "long-cmd-test"); const surface = createTrackedSurface(env, "long-cmd-test");
await sleep(1000); await sleep(1000);
focusSurface(surface);
const marker = uniqueId(); const marker = uniqueId();
const longValue = "X".repeat(500); const longValue = "X".repeat(500);
@@ -145,6 +169,7 @@ for (const backend of backends) {
it("reads screen asynchronously", async () => { it("reads screen asynchronously", async () => {
const surface = createTrackedSurface(env, "async-read-test"); const surface = createTrackedSurface(env, "async-read-test");
await sleep(1000); await sleep(1000);
focusSurface(surface);
const marker = uniqueId(); const marker = uniqueId();
sendCommand(surface, `echo "ASYNC_${marker}"`); sendCommand(surface, `echo "ASYNC_${marker}"`);
@@ -164,7 +189,10 @@ for (const backend of backends) {
const m1 = uniqueId(); const m1 = uniqueId();
const m2 = uniqueId(); const m2 = uniqueId();
focusSurface(s1);
sendCommand(s1, `echo "S1_${m1}"`); sendCommand(s1, `echo "S1_${m1}"`);
await sleep(750);
focusSurface(s2);
sendCommand(s2, `echo "S2_${m2}"`); sendCommand(s2, `echo "S2_${m2}"`);
await sleep(1500); await sleep(1500);
@@ -178,9 +206,10 @@ for (const backend of backends) {
it("writes output to a file and verifies via surface", async () => { it("writes output to a file and verifies via surface", async () => {
const surface = createTrackedSurface(env, "file-test"); const surface = createTrackedSurface(env, "file-test");
await sleep(1000); await sleep(1000);
focusSurface(surface);
const marker = uniqueId(); const marker = uniqueId();
const filePath = `/tmp/pi-tmux-test-${marker}.txt`; const filePath = `/tmp/pi-surface-test-${marker}.txt`;
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`); sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
+74 -19
View File
@@ -30,7 +30,12 @@ import {
summarizeSessionStats, summarizeSessionStats,
} from "../pi-extension/subagents/session.ts"; } from "../pi-extension/subagents/session.ts";
import { shellEscape } from "../pi-extension/subagents/tmux.ts"; import {
__pollForExitTest__,
selectMuxBackend,
shellEscape,
} from "../pi-extension/subagents/surface.ts";
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
import { import {
advanceStatusState, advanceStatusState,
capStatusLines, capStatusLines,
@@ -56,7 +61,6 @@ import {
runningChildrenCount, runningChildrenCount,
} from "../pi-extension/subagents/subagent-done.ts"; } from "../pi-extension/subagents/subagent-done.ts";
import subagentDoneExtension from "../pi-extension/subagents/subagent-done.ts"; import subagentDoneExtension from "../pi-extension/subagents/subagent-done.ts";
import { __pollForExitTest__ } from "../pi-extension/subagents/tmux.ts";
// --- Helpers --- // --- Helpers ---
@@ -1172,10 +1176,12 @@ describe("subagent discovery", () => {
); );
}); });
it("bundled scout/researcher/worker all resolve as non-interactive (auto-exit)", () => { it("bundled scout/researcher/worker use the configured model and resolve as non-interactive", () => {
for (const name of ["scout", "researcher", "worker"]) { for (const name of ["scout", "researcher", "worker"]) {
const defs = testApi.loadAgentDefaults(name); const defs = testApi.loadAgentDefaults(name);
assert.ok(defs, `expected bundled agent ${name} to be discoverable`); assert.ok(defs, `expected bundled agent ${name} to be discoverable`);
assert.equal(defs.model, "openai-codex/gpt-5.6-luna");
assert.equal(defs.thinking, "xhigh");
assert.equal( assert.equal(
testApi.resolveEffectiveInteractive({ name, task: "" }, defs), testApi.resolveEffectiveInteractive({ name, task: "" }, defs),
false, false,
@@ -1209,10 +1215,16 @@ describe("subagent discovery", () => {
it("getToolExtensionPath maps custom tools and skips built-ins", () => { it("getToolExtensionPath maps custom tools and skips built-ins", () => {
assert.equal(testApi.getToolExtensionPath("read"), undefined); assert.equal(testApi.getToolExtensionPath("read"), undefined);
assert.equal(testApi.getToolExtensionPath("bash"), undefined); assert.equal(testApi.getToolExtensionPath("bash"), undefined);
assert.ok(testApi.getToolExtensionPath("web_search")?.endsWith("web-search/index.ts"));
assert.ok(testApi.getToolExtensionPath("safe_bash")?.endsWith("tools/safe-bash.ts")); assert.ok(testApi.getToolExtensionPath("safe_bash")?.endsWith("tools/safe-bash.ts"));
// Spawning tools are registered by this extension itself. // Spawning tools are registered by this extension itself.
assert.ok(testApi.getToolExtensionPath("subagent")?.endsWith("index.ts")); assert.ok(testApi.getToolExtensionPath("subagent")?.endsWith("index.ts"));
withTempDir((dir) => {
const extensionPath = join(dir, "portable-test-tool.ts");
writeFileSync(extensionPath, "export default function () {}\n");
subagentsModule.registerToolExtension("portable_test_tool", extensionPath);
assert.equal(testApi.getToolExtensionPath("portable_test_tool"), extensionPath);
});
}); });
it("ignores invalid session-mode values", async () => { it("ignores invalid session-mode values", async () => {
@@ -1741,8 +1753,21 @@ describe("subagent-done.ts", () => {
}); });
}); });
describe("tmux.ts interpretExitSidecar", () => { describe("surface exit signals", () => {
const { interpretExitSidecar } = __pollForExitTest__; const { interpretExitSidecar, readCompletionFile } = __pollForExitTest__;
it("reads and removes a shell completion file", () => {
withTempDir((dir) => {
const completionFile = join(dir, "child.exit-code");
writeFileSync(completionFile, "7\n");
assert.deepEqual(readCompletionFile(completionFile), {
reason: "sentinel",
exitCode: 7,
});
assert.equal(existsSync(completionFile), false);
assert.equal(readCompletionFile(completionFile), null);
});
});
it("no longer decodes ping payloads (ask_question keeps the session open instead)", () => { it("no longer decodes ping payloads (ask_question keeps the session open instead)", () => {
// ask_question writes a `.ask` signal, not a `.exit` ping sidecar, so an // ask_question writes a `.ask` signal, not a `.exit` ping sidecar, so an
@@ -2460,19 +2485,11 @@ describe("subagent status renderer", () => {
}); });
describe("subagent startup delay", () => { describe("subagent startup delay", () => {
it("defaults to 500ms when no env var is set", () => { it("uses backend-specific defaults", () => {
const testApi = (subagentsModule as any).__test__; const testApi = (subagentsModule as any).__test__;
assert.ok(testApi, "expected subagents test helpers to be exported"); assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
assert.equal(typeof testApi.getShellReadyDelayMs, "function"); assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
const original = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
try {
assert.equal(testApi.getShellReadyDelayMs(), 500);
} finally {
if (original == null) delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
else process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = original;
}
}); });
it("uses PI_SUBAGENT_SHELL_READY_DELAY_MS when it is set", () => { it("uses PI_SUBAGENT_SHELL_READY_DELAY_MS when it is set", () => {
@@ -2652,7 +2669,45 @@ describe("subagent display helpers", () => {
}); });
}); });
describe("tmux.ts", () => { 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("parses Herdr tab and pane creation responses", () => {
assert.deepEqual(
parseCreatedTab(JSON.stringify({
result: {
root_pane: { pane_id: "w3:p9" },
tab: { tab_id: "w3:t6" },
},
})),
{ paneId: "w3:p9", tabId: "w3:t6" },
);
assert.equal(
parseCreatedPane(JSON.stringify({ result: { pane: { pane_id: "w3:p10" } } })),
"w3:p10",
);
assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/);
});
});
describe("subagent completion wrapper", () => {
it("records the child exit code without losing the terminal sentinel", () => {
const wrapped = (subagentsModule as any).__test__.wrapWithCompletionFile(
"pi --session test.jsonl",
"/tmp/subagent exit",
);
assert.match(wrapped, /^pi --session test\.jsonl; exit_code=\$\?/);
assert.match(wrapped, /printf '%s\\n' "\$exit_code" > '\/tmp\/subagent exit'/);
assert.match(wrapped, /__SUBAGENT_DONE_\$\{exit_code\}__/);
});
});
describe("surface.ts", () => {
describe("shellEscape", () => { describe("shellEscape", () => {
it("wraps in single quotes", () => { it("wraps in single quotes", () => {
assert.equal(shellEscape("hello"), "'hello'"); assert.equal(shellEscape("hello"), "'hello'");