Files
pi-interactive-subagents/pi-extension/subagents/cmux.ts
T

1369 lines
41 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).
*/
/**
* tmux layout applied to the subagent window to keep panes evenly sized.
* Switchable: "even-horizontal" (equal columns, matches Ctrl+b Alt+1),
* "main-vertical" (big main pane + tiled column), "tiled" (grid).
*/
const SUBAGENT_TMUX_LAYOUT = "even-horizontal";
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. tmux-only — cmux uses
* tabs and zellij/wezterm manage their own layouts. 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.
*/
export function rebalanceSurfaces(hintPane?: string): void {
if (getMuxBackend() !== "tmux") return;
// 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",
});
} catch {
// Pane/window may be gone; balancing is best-effort.
}
}, 120);
}
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}`);
}
rebalanceSurfaces(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" });
rebalanceSurfaces();
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" | "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, "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 });
});
}
}