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
+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,
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<typeof getMuxBackend>): 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<void>((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<void>((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;
}
},
});
+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
* 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<string, boolean>();
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<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 {
// 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 <pane> 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<string> {
requireTmux();
const { stdout } = await execFileAsync(
@@ -229,126 +112,8 @@ export async function readScreenAsync(surface: string, lines = 50): Promise<stri
return stdout;
}
/**
* Close a pane.
*/
export function closeSurface(surface: string): void {
requireTmux();
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
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 });
});
}
}