diff --git a/README.md b/README.md index 0b6d5d0..ef22c66 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pi-interactive-subagents -Async subagents for [pi](https://github.com/badlogic/pi-mono) — spawn, orchestrate, and manage sub-agent sessions in multiplexer panes. **Fully non-blocking** — the main agent keeps working while subagents run in the background. +Async subagents for [pi](https://github.com/badlogic/pi-mono) — spawn, orchestrate, and manage sub-agent sessions in tmux panes. **Fully non-blocking** — the main agent keeps working while subagents run in the background. ## How It Works @@ -27,13 +27,13 @@ If your shell startup is slow and subagent commands sometimes get dropped before export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 ``` -Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch commands target child surfaces by explicit ID, so focus and command delivery are independent. Note: the `interactive` option controls parent status notifications, not terminal focus. +Subagent panes are created without stealing keyboard focus: splits target the parent pi pane by explicit id, so focus and pane creation are independent. Launch commands target child panes by id as well. -### Pane layout (tmux) +### Pane layout -Under tmux, each subagent is a horizontal split off the parent pi pane. Left to itself, tmux halves the target pane on every split and dumps freed space onto a neighbor on close, so panes drift to wildly uneven widths. To prevent that, the extension automatically re-applies an even layout to the subagent window after every spawn and every exit (debounced so a burst of parallel spawns or staggered exits collapses into a single layout call). This only runs under tmux — cmux uses tabs, and zellij/wezterm manage their own layouts. +Each subagent is a horizontal split off the parent pi pane. Left to itself, tmux halves the target pane on every split and dumps freed space onto a neighbor on close, so panes drift to wildly uneven widths. To prevent that, the extension automatically re-applies an even layout to the subagent window after every spawn and every exit (debounced so a burst of parallel spawns or staggered exits collapses into a single layout call). -The layout is a single constant in `pi-extension/subagents/cmux.ts`: +The layout is a single constant in `pi-extension/subagents/tmux.ts`: ```ts const SUBAGENT_TMUX_LAYOUT = "even-horizontal"; @@ -57,7 +57,7 @@ The value is passed straight to `tmux select-layout`, so any named tmux layout w | Tool | Description | | ------------------ | ------------------------------------------------------------------------------------------------- | -| `subagent` | Spawn a sub-agent in a dedicated multiplexer pane (async — returns immediately) | +| `subagent` | Spawn a sub-agent in a dedicated tmux pane (async — returns immediately) | | `subagent_message` | Steer a running subagent or resume a finished one — by `name` either way | | `subagents_list` | List available agent definitions | @@ -83,7 +83,7 @@ Agent discovery follows priority: **project-local** (`.pi/agents/`) > **global** ``` 1. Agent calls subagent() → returns immediately ("started") -2. Sub-agent runs in mux pane → widget shows live status +2. Sub-agent runs in tmux pane → widget shows live status 3. User keeps chatting → main session fully interactive 4. Sub-agent finishes → result steered back as a normal completion/failure 5. Main agent processes result → continues with new context @@ -420,33 +420,19 @@ Every sub-agent session displays a compact tools widget showing the agent's allo ## Requirements - [pi](https://github.com/badlogic/pi-mono) — the coding agent -- One supported multiplexer: - - [cmux](https://github.com/manaflow-ai/cmux) - - [tmux](https://github.com/tmux/tmux) - - [zellij](https://zellij.dev) - - [WezTerm](https://wezfurlong.org/wezterm/) +- [tmux](https://github.com/tmux/tmux) ```bash -cmux pi -# or tmux new -A -s pi 'pi' -# or -zellij --session pi # then run: pi -# or -# just run pi inside WezTerm ``` -Optional backend override: - -```bash -export PI_SUBAGENT_MUX=cmux # or tmux, zellij, wezterm -``` +> This fork is **tmux-only**. It was forked from [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents), which also supports cmux, zellij, and WezTerm — look there if you need another multiplexer. --- ## Acknowledgements -The sub-agent status supervision and turn-only interruption features were inspired by [RepoPrompt](https://repoprompt.com/)'s sub-agent snapshot polling and run cancellation features. +Forked from [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents), which originated the subagent architecture, the multi-multiplexer surface layer, and the status widget. The sub-agent status supervision and turn-only interruption features were inspired by [RepoPrompt](https://repoprompt.com/)'s sub-agent snapshot polling and run cancellation features. --- diff --git a/package.json b/package.json index 9c4a4a8..a95ca5d 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "pi-interactive-subagents", "version": "3.7.2", - "description": "Interactive subagents for pi - spawn, orchestrate, and manage sub-agent sessions in cmux/tmux/zellij terminals", + "description": "Interactive subagents for pi - spawn, orchestrate, and manage sub-agent sessions in tmux", "keywords": [ "pi-package" ], "license": "MIT", - "author": "HazAT", + "author": "Amos Blomqvist", "repository": { "type": "git", - "url": "https://github.com/HazAT/pi-interactive-subagents" + "url": "https://github.com/amosblomqvist/pi-interactive-subagents" }, "type": "module", "scripts": { diff --git a/pi-extension/subagents/cmux.ts b/pi-extension/subagents/cmux.ts deleted file mode 100644 index edb9294..0000000 --- a/pi-extension/subagents/cmux.ts +++ /dev/null @@ -1,1368 +0,0 @@ -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(); - -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 { - 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(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 | 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 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 { - 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 { - 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((resolve, reject) => { - if (signal.aborted) return reject(new Error("Aborted")); - const timer = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, options.interval); - function onAbort() { - clearTimeout(timer); - reject(new Error("Aborted")); - } - signal.addEventListener("abort", onAbort, { once: true }); - }); - } -} diff --git a/pi-extension/subagents/index.ts b/pi-extension/subagents/index.ts index 9f7ad69..7e20a0f 100644 --- a/pi-extension/subagents/index.ts +++ b/pi-extension/subagents/index.ts @@ -22,10 +22,9 @@ import { sendLongCommand, pollForExit, closeSurface, - getMuxBackend, shellEscape, readScreen, -} from "./cmux.ts"; +} from "./tmux.ts"; import { countSessionEntryLines, @@ -509,10 +508,10 @@ function muxUnavailableResult() { content: [ { type: "text" as const, - text: `Subagents require a supported terminal multiplexer. ${muxSetupHint()}`, + text: `Subagents require tmux. ${muxSetupHint()}`, }, ], - details: { error: "mux not available" }, + details: { error: "tmux not available" }, }; } @@ -1007,10 +1006,9 @@ function steerSubagent( send(running.surface, flattened); return { ok: true }; } catch (error: any) { - const backend = getMuxBackend() ?? "unknown"; return { error: - `Failed to deliver message to subagent "${running.name}" via ${backend}: ` + + `Failed to deliver message to subagent "${running.name}" via tmux: ` + `${error?.message ?? String(error)}`, }; } diff --git a/pi-extension/subagents/tmux.ts b/pi-extension/subagents/tmux.ts new file mode 100644 index 0000000..c18e240 --- /dev/null +++ b/pi-extension/subagents/tmux.ts @@ -0,0 +1,354 @@ +/** + * tmux surface layer — the only terminal multiplexer this extension supports. + * + * 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. + */ +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const execFileAsync = promisify(execFile); + +// ── Availability ── + +const commandAvailability = new Map(); + +function hasCommand(command: string): boolean { + if (commandAvailability.has(command)) { + return commandAvailability.get(command)!; + } + + let available = false; + try { + execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" }); + available = true; + } catch { + available = false; + } + + commandAvailability.set(command, available); + 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 { + return !!process.env.TMUX && hasCommand("tmux"); +} + +export function isMuxAvailable(): boolean { + return isTmuxAvailable(); +} + +export function muxSetupHint(): 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()}`); + } +} + +// ── 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). + */ +const SUBAGENT_TMUX_LAYOUT = "even-horizontal"; + +let rebalanceTimer: ReturnType | null = null; + +/** + * Re-balance subagent panes so repeated splits don't leave them lopsided. + * tmux halves the target pane on every split and dumps freed space onto a + * neighbor on close, so without this panes drift to wildly uneven widths. + * Applies SUBAGENT_TMUX_LAYOUT to the parent pi window. Debounced so a burst + * of parallel spawns or staggered exits collapses into a single layout call, + * and non-fatal: a cosmetic resize must never break spawning or watching. + */ +function rebalanceSurfaces(hintPane?: string): void { + // Prefer the parent pi pane (stable; survives a closing subagent pane). + const target = process.env.TMUX_PANE ?? hintPane; + if (!target) return; + if (rebalanceTimer) clearTimeout(rebalanceTimer); + rebalanceTimer = setTimeout(() => { + rebalanceTimer = null; + try { + // -t resolves to that pane's window; does not change focus. + execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], { + encoding: "utf8", + }); + } catch { + // Pane/window may be gone; balancing is best-effort. + } + }, 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", + fromSurface?: string, +): string { + void name; + 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("-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; +} + +/** + * 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", + }, + ); +} + +/** + * Read the screen contents of a pane (async). + */ +export async function readScreenAsync(surface: string, lines = 50): Promise { + requireTmux(); + const { stdout } = await execFileAsync( + "tmux", + ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`], + { encoding: "utf8" }, + ); + 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 { + const start = Date.now(); + + for (;;) { + if (signal.aborted) { + throw new Error("Aborted while waiting for subagent to finish"); + } + + // Fast path: check for .exit sidecar file (written by the error path) + if (options.sessionFile) { + try { + const exitFile = `${options.sessionFile}.exit`; + if (existsSync(exitFile)) { + const data = JSON.parse(readFileSync(exitFile, "utf-8")); + rmSync(exitFile, { force: true }); + return interpretExitSidecar(data); + } + } catch {} + } + + // Check Claude sentinel file (written by plugin Stop hook) + if (options.sentinelFile) { + try { + if (existsSync(options.sentinelFile)) { + return { reason: "sentinel", exitCode: 0 }; + } + } catch {} + } + + // Slow path: read terminal screen for sentinel (crash detection) + try { + const screen = await readScreenAsync(surface, 5); + const match = screen.match(/__SUBAGENT_DONE_(\d+)__/); + if (match) { + return { reason: "sentinel", exitCode: parseInt(match[1], 10) }; + } + } catch { + // Surface may have been destroyed — check if .exit file appeared in the meantime + if (options.sessionFile) { + try { + const exitFile = `${options.sessionFile}.exit`; + if (existsSync(exitFile)) { + const data = JSON.parse(readFileSync(exitFile, "utf-8")); + rmSync(exitFile, { force: true }); + return interpretExitSidecar(data); + } + } catch {} + } + } + + const elapsed = Math.floor((Date.now() - start) / 1000); + options.onTick?.(elapsed); + + await new Promise((resolve, reject) => { + if (signal.aborted) return reject(new Error("Aborted")); + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, options.interval); + function onAbort() { + clearTimeout(timer); + reject(new Error("Aborted")); + } + signal.addEventListener("abort", onAbort, { once: true }); + }); + } +} diff --git a/test/integration/harness.ts b/test/integration/harness.ts index 3d11a51..f3a24c1 100644 --- a/test/integration/harness.ts +++ b/test/integration/harness.ts @@ -2,11 +2,11 @@ * Integration test harness for pi-interactive-subagents. * * Provides utilities to: - * - Detect available mux backends (cmux, tmux, zellij) + * - Detect whether tmux is available * - Create isolated test environments with test agent definitions - * - Start real pi sessions in mux surfaces + * - Start real pi sessions in tmux panes * - Poll for file creation and screen output - * - Clean up surfaces and temp files after tests + * - Clean up panes and temp files after tests */ import { execFileSync } from "node:child_process"; import { @@ -23,7 +23,7 @@ import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import { - getMuxBackend, + isMuxAvailable, createSurface, createSurfaceSplit, sendCommand, @@ -31,14 +31,10 @@ import { readScreen, readScreenAsync, closeSurface, - sendEscape, shellEscape, - parseCmuxFocusedSnapshotFromJson, - parseCmuxPaneRefForSurfaceFromJson, - type MuxBackend, -} from "../../pi-extension/subagents/cmux.ts"; +} from "../../pi-extension/subagents/tmux.ts"; -// Re-export mux primitives for tests +// Re-export tmux primitives for tests export { createSurface, createSurfaceSplit, @@ -47,10 +43,8 @@ export { readScreen, readScreenAsync, closeSurface, - sendEscape, shellEscape, }; -export type { MuxBackend }; // ── Paths ── @@ -82,99 +76,42 @@ export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000"); // ── Backend detection ── /** - * Detect which mux backends are actually available in the current environment. - * Temporarily sets PI_SUBAGENT_MUX to probe each backend. + * Detect whether tmux is available in the current environment. + * Returns ["tmux"] or []. */ -export function getAvailableBackends(): MuxBackend[] { - const backends: MuxBackend[] = []; - const orig = process.env.PI_SUBAGENT_MUX; - - for (const backend of ["cmux", "tmux", "zellij"] as MuxBackend[]) { - process.env.PI_SUBAGENT_MUX = backend; - try { - if (getMuxBackend() === backend) backends.push(backend); - } catch {} - } - - if (orig === undefined) delete process.env.PI_SUBAGENT_MUX; - else process.env.PI_SUBAGENT_MUX = orig; - - return backends; +export function getAvailableBackends(): string[] { + return isMuxAvailable() ? ["tmux"] : []; } -export function setBackend(backend: MuxBackend): string | undefined { - const prev = process.env.PI_SUBAGENT_MUX; - process.env.PI_SUBAGENT_MUX = backend; - return prev; +export function focusSurface(surface: string): void { + execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" }); } -export function restoreBackend(prev: string | undefined): void { - if (prev === undefined) delete process.env.PI_SUBAGENT_MUX; - else process.env.PI_SUBAGENT_MUX = prev; -} - -export function focusSurface(backend: MuxBackend, surface: string): void { - if (backend === "cmux") { - const pane = getSurfacePane(backend, surface); - if (pane) execFileSync("cmux", ["focus-pane", "--pane", pane], { encoding: "utf8" }); - execFileSync("cmux", ["focus-panel", "--panel", surface], { encoding: "utf8" }); - return; +export function getFocusedSurface(): string | null { + try { + const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], { + encoding: "utf8", + }); + const activeLine = panes.split("\n").find((line) => line.endsWith(" 1")); + return activeLine?.split(" ")[0] ?? null; + } catch { + return null; } - - if (backend === "tmux") { - execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" }); - return; - } - - throw new Error(`Focus helpers are not implemented for ${backend}`); -} - -export function getFocusedSurface(backend: MuxBackend): string | null { - if (backend === "cmux") { - const info = execFileSync("cmux", ["identify", "--json"], { encoding: "utf8" }); - return parseCmuxFocusedSnapshotFromJson(info)?.surfaceRef ?? null; - } - - if (backend === "tmux") { - try { - const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], { - encoding: "utf8", - }); - const activeLine = panes.split("\n").find((line) => line.endsWith(" 1")); - return activeLine?.split(" ")[0] ?? null; - } catch { - return null; - } - } - - throw new Error(`Focus helpers are not implemented for ${backend}`); -} - -export function getSurfacePane(backend: MuxBackend, surface: string): string | null { - if (backend === "cmux") { - const info = execFileSync("cmux", ["identify", "--surface", surface], { encoding: "utf8" }); - return parseCmuxPaneRefForSurfaceFromJson(info, surface); - } - - if (backend === "tmux") return surface; - - throw new Error(`Pane lookup is not implemented for ${backend}`); } export async function waitForFocusedSurface( - backend: MuxBackend, surface: string, timeout: number = PI_TIMEOUT, ): Promise { const start = Date.now(); while (Date.now() - start < timeout) { - if (getFocusedSurface(backend) === surface) return; + if (getFocusedSurface() === surface) return; await sleep(200); } throw new Error( - `Timeout (${timeout}ms) waiting for focused ${backend} surface ${surface}; ` + - `current focus is ${getFocusedSurface(backend) ?? "unknown"}`, + `Timeout (${timeout}ms) waiting for focused tmux pane ${surface}; ` + + `current focus is ${getFocusedSurface() ?? "unknown"}`, ); } @@ -183,9 +120,7 @@ export async function waitForFocusedSurface( export interface TestEnv { /** Temp directory serving as the test project root */ dir: string; - /** Active mux backend for this test run */ - backend: MuxBackend; - /** Surfaces created during the test (cleaned up automatically) */ + /** Panes created during the test (cleaned up automatically) */ surfaces: string[]; /** Temp files to clean up */ tempFiles: string[]; @@ -195,7 +130,7 @@ export interface TestEnv { * Create an isolated test environment with test agent definitions. * The temp dir has `.pi/agents/` containing copies of all test agents. */ -export function createTestEnv(backend: MuxBackend): TestEnv { +export function createTestEnv(): TestEnv { const dir = mkdtempSync(join(tmpdir(), "pi-integ-")); const agentsDir = join(dir, ".pi", "agents"); mkdirSync(agentsDir, { recursive: true }); @@ -209,7 +144,7 @@ export function createTestEnv(backend: MuxBackend): TestEnv { } } - return { dir, backend, surfaces: [], tempFiles: [] }; + return { dir, surfaces: [], tempFiles: [] }; } /** diff --git a/test/integration/subagent-lifecycle.test.ts b/test/integration/subagent-lifecycle.test.ts index 128a8d5..f2a96cb 100644 --- a/test/integration/subagent-lifecycle.test.ts +++ b/test/integration/subagent-lifecycle.test.ts @@ -2,14 +2,13 @@ * Integration tests for the full subagent lifecycle. * * These tests spawn REAL pi sessions with REAL LLM calls (haiku by default). - * Each test creates a mux surface, runs pi with a task that uses the subagent + * Each test creates a tmux pane, runs pi with a task that uses the subagent * tool, and verifies the outcome via marker files and screen output. * * Costs: ~$0.01-0.05 per test run (haiku). * Duration: ~30-90s per test. * - * Run inside a supported multiplexer: - * cmux bash -c 'npm run test:integration' + * Run inside tmux: * tmux new 'npm run test:integration' * * Configuration: @@ -21,8 +20,6 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { getAvailableBackends, - setBackend, - restoreBackend, createTestEnv, cleanupTestEnv, createTrackedSurface, @@ -40,23 +37,20 @@ import { const backends = getAvailableBackends(); if (backends.length === 0) { - console.log("⚠️ No mux backend available — skipping subagent lifecycle integration tests"); - console.log(" Run inside cmux or tmux to enable these tests."); + console.log("⚠️ tmux is not available — skipping subagent lifecycle integration tests"); + console.log(" Run inside tmux to enable these tests."); } for (const backend of backends) { describe(`subagent-lifecycle [${backend}]`, { timeout: PI_TIMEOUT * 3 }, () => { - let prevMux: string | undefined; let env: TestEnv; before(() => { - prevMux = setBackend(backend); - env = createTestEnv(backend); + env = createTestEnv(); }); after(() => { cleanupTestEnv(env); - restoreBackend(prevMux); }); // ── Basic spawn + completion ── diff --git a/test/integration/mux-surface.test.ts b/test/integration/tmux-surface.test.ts similarity index 69% rename from test/integration/mux-surface.test.ts rename to test/integration/tmux-surface.test.ts index 891428b..15f3ad3 100644 --- a/test/integration/mux-surface.test.ts +++ b/test/integration/tmux-surface.test.ts @@ -1,29 +1,24 @@ /** - * Integration tests for the multiplexer surface layer. + * Integration tests for the tmux surface layer. * - * These tests exercise real mux operations: creating panes, - * sending commands, reading screen output, and closing surfaces. + * These tests exercise real tmux operations: creating panes, + * sending commands, reading screen output, and closing panes. * No LLM calls — fast and free. * - * Run inside a supported multiplexer: - * cmux bash -c 'npm run test:integration' + * Run inside tmux: * tmux new 'npm run test:integration' - * zellij --session pi # then run: npm run test:integration */ import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; import { unlinkSync } from "node:fs"; import { getAvailableBackends, - setBackend, - restoreBackend, createTestEnv, cleanupTestEnv, createTrackedSurface, createTrackedSurfaceSplit, focusSurface, getFocusedSurface, - getSurfacePane, waitForFocusedSurface, untrackSurface, sendCommand, @@ -31,7 +26,6 @@ import { readScreen, readScreenAsync, closeSurface, - sendEscape, sleep, uniqueId, trackTempFile, @@ -44,47 +38,36 @@ const backends = getAvailableBackends(); const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500"); if (backends.length === 0) { - console.log("⚠️ No mux backend available — skipping mux-surface integration tests"); - console.log(" Run inside cmux or tmux to enable these tests."); + console.log("⚠️ tmux is not available — skipping tmux-surface integration tests"); + console.log(" Run inside tmux to enable these tests."); } for (const backend of backends) { - describe(`mux-surface [${backend}]`, { timeout: 60_000 }, () => { - let prevMux: string | undefined; + describe(`tmux-surface [${backend}]`, { timeout: 60_000 }, () => { let env: TestEnv; before(() => { - prevMux = setBackend(backend); - env = createTestEnv(backend); + env = createTestEnv(); }); after(() => { cleanupTestEnv(env); - restoreBackend(prevMux); }); it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => { const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right"); await sleep(1000); - focusSurface(backend, anchor); - await waitForFocusedSurface(backend, anchor, 10_000); + focusSurface(anchor); + await waitForFocusedSurface(anchor, 10_000); const childA = createTrackedSurface(env, "focus-child-a"); await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS); - assert.equal(getFocusedSurface(backend), anchor); + assert.equal(getFocusedSurface(), anchor); const childB = createTrackedSurface(env, "focus-child-b"); await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS); - assert.equal(getFocusedSurface(backend), anchor); - - if (backend === "cmux") { - const paneA = getSurfacePane(backend, childA); - const paneB = getSurfacePane(backend, childB); - assert.ok(paneA, `Expected pane ref for ${childA}`); - assert.ok(paneB, `Expected pane ref for ${childB}`); - assert.equal(paneB, paneA); - } + assert.equal(getFocusedSurface(), anchor); const markerA = uniqueId(); const markerB = uniqueId(); @@ -95,7 +78,7 @@ for (const backend of backends) { waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50), waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50), ]); - assert.equal(getFocusedSurface(backend), anchor); + assert.equal(getFocusedSurface(), anchor); }); it("creates a surface, sends a command, reads output, and closes it", async () => { @@ -197,7 +180,7 @@ for (const backend of backends) { await sleep(1000); const marker = uniqueId(); - const filePath = `/tmp/pi-mux-test-${marker}.txt`; + const filePath = `/tmp/pi-tmux-test-${marker}.txt`; sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`); @@ -210,34 +193,5 @@ for (const backend of backends) { unlinkSync(filePath); } catch {} }); - - it("delivers Escape as byte 27 to the target surface", async () => { - const surface = createTrackedSurface(env, "escape-byte-test"); - await sleep(1000); - - const marker = uniqueId(); - const byteFile = `/tmp/pi-mux-escape-${marker}.txt`; - trackTempFile(env, byteFile); - - const nodeProgram = - "const fs = require('node:fs');" + - "if (!process.stdin.isTTY) throw new Error('stdin is not a TTY');" + - "process.stdin.setRawMode(true);" + - "process.stdin.resume();" + - "process.stdout.write('ESC_READY\\n');" + - "process.stdin.once('data', (chunk) => {" + - `fs.writeFileSync(${JSON.stringify(byteFile)}, Array.from(chunk).join(','));` + - "process.exit(0);" + - "});"; - const command = `node -e ${JSON.stringify(nodeProgram)}`; - - sendLongCommand(surface, command); - await waitForScreen(surface, /ESC_READY/, 15_000, 50); - - sendEscape(surface); - - const content = await waitForFile(byteFile, 15_000, /^27$/); - assert.equal(content.trim(), "27"); - }); }); } diff --git a/test/test.ts b/test/test.ts index 5b98f42..a8efda6 100644 --- a/test/test.ts +++ b/test/test.ts @@ -30,20 +30,7 @@ import { summarizeSessionStats, } from "../pi-extension/subagents/session.ts"; -import { - shellEscape, - isCmuxAvailable, - isWezTermAvailable, - parseCmuxFocusedSnapshot, - parseCmuxFocusedSnapshotFromJson, - parseCmuxJson, - parseCmuxPaneRefForSurface, - parseCmuxPaneRefForSurfaceFromJson, - canSplitZellijPane, - predictZellijSplitDirection, - selectZellijPlacement, - selectZellijStackPlacement, -} from "../pi-extension/subagents/cmux.ts"; +import { shellEscape } from "../pi-extension/subagents/tmux.ts"; import { advanceStatusState, capStatusLines, @@ -69,7 +56,7 @@ import { runningChildrenCount, } from "../pi-extension/subagents/subagent-done.ts"; import subagentDoneExtension from "../pi-extension/subagents/subagent-done.ts"; -import { __pollForExitTest__ } from "../pi-extension/subagents/cmux.ts"; +import { __pollForExitTest__ } from "../pi-extension/subagents/tmux.ts"; // --- Helpers --- @@ -1754,7 +1741,7 @@ describe("subagent-done.ts", () => { }); }); -describe("cmux.ts interpretExitSidecar", () => { +describe("tmux.ts interpretExitSidecar", () => { const { interpretExitSidecar } = __pollForExitTest__; it("no longer decodes ping payloads (ask_question keeps the session open instead)", () => { @@ -2665,7 +2652,7 @@ describe("subagent display helpers", () => { }); }); -describe("cmux.ts", () => { +describe("tmux.ts", () => { describe("shellEscape", () => { it("wraps in single quotes", () => { assert.equal(shellEscape("hello"), "'hello'"); @@ -2688,271 +2675,4 @@ describe("cmux.ts", () => { assert.ok(escaped.includes("$world")); }); }); - - describe("parseCmuxFocusedSnapshot", () => { - it("parses focused surface and pane refs", () => { - assert.deepEqual( - parseCmuxFocusedSnapshot({ focused: { surface_ref: "surface:3", pane_ref: "pane:2" } }), - { surfaceRef: "surface:3", paneRef: "pane:2" }, - ); - }); - - it("does not fall back to caller refs", () => { - assert.equal( - parseCmuxFocusedSnapshot({ caller: { surface_ref: "surface:1", pane_ref: "pane:1" } }), - null, - ); - }); - - it("returns null for malformed values", () => { - assert.equal(parseCmuxFocusedSnapshot(null), null); - assert.equal(parseCmuxFocusedSnapshot({ focused: {} }), null); - }); - }); - - describe("parseCmuxJson", () => { - it("returns null for malformed JSON text", () => { - assert.equal(parseCmuxJson("not json"), null); - }); - - it("parses valid JSON text", () => { - assert.deepEqual(parseCmuxJson('{"ok":true}'), { ok: true }); - }); - }); - - describe("parseCmuxFocusedSnapshotFromJson", () => { - it("returns null for malformed JSON text", () => { - assert.equal(parseCmuxFocusedSnapshotFromJson("not json"), null); - }); - - it("returns null when focused is absent or not an object", () => { - assert.equal( - parseCmuxFocusedSnapshotFromJson('{"focused":null,"caller":{"surface_ref":"surface:1","pane_ref":"pane:1"}}'), - null, - ); - assert.equal( - parseCmuxFocusedSnapshotFromJson('{"caller":{"surface_ref":"surface:1","pane_ref":"pane:1"}}'), - null, - ); - }); - - it("parses focused refs without falling back to caller refs", () => { - assert.deepEqual( - parseCmuxFocusedSnapshotFromJson( - '{"caller":{"surface_ref":"surface:1","pane_ref":"pane:1"},"focused":{"surface_ref":"surface:2","pane_ref":"pane:3"}}', - ), - { surfaceRef: "surface:2", paneRef: "pane:3" }, - ); - }); - }); - - describe("parseCmuxPaneRefForSurface", () => { - it("parses top-level pane refs for a surface", () => { - assert.equal( - parseCmuxPaneRefForSurface({ surface_ref: "surface:7", pane_ref: "pane:4" }, "surface:7"), - "pane:4", - ); - }); - - it("parses caller pane refs for identify --surface output", () => { - assert.equal( - parseCmuxPaneRefForSurface( - { caller: { surface_ref: "surface:7", pane_ref: "pane:4" } }, - "surface:7", - ), - "pane:4", - ); - }); - - it("returns null when the surface does not match", () => { - assert.equal( - parseCmuxPaneRefForSurface({ surface_ref: "surface:8", pane_ref: "pane:4" }, "surface:7"), - null, - ); - }); - }); - - describe("parseCmuxPaneRefForSurfaceFromJson", () => { - it("returns null for malformed JSON text", () => { - assert.equal(parseCmuxPaneRefForSurfaceFromJson("not json", "surface:7"), null); - }); - - it("parses caller refs from cmux identify --surface JSON text", () => { - assert.equal( - parseCmuxPaneRefForSurfaceFromJson( - '{"caller":{"surface_ref":"surface:7","pane_ref":"pane:4"}}', - "surface:7", - ), - "pane:4", - ); - }); - }); - - describe("zellij placement", () => { - const pane = (overrides: any) => ({ - id: 1, - is_plugin: false, - is_floating: false, - is_selectable: true, - exited: false, - pane_rows: 20, - pane_columns: 80, - tab_id: 1, - ...overrides, - }); - - it("matches Zellij direction and minimum split rules", () => { - assert.equal(predictZellijSplitDirection(pane({ pane_rows: 5, pane_columns: 11 })), "right"); - assert.equal(predictZellijSplitDirection(pane({ pane_rows: 11, pane_columns: 5 })), "down"); - assert.equal(predictZellijSplitDirection(pane({ pane_rows: 5, pane_columns: 10 })), null); - assert.equal(predictZellijSplitDirection(pane({ pane_rows: 4, pane_columns: 80 })), null); - - assert.equal(canSplitZellijPane(pane({ pane_rows: 5, pane_columns: 11 })), true); - assert.equal(canSplitZellijPane(pane({ pane_rows: 11, pane_columns: 5 })), true); - assert.equal(canSplitZellijPane(pane({ pane_rows: 5, pane_columns: 10 })), false); - assert.equal(canSplitZellijPane(pane({ pane_rows: 4, pane_columns: 80 })), false); - - assert.equal(canSplitZellijPane(pane({ pane_rows: 30, pane_columns: 100 }), 80, 20), false); - assert.equal(canSplitZellijPane(pane({ pane_rows: 45, pane_columns: 100 }), 80, 20), true); - assert.equal(canSplitZellijPane(pane({ pane_rows: 30, pane_columns: 170 }), 80, 20), true); - assert.equal(canSplitZellijPane(pane({ pane_rows: 31, pane_columns: 47 }), 50, 10), false); - assert.equal(canSplitZellijPane(pane({ pane_rows: 31, pane_columns: 77 }), 50, 10), true); - }); - - it("uses tab-scoped split only when all Zellij split candidates are safe", () => { - const plan = selectZellijPlacement( - [ - pane({ id: 10, tab_id: 1, pane_rows: 40, pane_columns: 120 }), - pane({ id: 11, tab_id: 1, pane_rows: 120, pane_columns: 100 }), - pane({ id: 12, tab_id: 2, pane_rows: 60, pane_columns: 200 }), - ], - 10, - ); - - assert.deepEqual(plan, { - mode: "split", - anchorPaneId: 11, - targetPaneId: 11, - tabId: 1, - splitDirection: "down", - }); - }); - - it("stacks when any Zellij split candidate would fall below Pi's configured minimum", () => { - const plan = selectZellijPlacement( - [ - pane({ id: 10, tab_id: 1, pane_rows: 100, pane_columns: 47 }), - pane({ id: 11, tab_id: 1, pane_rows: 31, pane_columns: 77 }), - ], - 10, - 50, - 10, - ); - - assert.deepEqual(plan, { - mode: "stack", - anchorPaneId: 11, - targetPaneId: 11, - tabId: 1, - }); - }); - - it("stacks when Zellij would split a pane below Pi's usable minimum", () => { - const plan = selectZellijPlacement( - [ - pane({ id: 10, tab_id: 1, pane_rows: 20, pane_columns: 20 }), - pane({ id: 11, tab_id: 1, pane_rows: 18, pane_columns: 60 }), - pane({ id: 12, tab_id: 1, pane_rows: 10, pane_columns: 70 }), - ], - 10, - ); - - assert.deepEqual(plan, { - mode: "stack", - anchorPaneId: 11, - targetPaneId: 11, - tabId: 1, - }); - }); - - it("never chooses the parent pane as the stack target", () => { - const plan = selectZellijStackPlacement( - [ - pane({ id: 10, tab_id: 1, pane_rows: 60, pane_columns: 200 }), - pane({ id: 11, tab_id: 1, pane_rows: 10, pane_columns: 20 }), - pane({ id: 12, tab_id: 1, pane_rows: 8, pane_columns: 30 }), - ], - 10, - ); - - assert.deepEqual(plan, { - mode: "stack", - anchorPaneId: 12, - targetPaneId: 12, - tabId: 1, - }); - }); - - it("does not stack when the only usable pane is the parent", () => { - const plan = selectZellijStackPlacement( - [pane({ id: 10, tab_id: 1, pane_rows: 60, pane_columns: 200 })], - 10, - ); - - assert.equal(plan, null); - }); - - it("stacks on the largest usable non-parent pane when none can split", () => { - const plan = selectZellijPlacement( - [ - pane({ id: 10, tab_id: 1, pane_rows: 5, pane_columns: 10 }), - pane({ id: 11, tab_id: 1, pane_rows: 6, pane_columns: 8 }), - pane({ id: 12, tab_id: 2, pane_rows: 60, pane_columns: 200 }), - ], - 10, - ); - - assert.deepEqual(plan, { - mode: "stack", - anchorPaneId: 11, - targetPaneId: 11, - tabId: 1, - }); - }); - - it("ignores floating, plugin, exited, unselectable, and other-tab panes", () => { - const plan = selectZellijPlacement( - [ - pane({ id: 10, tab_id: 1, pane_rows: 5, pane_columns: 10 }), - pane({ id: 11, tab_id: 1, pane_rows: 60, pane_columns: 200, is_floating: true }), - pane({ id: 12, tab_id: 1, pane_rows: 60, pane_columns: 200, is_plugin: true }), - pane({ id: 13, tab_id: 1, pane_rows: 60, pane_columns: 200, exited: true }), - pane({ id: 14, tab_id: 1, pane_rows: 60, pane_columns: 200, is_selectable: false }), - pane({ id: 15, tab_id: 2, pane_rows: 60, pane_columns: 200 }), - ], - 10, - ); - - assert.equal(plan, null); - }); - - it("returns null when the parent pane cannot be found", () => { - assert.equal(selectZellijPlacement([pane({ id: 10 })], 99), null); - }); - }); - - describe("isCmuxAvailable", () => { - it("returns boolean based on CMUX_SOCKET_PATH", () => { - // Can't easily mock env in node:test, just verify it returns a boolean - const result = isCmuxAvailable(); - assert.equal(typeof result, "boolean"); - }); - }); - - describe("isWezTermAvailable", () => { - it("returns boolean based on WEZTERM_UNIX_SOCKET", () => { - const result = isWezTermAvailable(); - assert.equal(typeof result, "boolean"); - }); - }); });