1335 lines
40 KiB
TypeScript
1335 lines
40 KiB
TypeScript
import { execSync, execFile, execFileSync, spawnSync } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, statSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join } from "node:path";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export type MuxBackend = "cmux" | "tmux" | "zellij" | "wezterm";
|
|
|
|
const commandAvailability = new Map<string, boolean>();
|
|
|
|
function hasCommand(command: string): boolean {
|
|
if (commandAvailability.has(command)) {
|
|
return commandAvailability.get(command)!;
|
|
}
|
|
|
|
let available = false;
|
|
if (process.platform === "win32") {
|
|
try {
|
|
execFileSync("where.exe", [command], { stdio: "ignore" });
|
|
available = true;
|
|
} catch {
|
|
try {
|
|
execSync(`command -v ${command}`, { stdio: "ignore" });
|
|
available = true;
|
|
} catch {
|
|
available = false;
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
execSync(`command -v ${command}`, { stdio: "ignore" });
|
|
available = true;
|
|
} catch {
|
|
available = false;
|
|
}
|
|
}
|
|
|
|
commandAvailability.set(command, available);
|
|
return available;
|
|
}
|
|
|
|
function muxPreference(): MuxBackend | null {
|
|
const pref = (process.env.PI_SUBAGENT_MUX ?? "").trim().toLowerCase();
|
|
if (pref === "cmux" || pref === "tmux" || pref === "zellij" || pref === "wezterm") return pref;
|
|
return null;
|
|
}
|
|
|
|
function isCmuxRuntimeAvailable(): boolean {
|
|
return !!process.env.CMUX_SOCKET_PATH && hasCommand("cmux");
|
|
}
|
|
|
|
function isTmuxRuntimeAvailable(): boolean {
|
|
return !!process.env.TMUX && hasCommand("tmux");
|
|
}
|
|
|
|
function isZellijRuntimeAvailable(): boolean {
|
|
return !!(process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME) && hasCommand("zellij");
|
|
}
|
|
|
|
function isWezTermRuntimeAvailable(): boolean {
|
|
return !!process.env.WEZTERM_UNIX_SOCKET && hasCommand("wezterm");
|
|
}
|
|
|
|
export function isCmuxAvailable(): boolean {
|
|
return isCmuxRuntimeAvailable();
|
|
}
|
|
|
|
export function isTmuxAvailable(): boolean {
|
|
return isTmuxRuntimeAvailable();
|
|
}
|
|
|
|
export function isZellijAvailable(): boolean {
|
|
return isZellijRuntimeAvailable();
|
|
}
|
|
|
|
export function isWezTermAvailable(): boolean {
|
|
return isWezTermRuntimeAvailable();
|
|
}
|
|
|
|
export function getMuxBackend(): MuxBackend | null {
|
|
const pref = muxPreference();
|
|
if (pref === "cmux") return isCmuxRuntimeAvailable() ? "cmux" : null;
|
|
if (pref === "tmux") return isTmuxRuntimeAvailable() ? "tmux" : null;
|
|
if (pref === "zellij") return isZellijRuntimeAvailable() ? "zellij" : null;
|
|
if (pref === "wezterm") return isWezTermRuntimeAvailable() ? "wezterm" : null;
|
|
|
|
if (isCmuxRuntimeAvailable()) return "cmux";
|
|
if (isTmuxRuntimeAvailable()) return "tmux";
|
|
if (isZellijRuntimeAvailable()) return "zellij";
|
|
if (isWezTermRuntimeAvailable()) return "wezterm";
|
|
return null;
|
|
}
|
|
|
|
export function isMuxAvailable(): boolean {
|
|
return getMuxBackend() !== null;
|
|
}
|
|
|
|
export function muxSetupHint(): string {
|
|
const pref = muxPreference();
|
|
if (pref === "cmux") {
|
|
return "Start pi inside cmux (`cmux pi`).";
|
|
}
|
|
if (pref === "tmux") {
|
|
return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
|
|
}
|
|
if (pref === "zellij") {
|
|
return "Start pi inside zellij (`zellij --session pi`, then run `pi`).";
|
|
}
|
|
if (pref === "wezterm") {
|
|
return "Start pi inside WezTerm.";
|
|
}
|
|
return "Start pi inside cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), or WezTerm.";
|
|
}
|
|
|
|
function requireMuxBackend(): MuxBackend {
|
|
const backend = getMuxBackend();
|
|
if (!backend) {
|
|
throw new Error(`No supported terminal multiplexer found. ${muxSetupHint()}`);
|
|
}
|
|
return backend;
|
|
}
|
|
|
|
/**
|
|
* Detect if the user's default shell is fish.
|
|
* Fish uses $status instead of $? for exit codes.
|
|
*/
|
|
export function isFishShell(): boolean {
|
|
const shell = process.env.SHELL ?? "";
|
|
return basename(shell) === "fish";
|
|
}
|
|
|
|
/**
|
|
* Return the shell-appropriate exit status variable ($? for bash/zsh, $status for fish).
|
|
*/
|
|
export function exitStatusVar(): string {
|
|
return isFishShell() ? "$status" : "$?";
|
|
}
|
|
|
|
export function shellEscape(s: string): string {
|
|
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
}
|
|
|
|
function tailLines(text: string, lines: number): string {
|
|
const split = text.split("\n");
|
|
if (split.length <= lines) return text;
|
|
return split.slice(-lines).join("\n");
|
|
}
|
|
|
|
function zellijPaneId(surface: string): string {
|
|
return surface.startsWith("pane:") ? surface.slice("pane:".length) : surface;
|
|
}
|
|
|
|
function zellijEnv(surface?: string): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
if (surface) {
|
|
env.ZELLIJ_PANE_ID = zellijPaneId(surface);
|
|
}
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Pane-scoped zellij actions that must target a specific pane via --pane-id
|
|
* (the ZELLIJ_PANE_ID env var is ignored by most of these).
|
|
* See https://github.com/HazAT/pi-interactive-subagents/issues/19
|
|
*/
|
|
const ZELLIJ_PANE_SCOPED_ACTIONS = new Set([
|
|
"close-pane",
|
|
"dump-screen",
|
|
"rename-pane",
|
|
"move-pane",
|
|
"write",
|
|
"write-chars",
|
|
"send-keys",
|
|
]);
|
|
|
|
function zellijActionArgs(args: string[], surface?: string): string[] {
|
|
if (!surface) return ["action", ...args];
|
|
const action = args[0];
|
|
if (!ZELLIJ_PANE_SCOPED_ACTIONS.has(action)) return ["action", ...args];
|
|
// Don't double-add if caller already specified it.
|
|
if (args.includes("--pane-id") || args.includes("-p")) return ["action", ...args];
|
|
return ["action", action, "--pane-id", zellijPaneId(surface), ...args.slice(1)];
|
|
}
|
|
|
|
function zellijActionSync(args: string[], surface?: string): string {
|
|
return execFileSync("zellij", zellijActionArgs(args, surface), {
|
|
encoding: "utf8",
|
|
env: zellijEnv(surface),
|
|
});
|
|
}
|
|
|
|
async function zellijActionAsync(args: string[], surface?: string): Promise<string> {
|
|
const { stdout } = await execFileAsync("zellij", zellijActionArgs(args, surface), {
|
|
encoding: "utf8",
|
|
env: zellijEnv(surface),
|
|
});
|
|
return stdout;
|
|
}
|
|
|
|
/** Tracked subagent pane for cmux — reused across subagent launches. */
|
|
let cmuxSubagentPane: string | null = null;
|
|
|
|
// Mirrors Zellij 0.44.x tab minimums, used to predict which pane Zellij itself
|
|
// will choose for a directionless split.
|
|
const ZELLIJ_MIN_TERMINAL_WIDTH = 5;
|
|
const ZELLIJ_MIN_TERMINAL_HEIGHT = 5;
|
|
const ZELLIJ_CURSOR_HEIGHT_WIDTH_RATIO = 4;
|
|
|
|
// Pi subagents need more usable space than Zellij's internal minimum. These can
|
|
// be tuned per session without another code change.
|
|
const DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS = 50;
|
|
const DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS = 10;
|
|
|
|
export interface ZellijPaneSnapshot {
|
|
id: number;
|
|
is_plugin?: boolean;
|
|
is_floating?: boolean;
|
|
is_selectable?: boolean;
|
|
exited?: boolean;
|
|
pane_rows?: number;
|
|
pane_columns?: number;
|
|
tab_id?: number;
|
|
is_focused?: boolean;
|
|
}
|
|
|
|
export type ZellijSplitDirection = "down" | "right";
|
|
|
|
export type ZellijPlacementPlan =
|
|
| {
|
|
mode: "split";
|
|
anchorPaneId: number;
|
|
targetPaneId: number;
|
|
tabId: number;
|
|
splitDirection: ZellijSplitDirection;
|
|
}
|
|
| { mode: "stack"; anchorPaneId: number; targetPaneId: number; tabId: number };
|
|
|
|
function paneArea(pane: ZellijPaneSnapshot): number {
|
|
return (pane.pane_rows ?? 0) * (pane.pane_columns ?? 0);
|
|
}
|
|
|
|
function isUsableZellijTiledPane(pane: ZellijPaneSnapshot): boolean {
|
|
return (
|
|
!pane.is_plugin &&
|
|
!pane.is_floating &&
|
|
pane.is_selectable !== false &&
|
|
!pane.exited &&
|
|
typeof pane.pane_rows === "number" &&
|
|
typeof pane.pane_columns === "number"
|
|
);
|
|
}
|
|
|
|
export function predictZellijSplitDirection(pane: ZellijPaneSnapshot): ZellijSplitDirection | null {
|
|
const columns = pane.pane_columns ?? 0;
|
|
const rows = pane.pane_rows ?? 0;
|
|
if (columns < ZELLIJ_MIN_TERMINAL_WIDTH || rows < ZELLIJ_MIN_TERMINAL_HEIGHT) return null;
|
|
|
|
if (
|
|
rows * ZELLIJ_CURSOR_HEIGHT_WIDTH_RATIO > columns &&
|
|
rows > ZELLIJ_MIN_TERMINAL_HEIGHT * 2
|
|
) {
|
|
return "down";
|
|
}
|
|
|
|
if (columns > ZELLIJ_MIN_TERMINAL_WIDTH * 2) {
|
|
return "right";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function canSplitZellijPane(
|
|
pane: ZellijPaneSnapshot,
|
|
minColumns = ZELLIJ_MIN_TERMINAL_WIDTH,
|
|
minRows = ZELLIJ_MIN_TERMINAL_HEIGHT,
|
|
): boolean {
|
|
const columns = pane.pane_columns ?? 0;
|
|
const rows = pane.pane_rows ?? 0;
|
|
const direction = predictZellijSplitDirection(pane);
|
|
if (!direction) return false;
|
|
|
|
if (direction === "down") {
|
|
return columns >= minColumns && Math.floor(rows / 2) >= minRows;
|
|
}
|
|
|
|
return rows >= minRows && Math.floor(columns / 2) >= minColumns;
|
|
}
|
|
|
|
function zellijTabPanesForParent(
|
|
panes: ZellijPaneSnapshot[],
|
|
parentPaneId: number,
|
|
): { parentPane: ZellijPaneSnapshot; tabPanes: ZellijPaneSnapshot[] } | null {
|
|
const parentPane = panes.find((pane) => !pane.is_plugin && pane.id === parentPaneId);
|
|
if (!parentPane || typeof parentPane.tab_id !== "number") return null;
|
|
|
|
const tabPanes = panes
|
|
.filter((pane) => pane.tab_id === parentPane.tab_id)
|
|
.filter(isUsableZellijTiledPane);
|
|
|
|
return { parentPane, tabPanes };
|
|
}
|
|
|
|
export function selectZellijStackPlacement(
|
|
panes: ZellijPaneSnapshot[],
|
|
parentPaneId: number,
|
|
): ZellijPlacementPlan | null {
|
|
const tabInfo = zellijTabPanesForParent(panes, parentPaneId);
|
|
if (!tabInfo) return null;
|
|
|
|
const stackTarget = tabInfo.tabPanes
|
|
.filter((pane) => pane.id !== parentPaneId)
|
|
.sort((a, b) => paneArea(b) - paneArea(a))[0];
|
|
if (!stackTarget) return null;
|
|
|
|
return {
|
|
mode: "stack",
|
|
anchorPaneId: stackTarget.id,
|
|
targetPaneId: stackTarget.id,
|
|
tabId: tabInfo.parentPane.tab_id!,
|
|
};
|
|
}
|
|
|
|
export function selectZellijPlacement(
|
|
panes: ZellijPaneSnapshot[],
|
|
parentPaneId: number,
|
|
minColumns = DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS,
|
|
minRows = DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS,
|
|
): ZellijPlacementPlan | null {
|
|
const tabInfo = zellijTabPanesForParent(panes, parentPaneId);
|
|
if (!tabInfo) return null;
|
|
|
|
const zellijSplitCandidates = tabInfo.tabPanes
|
|
.map((pane) => ({ pane, splitDirection: predictZellijSplitDirection(pane) }))
|
|
.filter(
|
|
(candidate): candidate is { pane: ZellijPaneSnapshot; splitDirection: ZellijSplitDirection } =>
|
|
candidate.splitDirection !== null &&
|
|
canSplitZellijPane(candidate.pane, ZELLIJ_MIN_TERMINAL_WIDTH, ZELLIJ_MIN_TERMINAL_HEIGHT),
|
|
);
|
|
|
|
const safeSplitCandidates = zellijSplitCandidates.filter((candidate) =>
|
|
canSplitZellijPane(candidate.pane, minColumns, minRows),
|
|
);
|
|
|
|
// Split creation is tab-scoped, so Zellij chooses the concrete split pane.
|
|
// Only split when every pane Zellij might split would remain usable.
|
|
if (
|
|
zellijSplitCandidates.length > 0 &&
|
|
safeSplitCandidates.length === zellijSplitCandidates.length
|
|
) {
|
|
const splitTarget = safeSplitCandidates.sort((a, b) => paneArea(b.pane) - paneArea(a.pane))[0];
|
|
return {
|
|
mode: "split",
|
|
anchorPaneId: splitTarget.pane.id,
|
|
targetPaneId: splitTarget.pane.id,
|
|
tabId: tabInfo.parentPane.tab_id!,
|
|
splitDirection: splitTarget.splitDirection,
|
|
};
|
|
}
|
|
|
|
return selectZellijStackPlacement(panes, parentPaneId);
|
|
}
|
|
|
|
function parseZellijPaneSurface(rawId: string, context: string): string {
|
|
const idMatch = rawId.match(/(\d+)/);
|
|
if (!idMatch) {
|
|
throw new Error(`Unexpected zellij pane id from ${context}: ${rawId || "(empty)"}`);
|
|
}
|
|
return `pane:${idMatch[1]}`;
|
|
}
|
|
|
|
function readZellijPanes(): ZellijPaneSnapshot[] {
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
try {
|
|
const output = zellijActionSync(["list-panes", "--json", "--geometry", "--state", "--tab"]);
|
|
if (!output.trim()) {
|
|
throw new Error("Unexpected zellij list-panes output: empty");
|
|
}
|
|
const parsed = JSON.parse(output);
|
|
if (!Array.isArray(parsed)) {
|
|
throw new Error("Unexpected zellij list-panes output: not an array");
|
|
}
|
|
return parsed as ZellijPaneSnapshot[];
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (attempt < 2) sleepSync(50);
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
function createZellijTiledPane(name: string, tabId: number): string {
|
|
const args = ["new-pane", "--tab-id", String(tabId), "--name", name, "--cwd", process.cwd()];
|
|
return parseZellijPaneSurface(zellijActionSync(args).trim(), "new-pane");
|
|
}
|
|
|
|
function createZellijStackedPane(name: string, anchorSurface: string): string {
|
|
const args = [
|
|
"new-pane",
|
|
"--stacked",
|
|
"--near-current-pane",
|
|
"--name",
|
|
name,
|
|
"--cwd",
|
|
process.cwd(),
|
|
];
|
|
return parseZellijPaneSurface(zellijActionSync(args, anchorSurface).trim(), "new-pane --stacked");
|
|
}
|
|
|
|
function createZellijTab(name: string): string {
|
|
const tabIdRaw = zellijActionSync(["new-tab", "--name", name, "--cwd", process.cwd()]).trim();
|
|
const tabId = Number(tabIdRaw);
|
|
if (!Number.isInteger(tabId)) {
|
|
throw new Error(`Unexpected zellij tab id from new-tab: ${tabIdRaw || "(empty)"}`);
|
|
}
|
|
|
|
try {
|
|
const panes = readZellijPanes();
|
|
const pane = panes.find(
|
|
(candidate) =>
|
|
candidate.tab_id === tabId &&
|
|
isUsableZellijTiledPane(candidate) &&
|
|
typeof candidate.id === "number",
|
|
);
|
|
if (!pane) {
|
|
throw new Error(`Could not find initial pane for zellij tab ${tabId}`);
|
|
}
|
|
|
|
const surface = `pane:${pane.id}`;
|
|
try {
|
|
zellijActionSync(["rename-pane", name], surface);
|
|
} catch {
|
|
// Optional.
|
|
}
|
|
return surface;
|
|
} catch (error) {
|
|
try {
|
|
zellijActionSync(["close-tab", "--tab-id", String(tabId)]);
|
|
} catch {
|
|
// Best effort cleanup for tabs created before post-creation inspection failed.
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function envPositiveInteger(name: string, fallback: number): number {
|
|
const value = Number(process.env[name]);
|
|
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
}
|
|
|
|
function sleepSync(milliseconds: number): void {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
}
|
|
|
|
function zellijSurfaceLockPath(): string {
|
|
const session = (process.env.ZELLIJ_SESSION_NAME ?? process.env.ZELLIJ ?? "default").replace(
|
|
/[^A-Za-z0-9_.-]/g,
|
|
"_",
|
|
);
|
|
return join(tmpdir(), `pi-zellij-surface-${session}.lock`);
|
|
}
|
|
|
|
function withZellijSurfaceLock<T>(callback: () => T): T {
|
|
const lockPath = zellijSurfaceLockPath();
|
|
const deadline = Date.now() + 10000;
|
|
|
|
while (true) {
|
|
try {
|
|
mkdirSync(lockPath);
|
|
writeFileSync(join(lockPath, "owner"), `${process.pid}\n`);
|
|
break;
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code !== "EEXIST") throw error;
|
|
|
|
try {
|
|
if (Date.now() - statSync(lockPath).mtimeMs > 30000) {
|
|
rmSync(lockPath, { recursive: true, force: true });
|
|
continue;
|
|
}
|
|
} catch {}
|
|
|
|
if (Date.now() > deadline) {
|
|
throw new Error(`Timed out waiting for zellij surface lock: ${lockPath}`);
|
|
}
|
|
sleepSync(50);
|
|
}
|
|
}
|
|
|
|
try {
|
|
return callback();
|
|
} finally {
|
|
rmSync(lockPath, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function createZellijSurfaceUnlocked(name: string): string {
|
|
const parentPaneIdRaw = process.env.ZELLIJ_PANE_ID;
|
|
const parentPaneId = parentPaneIdRaw ? Number(parentPaneIdRaw) : NaN;
|
|
const minColumns = envPositiveInteger(
|
|
"PI_SUBAGENT_ZELLIJ_MIN_COLUMNS",
|
|
DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS,
|
|
);
|
|
const minRows = envPositiveInteger(
|
|
"PI_SUBAGENT_ZELLIJ_MIN_ROWS",
|
|
DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS,
|
|
);
|
|
|
|
const plan = Number.isInteger(parentPaneId)
|
|
? selectZellijPlacement(readZellijPanes(), parentPaneId, minColumns, minRows)
|
|
: null;
|
|
|
|
if (plan?.mode === "split") {
|
|
return createZellijTiledPane(name, plan.tabId);
|
|
}
|
|
|
|
if (plan?.mode === "stack") {
|
|
return createZellijStackedPane(name, `pane:${plan.targetPaneId}`);
|
|
}
|
|
|
|
return createZellijTab(name);
|
|
}
|
|
|
|
function createZellijSurface(name: string): string {
|
|
return withZellijSurfaceLock(() => createZellijSurfaceUnlocked(name));
|
|
}
|
|
|
|
type CmuxFocusSnapshot = {
|
|
surfaceRef?: string;
|
|
paneRef?: string;
|
|
};
|
|
|
|
type CmuxCreatedSurface = {
|
|
surface: string;
|
|
paneRef?: string;
|
|
};
|
|
|
|
type CmuxIdentifySnapshot = {
|
|
focused: CmuxFocusSnapshot | null;
|
|
caller: CmuxFocusSnapshot | null;
|
|
};
|
|
|
|
function nonEmptyString(value: unknown): value is string {
|
|
return typeof value === "string" && value.length > 0;
|
|
}
|
|
|
|
export function parseCmuxFocusedSnapshot(value: unknown): CmuxFocusSnapshot | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
|
|
const focused = (value as { focused?: unknown }).focused;
|
|
if (!focused || typeof focused !== "object") return null;
|
|
|
|
const record = focused as { surface_ref?: unknown; pane_ref?: unknown };
|
|
const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined;
|
|
const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined;
|
|
|
|
if (!surfaceRef && !paneRef) return null;
|
|
return { surfaceRef, paneRef };
|
|
}
|
|
|
|
export function parseCmuxJson(value: string): unknown | null {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch (error) {
|
|
void error;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function parseCmuxFocusedSnapshotFromJson(value: string): CmuxFocusSnapshot | null {
|
|
return parseCmuxFocusedSnapshot(parseCmuxJson(value));
|
|
}
|
|
|
|
function parseCmuxCallerSnapshot(value: unknown): CmuxFocusSnapshot | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
|
|
const caller = (value as { caller?: unknown }).caller;
|
|
if (!caller || typeof caller !== "object") return null;
|
|
|
|
const record = caller as { surface_ref?: unknown; pane_ref?: unknown };
|
|
const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined;
|
|
const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined;
|
|
|
|
if (!surfaceRef && !paneRef) return null;
|
|
return { surfaceRef, paneRef };
|
|
}
|
|
|
|
export function parseCmuxPaneRefForSurface(value: unknown, surface: string): string | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
|
|
const record = value as { surface_ref?: unknown; pane_ref?: unknown; caller?: unknown };
|
|
if (record.surface_ref === surface && nonEmptyString(record.pane_ref)) return record.pane_ref;
|
|
|
|
const caller = record.caller;
|
|
if (!caller || typeof caller !== "object") return null;
|
|
|
|
const callerRecord = caller as { surface_ref?: unknown; pane_ref?: unknown };
|
|
if (callerRecord.surface_ref === surface && nonEmptyString(callerRecord.pane_ref)) {
|
|
return callerRecord.pane_ref;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function parseCmuxPaneRefForSurfaceFromJson(value: string, surface: string): string | null {
|
|
return parseCmuxPaneRefForSurface(parseCmuxJson(value), surface);
|
|
}
|
|
|
|
function readCmux(args: string[]): string | null {
|
|
const result = spawnSync("cmux", args, { encoding: "utf8" });
|
|
if (result.error || result.status !== 0 || !result.stdout.trim()) return null;
|
|
return result.stdout;
|
|
}
|
|
|
|
function parseCmuxIdentifySnapshot(value: string | null): CmuxIdentifySnapshot {
|
|
const parsed = value ? parseCmuxJson(value) : null;
|
|
return {
|
|
focused: parseCmuxFocusedSnapshot(parsed),
|
|
caller: parseCmuxCallerSnapshot(parsed),
|
|
};
|
|
}
|
|
|
|
function captureCmuxIdentifySnapshot(): CmuxIdentifySnapshot {
|
|
return parseCmuxIdentifySnapshot(readCmux(["identify", "--json"]));
|
|
}
|
|
|
|
function captureCmuxFocusSnapshot(): CmuxFocusSnapshot | null {
|
|
return captureCmuxIdentifySnapshot().focused;
|
|
}
|
|
|
|
function readCmuxPaneRefForSurface(surface: string): string | null {
|
|
const info = readCmux(["identify", "--surface", surface]);
|
|
return info ? parseCmuxPaneRefForSurfaceFromJson(info, surface) : null;
|
|
}
|
|
|
|
function restoreCmuxFocusSnapshot(snapshot: CmuxFocusSnapshot | null): void {
|
|
if (!snapshot) return;
|
|
|
|
if (snapshot.paneRef) {
|
|
spawnSync("cmux", ["focus-pane", "--pane", snapshot.paneRef], { encoding: "utf8" });
|
|
}
|
|
|
|
if (snapshot.surfaceRef) {
|
|
spawnSync("cmux", ["focus-panel", "--panel", snapshot.surfaceRef], { encoding: "utf8" });
|
|
}
|
|
}
|
|
|
|
function waitForCmuxFocusSettle(): void {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
}
|
|
|
|
function cmuxFocusMatchesChild(
|
|
currentFocus: CmuxFocusSnapshot | null,
|
|
child: CmuxCreatedSurface,
|
|
): boolean {
|
|
if (!currentFocus) return false;
|
|
if (currentFocus.surfaceRef === child.surface) return true;
|
|
return !!currentFocus.paneRef && currentFocus.paneRef === child.paneRef;
|
|
}
|
|
|
|
function cmuxFocusMatchesSurfaceRef(
|
|
currentFocus: CmuxFocusSnapshot | null,
|
|
surfaceRef: string | undefined,
|
|
): boolean {
|
|
return !!surfaceRef && currentFocus?.surfaceRef === surfaceRef;
|
|
}
|
|
|
|
function cmuxFocusMatchesPaneRef(
|
|
currentFocus: CmuxFocusSnapshot | null,
|
|
paneRef: string | undefined,
|
|
): boolean {
|
|
return !!paneRef && currentFocus?.paneRef === paneRef;
|
|
}
|
|
|
|
function restoreCmuxFocusIfLaunchSurfaceFocused(
|
|
snapshot: CmuxFocusSnapshot | null,
|
|
child: CmuxCreatedSurface,
|
|
options?: { sourceSurfaceRef?: string; callerSnapshot?: CmuxFocusSnapshot | null },
|
|
): void {
|
|
if (!snapshot) return;
|
|
|
|
waitForCmuxFocusSettle();
|
|
const currentFocus = captureCmuxFocusSnapshot();
|
|
if (
|
|
cmuxFocusMatchesChild(currentFocus, child) ||
|
|
cmuxFocusMatchesSurfaceRef(currentFocus, options?.sourceSurfaceRef) ||
|
|
cmuxFocusMatchesSurfaceRef(currentFocus, options?.callerSnapshot?.surfaceRef) ||
|
|
// cmux can settle focus onto another active surface in the caller pane after creating a split/surface.
|
|
cmuxFocusMatchesPaneRef(currentFocus, options?.callerSnapshot?.paneRef)
|
|
) {
|
|
restoreCmuxFocusSnapshot(snapshot);
|
|
}
|
|
}
|
|
|
|
function parseCmuxCreatedSurface(output: string, command: string): CmuxCreatedSurface {
|
|
const surfaceMatch = output.match(/surface:\d+/);
|
|
if (!surfaceMatch) {
|
|
throw new Error(`Unexpected cmux ${command} output: ${output}`);
|
|
}
|
|
|
|
return {
|
|
surface: surfaceMatch[0],
|
|
paneRef: output.match(/pane:\d+/)?.[0],
|
|
};
|
|
}
|
|
|
|
function renameCmuxSurface(surface: string, name: string): void {
|
|
execFileSync("cmux", ["rename-tab", "--surface", surface, name], { encoding: "utf8" });
|
|
}
|
|
|
|
function createCmuxSplitSurface(
|
|
name: string,
|
|
direction: "left" | "right" | "up" | "down",
|
|
fromSurface?: string,
|
|
): CmuxCreatedSurface {
|
|
const identifySnapshot = captureCmuxIdentifySnapshot();
|
|
const focusSnapshot = identifySnapshot.focused;
|
|
const callerSnapshot = identifySnapshot.caller;
|
|
let child: CmuxCreatedSurface | null = null;
|
|
|
|
try {
|
|
const args = ["new-split", direction];
|
|
if (fromSurface) args.push("--surface", fromSurface);
|
|
|
|
const output = execFileSync("cmux", args, { encoding: "utf8" }).trim();
|
|
child = parseCmuxCreatedSurface(output, "new-split");
|
|
child.paneRef ??= readCmuxPaneRefForSurface(child.surface) ?? undefined;
|
|
renameCmuxSurface(child.surface, name);
|
|
return child;
|
|
} finally {
|
|
if (child) {
|
|
restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, {
|
|
sourceSurfaceRef: fromSurface,
|
|
callerSnapshot,
|
|
});
|
|
} else {
|
|
restoreCmuxFocusSnapshot(focusSnapshot);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new terminal surface for a subagent.
|
|
*
|
|
* For cmux: the first call creates a right-split pane; subsequent calls add
|
|
* tabs to that same pane (avoiding ever-narrower splits).
|
|
* For zellij: chooses a tab-aware tiled or stacked placement.
|
|
* For tmux/wezterm: falls back to split behavior.
|
|
*
|
|
* Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm).
|
|
*/
|
|
export function createSurface(name: string): string {
|
|
const backend = getMuxBackend();
|
|
|
|
if (backend === "cmux" && cmuxSubagentPane) {
|
|
// Verify the pane still exists before adding a tab to it
|
|
try {
|
|
const tree = execSync(`cmux tree`, { encoding: "utf8" });
|
|
if (tree.includes(cmuxSubagentPane)) {
|
|
return createSurfaceInPane(name, cmuxSubagentPane);
|
|
}
|
|
} catch {}
|
|
// Pane is gone — fall through to create a new split
|
|
cmuxSubagentPane = null;
|
|
}
|
|
|
|
if (backend === "cmux") {
|
|
const created = createCmuxSplitSurface(name, "right", process.env.CMUX_SURFACE_ID);
|
|
cmuxSubagentPane = created.paneRef ?? null;
|
|
return created.surface;
|
|
}
|
|
|
|
if (backend === "zellij") {
|
|
return createZellijSurface(name);
|
|
}
|
|
|
|
// On tmux, target the parent pi's pane so splits follow the agent, not the user's focus.
|
|
// See https://github.com/HazAT/pi-interactive-subagents/issues/12
|
|
const fromSurface = backend === "tmux" ? process.env.TMUX_PANE : undefined;
|
|
return createSurfaceSplit(name, "right", fromSurface);
|
|
}
|
|
|
|
/**
|
|
* Create a new surface (tab) in an existing cmux pane.
|
|
*/
|
|
function createSurfaceInPane(name: string, pane: string): string {
|
|
const identifySnapshot = captureCmuxIdentifySnapshot();
|
|
const focusSnapshot = identifySnapshot.focused;
|
|
const callerSnapshot = identifySnapshot.caller;
|
|
let child: CmuxCreatedSurface | null = null;
|
|
|
|
try {
|
|
const output = execFileSync("cmux", ["new-surface", "--pane", pane], { encoding: "utf8" }).trim();
|
|
child = parseCmuxCreatedSurface(output, "new-surface");
|
|
child.paneRef ??= pane;
|
|
renameCmuxSurface(child.surface, name);
|
|
return child.surface;
|
|
} finally {
|
|
if (child) {
|
|
restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, {
|
|
callerSnapshot,
|
|
});
|
|
} else {
|
|
restoreCmuxFocusSnapshot(focusSnapshot);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new split in the given direction from an optional source pane.
|
|
* Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm).
|
|
*/
|
|
export function createSurfaceSplit(
|
|
name: string,
|
|
direction: "left" | "right" | "up" | "down",
|
|
fromSurface?: string,
|
|
): string {
|
|
const backend = requireMuxBackend();
|
|
|
|
if (backend === "cmux") {
|
|
return createCmuxSplitSurface(name, direction, fromSurface).surface;
|
|
}
|
|
|
|
if (backend === "tmux") {
|
|
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("-P", "-F", "#{pane_id}");
|
|
|
|
const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
|
|
if (!pane.startsWith("%")) {
|
|
throw new Error(`Unexpected tmux split-window output: ${pane}`);
|
|
}
|
|
|
|
return pane;
|
|
}
|
|
|
|
if (backend === "wezterm") {
|
|
const args = ["cli", "split-pane"];
|
|
if (direction === "left") args.push("--left");
|
|
else if (direction === "right") args.push("--right");
|
|
else if (direction === "up") args.push("--top");
|
|
else args.push("--bottom");
|
|
args.push("--cwd", process.cwd());
|
|
if (fromSurface) {
|
|
args.push("--pane-id", fromSurface);
|
|
}
|
|
const paneId = execFileSync("wezterm", args, { encoding: "utf8" }).trim();
|
|
if (!paneId || !/^\d+$/.test(paneId)) {
|
|
throw new Error(`Unexpected wezterm split-pane output: ${paneId || "(empty)"}`);
|
|
}
|
|
try {
|
|
execFileSync("wezterm", ["cli", "set-tab-title", "--pane-id", paneId, name], {
|
|
encoding: "utf8",
|
|
});
|
|
} catch {
|
|
// Optional — tab title is cosmetic.
|
|
}
|
|
return paneId;
|
|
}
|
|
|
|
// zellij
|
|
const directionArg = direction === "left" || direction === "right" ? "right" : "down";
|
|
const args = ["new-pane", "--direction", directionArg, "--name", name, "--cwd", process.cwd()];
|
|
|
|
let rawId: string;
|
|
try {
|
|
rawId = zellijActionSync(args, fromSurface).trim();
|
|
} catch {
|
|
if (!fromSurface) throw new Error("Failed to create zellij pane");
|
|
rawId = zellijActionSync(args).trim();
|
|
}
|
|
|
|
// zellij returns the pane ID as e.g. "terminal_7" — extract the numeric part.
|
|
// Previously we sent `write-chars "echo $ZELLIJ_PANE_ID"` to a temp file, but
|
|
// `write-chars` without --pane-id targets the focused pane, which raced on tab switches.
|
|
const surface = parseZellijPaneSurface(rawId, "new-pane");
|
|
|
|
if (direction === "left" || direction === "up") {
|
|
try {
|
|
zellijActionSync(["move-pane", direction], surface);
|
|
} catch {
|
|
// Optional layout polish.
|
|
}
|
|
}
|
|
|
|
try {
|
|
zellijActionSync(["rename-pane", name], surface);
|
|
} catch {
|
|
// Optional.
|
|
}
|
|
|
|
return surface;
|
|
}
|
|
|
|
/**
|
|
* Rename the current tab/window.
|
|
*/
|
|
export function renameCurrentTab(title: string): void {
|
|
const backend = requireMuxBackend();
|
|
|
|
if (backend === "cmux") {
|
|
const surfaceId = process.env.CMUX_SURFACE_ID;
|
|
if (!surfaceId) throw new Error("CMUX_SURFACE_ID not set");
|
|
execSync(`cmux rename-tab --surface ${shellEscape(surfaceId)} ${shellEscape(title)}`, {
|
|
encoding: "utf8",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (backend === "tmux") {
|
|
if (process.env.PI_SUBAGENT_RENAME_TMUX_WINDOW !== "1") {
|
|
return;
|
|
}
|
|
const paneId = process.env.TMUX_PANE;
|
|
if (!paneId) throw new Error("TMUX_PANE not set");
|
|
const windowId = execFileSync("tmux", ["display-message", "-p", "-t", paneId, "#{window_id}"], {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
execFileSync("tmux", ["rename-window", "-t", windowId, title], { encoding: "utf8" });
|
|
return;
|
|
}
|
|
|
|
if (backend === "wezterm") {
|
|
const paneId = process.env.WEZTERM_PANE;
|
|
const args = ["cli", "set-tab-title"];
|
|
if (paneId) args.push("--pane-id", paneId);
|
|
args.push(title);
|
|
execFileSync("wezterm", args, { encoding: "utf8" });
|
|
return;
|
|
}
|
|
|
|
// zellij: rename the agent's own pane, not the whole tab. In multi-pane layouts,
|
|
// rename-tab clobbers the user's tab title whenever a subagent starts or /plan runs.
|
|
// Closes #21.
|
|
const paneId = process.env.ZELLIJ_PANE_ID;
|
|
if (paneId) {
|
|
zellijActionSync(["rename-pane", title], `pane:${paneId}`);
|
|
} else {
|
|
zellijActionSync(["rename-pane", title]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rename the current workspace/session where supported.
|
|
*/
|
|
export function renameWorkspace(title: string): void {
|
|
const backend = requireMuxBackend();
|
|
|
|
if (backend === "cmux") {
|
|
execSync(`cmux workspace-action --action rename --title ${shellEscape(title)}`, {
|
|
encoding: "utf8",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (backend === "tmux") {
|
|
if (process.env.PI_SUBAGENT_RENAME_TMUX_SESSION !== "1") {
|
|
return;
|
|
}
|
|
|
|
const paneId = process.env.TMUX_PANE;
|
|
if (!paneId) throw new Error("TMUX_PANE not set");
|
|
const sessionId = execFileSync(
|
|
"tmux",
|
|
["display-message", "-p", "-t", paneId, "#{session_id}"],
|
|
{
|
|
encoding: "utf8",
|
|
},
|
|
).trim();
|
|
execFileSync("tmux", ["rename-session", "-t", sessionId, title], { encoding: "utf8" });
|
|
return;
|
|
}
|
|
|
|
if (backend === "wezterm") {
|
|
const paneId = process.env.WEZTERM_PANE;
|
|
const args = ["cli", "set-window-title"];
|
|
if (paneId) args.push("--pane-id", paneId);
|
|
args.push(title);
|
|
try {
|
|
execFileSync("wezterm", args, { encoding: "utf8" });
|
|
} catch {
|
|
// Optional — window title is cosmetic.
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Skip session rename for zellij. rename-session renames the socket file
|
|
// but the ZELLIJ_SESSION_NAME env var in the parent process keeps the old
|
|
// name, so all subsequent `zellij action ...` CLI calls fail with
|
|
// "There is no active session!" because the CLI can't find the socket.
|
|
// Additionally, pi titles often contain special characters (em dashes,
|
|
// spaces) that fail zellij's session name validation on lookup.
|
|
// rename-tab (called separately) is sufficient for user-visible naming.
|
|
}
|
|
|
|
/**
|
|
* Send a command string to a pane and execute it.
|
|
*/
|
|
export function sendCommand(surface: string, command: string): void {
|
|
const backend = requireMuxBackend();
|
|
|
|
if (backend === "cmux") {
|
|
execSync(`cmux send --surface ${shellEscape(surface)} ${shellEscape(command + "\n")}`, {
|
|
encoding: "utf8",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (backend === "tmux") {
|
|
execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
|
|
execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" });
|
|
return;
|
|
}
|
|
|
|
if (backend === "wezterm") {
|
|
execFileSync(
|
|
"wezterm",
|
|
["cli", "send-text", "--pane-id", surface, "--no-paste", command + "\n"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return;
|
|
}
|
|
|
|
zellijActionSync(["write-chars", command], surface);
|
|
zellijActionSync(["write", "13"], surface);
|
|
}
|
|
|
|
/**
|
|
* Send one Escape keypress to an active pane.
|
|
*/
|
|
export function sendEscape(surface: string): void {
|
|
const backend = requireMuxBackend();
|
|
|
|
if (backend === "cmux") {
|
|
execFileSync("cmux", ["send", "--surface", surface, "\u001b"], { encoding: "utf8" });
|
|
return;
|
|
}
|
|
|
|
if (backend === "tmux") {
|
|
execFileSync("tmux", ["send-keys", "-t", surface, "Escape"], { encoding: "utf8" });
|
|
return;
|
|
}
|
|
|
|
if (backend === "wezterm") {
|
|
execFileSync("wezterm", ["cli", "send-text", "--pane-id", surface, "--no-paste", "\u001b"], {
|
|
encoding: "utf8",
|
|
});
|
|
return;
|
|
}
|
|
|
|
zellijActionSync(["write", "27"], surface);
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
const backend = requireMuxBackend();
|
|
|
|
if (backend === "cmux") {
|
|
return execSync(`cmux read-screen --surface ${shellEscape(surface)} --lines ${lines}`, {
|
|
encoding: "utf8",
|
|
});
|
|
}
|
|
|
|
if (backend === "tmux") {
|
|
return execFileSync(
|
|
"tmux",
|
|
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
|
{
|
|
encoding: "utf8",
|
|
},
|
|
);
|
|
}
|
|
|
|
if (backend === "wezterm") {
|
|
const raw = execFileSync(
|
|
"wezterm",
|
|
["cli", "get-text", "--pane-id", surface],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return tailLines(raw, lines);
|
|
}
|
|
|
|
// Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file.
|
|
// The ZELLIJ_PANE_ID env var doesn't reliably target other panes for dump-screen,
|
|
// and --path may silently fail to create the file. Stdout capture is robust.
|
|
const paneId = zellijPaneId(surface);
|
|
const raw = execFileSync(
|
|
"zellij",
|
|
["action", "dump-screen", "--pane-id", paneId],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return tailLines(raw, lines);
|
|
}
|
|
|
|
/**
|
|
* Read the screen contents of a pane (async).
|
|
*/
|
|
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
|
const backend = requireMuxBackend();
|
|
|
|
if (backend === "cmux") {
|
|
const { stdout } = await execFileAsync(
|
|
"cmux",
|
|
["read-screen", "--surface", surface, "--lines", String(lines)],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return stdout;
|
|
}
|
|
|
|
if (backend === "tmux") {
|
|
const { stdout } = await execFileAsync(
|
|
"tmux",
|
|
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return stdout;
|
|
}
|
|
|
|
if (backend === "wezterm") {
|
|
const { stdout } = await execFileAsync(
|
|
"wezterm",
|
|
["cli", "get-text", "--pane-id", surface],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return tailLines(stdout, lines);
|
|
}
|
|
|
|
// Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file.
|
|
const paneId = zellijPaneId(surface);
|
|
const { stdout } = await execFileAsync(
|
|
"zellij",
|
|
["action", "dump-screen", "--pane-id", paneId],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return tailLines(stdout, lines);
|
|
}
|
|
|
|
/**
|
|
* Close a pane.
|
|
*/
|
|
export function closeSurface(surface: string): void {
|
|
const backend = requireMuxBackend();
|
|
|
|
if (backend === "cmux") {
|
|
execSync(`cmux close-surface --surface ${shellEscape(surface)}`, {
|
|
encoding: "utf8",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (backend === "tmux") {
|
|
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
|
return;
|
|
}
|
|
|
|
if (backend === "wezterm") {
|
|
execFileSync("wezterm", ["cli", "kill-pane", "--pane-id", surface], {
|
|
encoding: "utf8",
|
|
});
|
|
return;
|
|
}
|
|
|
|
zellijActionSync(["close-pane"], surface);
|
|
}
|
|
|
|
export interface PollResult {
|
|
/** How the subagent exited */
|
|
reason: "done" | "ping" | "sentinel" | "error";
|
|
/** Shell exit code (from sentinel). 0 for file-based exits. */
|
|
exitCode: number;
|
|
/** Ping data if reason is "ping" */
|
|
ping?: { name: string; message: string };
|
|
/** Error message if reason is "error" (auto-retry exhausted, provider overload, etc.) */
|
|
errorMessage?: string;
|
|
}
|
|
|
|
/**
|
|
* Interpret an `.exit` sidecar payload (written by subagent_done / caller_ping /
|
|
* the error path in subagent-done.ts). Centralized so both the fast and slow
|
|
* paths in pollForExit decode the payload the same way.
|
|
*/
|
|
function interpretExitSidecar(data: any): PollResult {
|
|
if (data?.type === "ping") {
|
|
return {
|
|
reason: "ping",
|
|
exitCode: 0,
|
|
ping: { name: data.name, message: data.message },
|
|
};
|
|
}
|
|
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 subagent_done / caller_ping), falling back to the terminal
|
|
* sentinel for 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 subagent_done / caller_ping)
|
|
if (options.sessionFile) {
|
|
try {
|
|
const exitFile = `${options.sessionFile}.exit`;
|
|
if (existsSync(exitFile)) {
|
|
const data = JSON.parse(readFileSync(exitFile, "utf8"));
|
|
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, "utf8"));
|
|
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 });
|
|
});
|
|
}
|
|
}
|