From b3884f6c35671e789c2959ff91c4b09d944f9931 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 26 Aug 2026 10:57:45 -0400 Subject: [PATCH] Adding support for herdr - agents in new tabs --- .gitignore | 4 +- README.md | 30 +- agents/researcher.md | 4 +- agents/scout.md | 4 +- agents/worker.md | 4 +- package.json | 2 +- pi-extension/subagents/herdr.ts | 178 ++++++++++++ pi-extension/subagents/index.ts | 65 ++++- pi-extension/subagents/surface.ts | 214 ++++++++++++++ pi-extension/subagents/tmux.ts | 263 +----------------- test/integration/harness.ts | 65 ++++- test/integration/subagent-lifecycle.test.ts | 8 +- .../{tmux-surface.test.ts => surface.test.ts} | 57 +++- test/test.ts | 93 +++++-- 14 files changed, 660 insertions(+), 331 deletions(-) create mode 100644 pi-extension/subagents/herdr.ts create mode 100644 pi-extension/subagents/surface.ts rename test/integration/{tmux-surface.test.ts => surface.test.ts} (74%) diff --git a/.gitignore b/.gitignore index a231c90..4625d62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ config.json .DS_Store -.memory/ \ No newline at end of file +.memory/ +.pi/ +.worktrees/ \ No newline at end of file diff --git a/README.md b/README.md index fcc1aae..021742e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # 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 -`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 ─╮ @@ -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. -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: ```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 | 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 | | `subagents_list` | List available agent definitions | | `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 | | ----- | ----- | ----- | ---- | -| **scout** | `openrouter/z-ai/glm-5.3` | `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 | -| **worker** | `openrouter/z-ai/glm-5.3` | `read`, `write`, `edit`, `bash`, `web_search`, `web_fetch` + spawning | General implementer; may spawn `scout` and `researcher` | +| **scout** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon | +| **researcher** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `web_search`, `web_fetch`, `safe_bash` | Web research, synthesized into a sourced brief | +| **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`). @@ -90,8 +90,8 @@ Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global). --- name: my-agent description: Does something specific -model: openrouter/z-ai/glm-5.3 -thinking: medium +model: openai-codex/gpt-5.6-luna +thinking: xhigh tools: read, edit, write, safe_bash, web_search session-mode: lineage-only auto-exit: true @@ -178,7 +178,13 @@ Status display is configured via `config.json` in the extension directory (copy ## Requirements - [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 tmux new -A -s pi 'pi' diff --git a/agents/researcher.md b/agents/researcher.md index 28a4f5e..374e0c1 100644 --- a/agents/researcher.md +++ b/agents/researcher.md @@ -2,8 +2,8 @@ name: researcher description: Web researcher — searches the web and synthesizes findings tools: web_search, web_fetch, safe_bash -model: openrouter/z-ai/glm-5.3 -thinking: medium +model: openai-codex/gpt-5.6-luna +thinking: xhigh system-prompt: append auto-exit: true --- diff --git a/agents/scout.md b/agents/scout.md index c781f45..5069909 100644 --- a/agents/scout.md +++ b/agents/scout.md @@ -2,8 +2,8 @@ name: scout description: Fast codebase recon — explores files, finds patterns, maps architecture tools: read, grep, find, ls -model: openrouter/z-ai/glm-5.3 -thinking: low +model: openai-codex/gpt-5.6-luna +thinking: xhigh system-prompt: append auto-exit: true --- diff --git a/agents/worker.md b/agents/worker.md index 1161161..ae00cfe 100644 --- a/agents/worker.md +++ b/agents/worker.md @@ -3,8 +3,8 @@ name: worker description: General-purpose worker — reads, writes, and edits code tools: read, write, edit, bash, web_search, web_fetch subagent_agents: scout, researcher -model: openrouter/z-ai/glm-5.3 -thinking: high +model: openai-codex/gpt-5.6-luna +thinking: xhigh system-prompt: append auto-exit: true --- diff --git a/package.json b/package.json index a95ca5d..76b5792 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pi-interactive-subagents", "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": [ "pi-package" ], diff --git a/pi-extension/subagents/herdr.ts b/pi-extension/subagents/herdr.ts new file mode 100644 index 0000000..8f2da59 --- /dev/null +++ b/pi-extension/subagents/herdr.ts @@ -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(); +const tabByPane = new Map(); + +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(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("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("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 { + 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" }); +} diff --git a/pi-extension/subagents/index.ts b/pi-extension/subagents/index.ts index 7e20a0f..f6084ba 100644 --- a/pi-extension/subagents/index.ts +++ b/pi-extension/subagents/index.ts @@ -24,7 +24,9 @@ import { closeSurface, shellEscape, readScreen, -} from "./tmux.ts"; + getMuxBackend, + wrapWithCompletionFile, +} from "./surface.ts"; import { countSessionEntryLines, @@ -497,10 +499,15 @@ function widgetIcon(kind: StatusSnapshot["kind"]): string { * (for example direnv/devenv), so the delay is configurable for users who hit * dropped commands. Keep the historical default at 500ms. */ +function defaultShellReadyDelayMs(backend: ReturnType): number { + return backend === "herdr" ? 1000 : 500; +} + function getShellReadyDelayMs(): number { const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim(); 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() { @@ -508,10 +515,10 @@ function muxUnavailableResult() { content: [ { 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; cli?: string; sentinelFile?: string; + completionFile?: string; statusState: SubagentStatusState; /** * When true, status transitions (stalled/recovered) do not wake the parent @@ -1008,7 +1016,7 @@ function steerSubagent( } catch (error: any) { return { 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)}`, }; } @@ -1121,6 +1129,7 @@ function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolea export const __test__ = { borderLine, + defaultShellReadyDelayMs, getShellReadyDelayMs, renderSubagentWidgetLines, loadAgentDefaults, @@ -1141,6 +1150,7 @@ export const __test__ = { handleSubagentSteer, resolveResultPresentation, resolveResumeLaunchBehavior, + wrapWithCompletionFile, runningSubagents, formatElapsed, 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. * * Call watchSubagent() on the returned object to observe completion. @@ -1184,6 +1194,8 @@ async function launchSubagent( if (!sessionFile) throw new Error("No session file"); const sessionId = ctx.sessionManager.getSessionId(); 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 targetCwdForSession = effectiveCwd ?? ctx.cwd; @@ -1201,14 +1213,15 @@ async function launchSubagent( ].join("-"); const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`); - // Use pre-created surface (parallel mode) or create a new one. - // For new surfaces, pause briefly so the shell is ready before sending the command. + // Use a pre-created surface or create a new one. New shells need a brief + // startup window before terminal input is reliable. const surfacePreCreated = !!options?.surface; const surface = options?.surface ?? createSurface(params.name); if (!surfacePreCreated) { await new Promise((resolve) => setTimeout(resolve, getShellReadyDelayMs())); } + try { const launchBehavior = resolveLaunchBehavior(params, agentDefs); if (launchBehavior.seededSessionMode) { @@ -1271,7 +1284,7 @@ async function launchSubagent( cmdParts.push(shellEscape(params.task)); 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") .toLowerCase() @@ -1301,6 +1314,7 @@ async function launchSubagent( launchScriptFile, cli: "claude", sentinelFile, + completionFile, interactive: effectiveInteractive, statusState: createStatusState({ source: "claude", @@ -1414,7 +1428,7 @@ async function launchSubagent( const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : ""; const piCommand = cdPrefix + envPrefix + parts.join(" "); - const command = `${piCommand}; echo '__SUBAGENT_DONE_'$?'__'`; + const command = wrapWithCompletionFile(piCommand, completionFile); const launchScriptName = `${(params.name || "subagent") .toLowerCase() .replace(/[^a-z0-9\s-]/g, "") @@ -1442,6 +1456,7 @@ async function launchSubagent( sessionFile: subagentSessionFile, launchScriptFile, activityFile, + completionFile, interactive: effectiveInteractive, statusState: createStatusState({ source: "pi", @@ -1451,6 +1466,17 @@ async function launchSubagent( runningSubagents.set(id, 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, sessionFile, sentinelFile: running.sentinelFile, + completionFile: running.completionFile, onTick() { observeRunningSubagent(running); deliverPendingQuestion(running); @@ -2152,6 +2179,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { const surface = createSurface(name); await new Promise((resolve) => setTimeout(resolve, getShellReadyDelayMs())); + try { // Build pi resume command const parts = ["pi", "--session", shellEscape(sessionPath)]; @@ -2162,7 +2190,9 @@ export default function subagentsExtension(pi: ExtensionAPI) { const sessionId = ctx.sessionManager.getSessionId(); const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId); const activityFile = getSubagentActivityFile(artifactDir, id); + const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`); mkdirSync(dirname(activityFile), { recursive: true }); + mkdirSync(dirname(completionFile), { recursive: true }); // Replay the model, identity, and default-deny tool/extension sandbox. applySandboxToParts(parts, loadout, { artifactDir, name }); @@ -2212,7 +2242,10 @@ export default function subagentsExtension(pi: ExtensionAPI) { // operate where they did before. 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( artifactDir, "subagent-scripts", @@ -2244,6 +2277,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { sessionFile: sessionPath, launchScriptFile, activityFile, + completionFile, interactive, statusState: createStatusState({ source: "pi", @@ -2316,6 +2350,15 @@ export default function subagentsExtension(pi: ExtensionAPI) { status: "started", }, }; + } catch (error) { + runningSubagents.delete(id); + try { + closeSurface(surface); + } catch { + // Preserve the resume error; cleanup is best-effort. + } + throw error; + } }, }); diff --git a/pi-extension/subagents/surface.ts b/pi-extension/subagents/surface.ts new file mode 100644 index 0000000..ae32ef6 --- /dev/null +++ b/pi-extension/subagents/surface.ts @@ -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; + 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 { + 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 { + 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((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 }); + }); + } +} diff --git a/pi-extension/subagents/tmux.ts b/pi-extension/subagents/tmux.ts index c18e240..e0e0f51 100644 --- a/pi-extension/subagents/tmux.ts +++ b/pi-extension/subagents/tmux.ts @@ -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 - * file: create/split a pane, type a command into it, read its screen, close - * 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. + * This module only implements tmux-specific terminal operations. Backend + * selection, command scripts, and exit polling live in surface.ts. */ import { execFile, execFileSync } from "node:child_process"; 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); - -// ── Availability ── - const commandAvailability = new Map(); function hasCommand(command: string): boolean { - if (commandAvailability.has(command)) { - return commandAvailability.get(command)!; - } + const cached = commandAvailability.get(command); + if (cached !== undefined) return cached; let available = false; try { @@ -39,62 +26,31 @@ function hasCommand(command: string): boolean { return available; } -/** - * 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 { +export function isAvailable(): boolean { return !!process.env.TMUX && hasCommand("tmux"); } -export function isMuxAvailable(): boolean { - return isTmuxAvailable(); -} - -export function muxSetupHint(): string { +export function setupHint(): string { return "Start pi inside tmux (`tmux new -A -s pi 'pi'`)."; } function requireTmux(): void { - if (!isTmuxAvailable()) { - throw new Error(`tmux is required for subagents. ${muxSetupHint()}`); + if (!isAvailable()) { + throw new Error(`tmux is required for this surface. ${setupHint()}`); } } -// ── Shell helpers ── - -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). - */ +/** tmux layout used to keep sibling panes evenly sized. */ const SUBAGENT_TMUX_LAYOUT = "even-horizontal"; - let rebalanceTimer: ReturnType | 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 { - // Prefer the parent pi pane (stable; survives a closing subagent pane). const target = process.env.TMUX_PANE ?? hintPane; if (!target) return; if (rebalanceTimer) clearTimeout(rebalanceTimer); rebalanceTimer = setTimeout(() => { rebalanceTimer = null; try { - // -t resolves to that pane's window; does not change focus. execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], { encoding: "utf8", }); @@ -104,24 +60,10 @@ function rebalanceSurfaces(hintPane?: string): void { }, 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 { - void name; // tmux panes are not named; the pi process inside shows its own title. 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( name: string, direction: "left" | "right" | "up" | "down", @@ -131,17 +73,9 @@ export function createSurfaceSplit( requireTmux(); const args = ["split-window", "-d"]; - if (direction === "left" || direction === "right") { - args.push("-h"); - } else { - args.push("-v"); - } - if (direction === "left" || direction === "up") { - args.push("-b"); - } - if (fromSurface) { - args.push("-t", fromSurface); - } + args.push(direction === "left" || direction === "right" ? "-h" : "-v"); + if (direction === "left" || direction === "up") args.push("-b"); + if (fromSurface) args.push("-t", fromSurface); args.push("-P", "-F", "#{pane_id}"); const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim(); @@ -153,72 +87,21 @@ export function createSurfaceSplit( 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 { requireTmux(); execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { 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 { requireTmux(); return execFileSync( "tmux", ["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 { requireTmux(); const { stdout } = await execFileAsync( @@ -229,126 +112,8 @@ export async function readScreenAsync(surface: string, lines = 50): Promise void; - }, -): Promise { - 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((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 }); - }); - } -} diff --git a/test/integration/harness.ts b/test/integration/harness.ts index f3a24c1..6f5aae8 100644 --- a/test/integration/harness.ts +++ b/test/integration/harness.ts @@ -2,9 +2,9 @@ * Integration test harness for pi-interactive-subagents. * * Provides utilities to: - * - Detect whether tmux is available + * - Detect the active terminal-surface backend * - 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 * - Clean up panes and temp files after tests */ @@ -18,12 +18,13 @@ import { existsSync, readFileSync, unlinkSync, + writeFileSync, } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import { - isMuxAvailable, + getMuxBackend, createSurface, createSurfaceSplit, sendCommand, @@ -31,10 +32,13 @@ import { readScreen, readScreenAsync, closeSurface, + pollForExit, 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 { createSurface, createSurfaceSplit, @@ -43,7 +47,9 @@ export { readScreen, readScreenAsync, closeSurface, + pollForExit, shellEscape, + wrapWithCompletionFile, }; // ── Paths ── @@ -68,27 +74,49 @@ const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index. // ── Configuration ── /** 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. */ export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000"); // ── Backend detection ── -/** - * Detect whether tmux is available in the current environment. - * Returns ["tmux"] or []. - */ -export function getAvailableBackends(): string[] { - return isMuxAvailable() ? ["tmux"] : []; +/** Return the one backend selected automatically for this process. */ +export function getAvailableBackends(): MuxBackend[] { + const backend = getMuxBackend(); + return backend ? [backend] : []; +} + +function parseHerdrJson(command: string, args: string[]): any { + const output = execFileSync(command, args, { encoding: "utf8" }); + return JSON.parse(output); } 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" }); } export function getFocusedSurface(): string | null { 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}"], { encoding: "utf8", }); @@ -110,7 +138,7 @@ export async function waitForFocusedSurface( } 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"}`, ); } @@ -139,7 +167,13 @@ export function createTestEnv(): TestEnv { if (existsSync(TEST_AGENTS_SRC)) { for (const file of readdirSync(TEST_AGENTS_SRC)) { 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) .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_'$?'__'`, { scriptPath: join(testDir, `test-launch-${Date.now()}.sh`), }); diff --git a/test/integration/subagent-lifecycle.test.ts b/test/integration/subagent-lifecycle.test.ts index f2a96cb..0d66b62 100644 --- a/test/integration/subagent-lifecycle.test.ts +++ b/test/integration/subagent-lifecycle.test.ts @@ -2,13 +2,13 @@ * Integration tests for the full subagent lifecycle. * * 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. * * Costs: ~$0.01-0.05 per test run (haiku). * Duration: ~30-90s per test. * - * Run inside tmux: + * Run inside Herdr, or inside tmux: * tmux new 'npm run test:integration' * * Configuration: @@ -37,8 +37,8 @@ import { const backends = getAvailableBackends(); if (backends.length === 0) { - console.log("⚠️ tmux is not available — skipping subagent lifecycle integration tests"); - console.log(" Run inside tmux to enable these tests."); + console.log("⚠️ Herdr/tmux is not available — skipping subagent lifecycle integration tests"); + console.log(" Run inside Herdr or tmux to enable these tests."); } for (const backend of backends) { diff --git a/test/integration/tmux-surface.test.ts b/test/integration/surface.test.ts similarity index 74% rename from test/integration/tmux-surface.test.ts rename to test/integration/surface.test.ts index 15f3ad3..42c55bf 100644 --- a/test/integration/tmux-surface.test.ts +++ b/test/integration/surface.test.ts @@ -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. * No LLM calls — fast and free. * - * Run inside tmux: + * Run inside Herdr, or inside tmux: * tmux new 'npm run test:integration' */ import { describe, it, before, after } from "node:test"; @@ -16,7 +16,6 @@ import { createTestEnv, cleanupTestEnv, createTrackedSurface, - createTrackedSurfaceSplit, focusSurface, getFocusedSurface, waitForFocusedSurface, @@ -26,6 +25,8 @@ import { readScreen, readScreenAsync, closeSurface, + pollForExit, + wrapWithCompletionFile, sleep, uniqueId, 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"); if (backends.length === 0) { - console.log("⚠️ tmux is not available — skipping tmux-surface integration tests"); - console.log(" Run inside tmux to enable these tests."); + console.log("⚠️ Herdr/tmux is not available — skipping surface integration tests"); + console.log(" Run inside Herdr or tmux to enable these tests."); } for (const backend of backends) { - describe(`tmux-surface [${backend}]`, { timeout: 60_000 }, () => { + describe(`surface [${backend}]`, { timeout: 60_000 }, () => { let env: TestEnv; before(() => { @@ -55,7 +56,7 @@ for (const backend of backends) { }); 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); focusSurface(anchor); @@ -71,19 +72,40 @@ for (const backend of backends) { const markerA = uniqueId(); const markerB = uniqueId(); + focusSurface(childA); 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}"`); + await waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50); + focusSurface(anchor); + await waitForFocusedSurface(anchor, 10_000); + }); - await Promise.all([ - waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50), - waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50), - ]); - assert.equal(getFocusedSurface(), anchor); + it("polls file-based completion and closes an unfocused surface", async () => { + const focusedBefore = getFocusedSurface(); + const surface = createTrackedSurface(env, "background-completion"); + await sleep(1000); + + 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 () => { const surface = createTrackedSurface(env, "echo-test"); await sleep(1000); + focusSurface(surface); const marker = uniqueId(); sendCommand(surface, `echo "MARKER_${marker}"`); @@ -102,6 +124,7 @@ for (const backend of backends) { it("preserves shell special characters in echo output", async () => { const surface = createTrackedSurface(env, "escape-test"); await sleep(1000); + focusSurface(surface); const marker = uniqueId(); // 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 () => { const surface = createTrackedSurface(env, "long-cmd-test"); await sleep(1000); + focusSurface(surface); const marker = uniqueId(); const longValue = "X".repeat(500); @@ -145,6 +169,7 @@ for (const backend of backends) { it("reads screen asynchronously", async () => { const surface = createTrackedSurface(env, "async-read-test"); await sleep(1000); + focusSurface(surface); const marker = uniqueId(); sendCommand(surface, `echo "ASYNC_${marker}"`); @@ -164,7 +189,10 @@ for (const backend of backends) { const m1 = uniqueId(); const m2 = uniqueId(); + focusSurface(s1); sendCommand(s1, `echo "S1_${m1}"`); + await sleep(750); + focusSurface(s2); sendCommand(s2, `echo "S2_${m2}"`); await sleep(1500); @@ -178,9 +206,10 @@ for (const backend of backends) { it("writes output to a file and verifies via surface", async () => { const surface = createTrackedSurface(env, "file-test"); await sleep(1000); + focusSurface(surface); 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}"`); diff --git a/test/test.ts b/test/test.ts index a2a1a95..abca073 100644 --- a/test/test.ts +++ b/test/test.ts @@ -30,7 +30,12 @@ import { summarizeSessionStats, } 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 { advanceStatusState, capStatusLines, @@ -56,7 +61,6 @@ import { runningChildrenCount, } 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 --- @@ -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"]) { const defs = testApi.loadAgentDefaults(name); 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( testApi.resolveEffectiveInteractive({ name, task: "" }, defs), false, @@ -1209,10 +1215,16 @@ describe("subagent discovery", () => { it("getToolExtensionPath maps custom tools and skips built-ins", () => { assert.equal(testApi.getToolExtensionPath("read"), 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")); // Spawning tools are registered by this extension itself. 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 () => { @@ -1741,8 +1753,21 @@ describe("subagent-done.ts", () => { }); }); -describe("tmux.ts interpretExitSidecar", () => { - const { interpretExitSidecar } = __pollForExitTest__; +describe("surface exit signals", () => { + 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)", () => { // 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", () => { - it("defaults to 500ms when no env var is set", () => { + it("uses backend-specific defaults", () => { const testApi = (subagentsModule as any).__test__; - assert.ok(testApi, "expected subagents test helpers to be exported"); - assert.equal(typeof testApi.getShellReadyDelayMs, "function"); - - 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; - } + assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500); + assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000); + assert.equal(testApi.defaultShellReadyDelayMs(null), 500); }); 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", () => { it("wraps in single quotes", () => { assert.equal(shellEscape("hello"), "'hello'");