made extension tmux-only, replaced cmux.ts with tmux.ts (~1368 -> 354 lines)
Removed the cmux, zellij, and wezterm backends, the MuxBackend type, the PI_SUBAGENT_MUX override, and dead exports (renameCurrentTab, renameWorkspace, sendEscape, isFishShell, exitStatusVar). The ten-function surface API used by index.ts is unchanged. Integration harness and tests are tmux-only; README and package.json now state tmux-only with a fork note pointing multi-multiplexer users at the upstream project.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# pi-interactive-subagents
|
# 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
|
## 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
|
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
|
```ts
|
||||||
const SUBAGENT_TMUX_LAYOUT = "even-horizontal";
|
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 |
|
| 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 |
|
| `subagent_message` | Steer a running subagent or resume a finished one — by `name` either way |
|
||||||
| `subagents_list` | List available agent definitions |
|
| `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")
|
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
|
3. User keeps chatting → main session fully interactive
|
||||||
4. Sub-agent finishes → result steered back as a normal completion/failure
|
4. Sub-agent finishes → result steered back as a normal completion/failure
|
||||||
5. Main agent processes result → continues with new context
|
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
|
## Requirements
|
||||||
|
|
||||||
- [pi](https://github.com/badlogic/pi-mono) — the coding agent
|
- [pi](https://github.com/badlogic/pi-mono) — the coding agent
|
||||||
- One supported multiplexer:
|
- [tmux](https://github.com/tmux/tmux)
|
||||||
- [cmux](https://github.com/manaflow-ai/cmux)
|
|
||||||
- [tmux](https://github.com/tmux/tmux)
|
|
||||||
- [zellij](https://zellij.dev)
|
|
||||||
- [WezTerm](https://wezfurlong.org/wezterm/)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cmux pi
|
|
||||||
# or
|
|
||||||
tmux new -A -s pi 'pi'
|
tmux new -A -s pi 'pi'
|
||||||
# or
|
|
||||||
zellij --session pi # then run: pi
|
|
||||||
# or
|
|
||||||
# just run pi inside WezTerm
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional backend override:
|
> 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.
|
||||||
|
|
||||||
```bash
|
|
||||||
export PI_SUBAGENT_MUX=cmux # or tmux, zellij, wezterm
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Acknowledgements
|
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-interactive-subagents",
|
"name": "pi-interactive-subagents",
|
||||||
"version": "3.7.2",
|
"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": [
|
"keywords": [
|
||||||
"pi-package"
|
"pi-package"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "HazAT",
|
"author": "Amos Blomqvist",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/HazAT/pi-interactive-subagents"
|
"url": "https://github.com/amosblomqvist/pi-interactive-subagents"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,9 @@ import {
|
|||||||
sendLongCommand,
|
sendLongCommand,
|
||||||
pollForExit,
|
pollForExit,
|
||||||
closeSurface,
|
closeSurface,
|
||||||
getMuxBackend,
|
|
||||||
shellEscape,
|
shellEscape,
|
||||||
readScreen,
|
readScreen,
|
||||||
} from "./cmux.ts";
|
} from "./tmux.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
countSessionEntryLines,
|
countSessionEntryLines,
|
||||||
@@ -509,10 +508,10 @@ function muxUnavailableResult() {
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
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);
|
send(running.surface, flattened);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const backend = getMuxBackend() ?? "unknown";
|
|
||||||
return {
|
return {
|
||||||
error:
|
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)}`,
|
`${error?.message ?? String(error)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, boolean>();
|
||||||
|
|
||||||
|
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<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-balance subagent panes so repeated splits don't leave them lopsided.
|
||||||
|
* tmux halves the target pane on every split and dumps freed space onto a
|
||||||
|
* neighbor on close, so without this panes drift to wildly uneven widths.
|
||||||
|
* Applies SUBAGENT_TMUX_LAYOUT to the parent pi window. Debounced so a burst
|
||||||
|
* of parallel spawns or staggered exits collapses into a single layout call,
|
||||||
|
* and non-fatal: a cosmetic resize must never break spawning or watching.
|
||||||
|
*/
|
||||||
|
function rebalanceSurfaces(hintPane?: string): void {
|
||||||
|
// Prefer the parent pi pane (stable; survives a closing subagent pane).
|
||||||
|
const target = process.env.TMUX_PANE ?? hintPane;
|
||||||
|
if (!target) return;
|
||||||
|
if (rebalanceTimer) clearTimeout(rebalanceTimer);
|
||||||
|
rebalanceTimer = setTimeout(() => {
|
||||||
|
rebalanceTimer = null;
|
||||||
|
try {
|
||||||
|
// -t <pane> resolves to that pane's window; does not change focus.
|
||||||
|
execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
} 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<string> {
|
||||||
|
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<PollResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new Error("Aborted while waiting for subagent to finish");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: check for .exit sidecar file (written by the error path)
|
||||||
|
if (options.sessionFile) {
|
||||||
|
try {
|
||||||
|
const exitFile = `${options.sessionFile}.exit`;
|
||||||
|
if (existsSync(exitFile)) {
|
||||||
|
const data = JSON.parse(readFileSync(exitFile, "utf-8"));
|
||||||
|
rmSync(exitFile, { force: true });
|
||||||
|
return interpretExitSidecar(data);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Claude sentinel file (written by plugin Stop hook)
|
||||||
|
if (options.sentinelFile) {
|
||||||
|
try {
|
||||||
|
if (existsSync(options.sentinelFile)) {
|
||||||
|
return { reason: "sentinel", exitCode: 0 };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: read terminal screen for sentinel (crash detection)
|
||||||
|
try {
|
||||||
|
const screen = await readScreenAsync(surface, 5);
|
||||||
|
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
|
||||||
|
if (match) {
|
||||||
|
return { reason: "sentinel", exitCode: parseInt(match[1], 10) };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Surface may have been destroyed — check if .exit file appeared in the meantime
|
||||||
|
if (options.sessionFile) {
|
||||||
|
try {
|
||||||
|
const exitFile = `${options.sessionFile}.exit`;
|
||||||
|
if (existsSync(exitFile)) {
|
||||||
|
const data = JSON.parse(readFileSync(exitFile, "utf-8"));
|
||||||
|
rmSync(exitFile, { force: true });
|
||||||
|
return interpretExitSidecar(data);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.floor((Date.now() - start) / 1000);
|
||||||
|
options.onTick?.(elapsed);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (signal.aborted) return reject(new Error("Aborted"));
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, options.interval);
|
||||||
|
function onAbort() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Aborted"));
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-92
@@ -2,11 +2,11 @@
|
|||||||
* Integration test harness for pi-interactive-subagents.
|
* Integration test harness for pi-interactive-subagents.
|
||||||
*
|
*
|
||||||
* Provides utilities to:
|
* Provides utilities to:
|
||||||
* - Detect available mux backends (cmux, tmux, zellij)
|
* - Detect whether tmux is available
|
||||||
* - Create isolated test environments with test agent definitions
|
* - 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
|
* - 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 { execFileSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import {
|
import {
|
||||||
getMuxBackend,
|
isMuxAvailable,
|
||||||
createSurface,
|
createSurface,
|
||||||
createSurfaceSplit,
|
createSurfaceSplit,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
@@ -31,14 +31,10 @@ import {
|
|||||||
readScreen,
|
readScreen,
|
||||||
readScreenAsync,
|
readScreenAsync,
|
||||||
closeSurface,
|
closeSurface,
|
||||||
sendEscape,
|
|
||||||
shellEscape,
|
shellEscape,
|
||||||
parseCmuxFocusedSnapshotFromJson,
|
} from "../../pi-extension/subagents/tmux.ts";
|
||||||
parseCmuxPaneRefForSurfaceFromJson,
|
|
||||||
type MuxBackend,
|
|
||||||
} from "../../pi-extension/subagents/cmux.ts";
|
|
||||||
|
|
||||||
// Re-export mux primitives for tests
|
// Re-export tmux primitives for tests
|
||||||
export {
|
export {
|
||||||
createSurface,
|
createSurface,
|
||||||
createSurfaceSplit,
|
createSurfaceSplit,
|
||||||
@@ -47,10 +43,8 @@ export {
|
|||||||
readScreen,
|
readScreen,
|
||||||
readScreenAsync,
|
readScreenAsync,
|
||||||
closeSurface,
|
closeSurface,
|
||||||
sendEscape,
|
|
||||||
shellEscape,
|
shellEscape,
|
||||||
};
|
};
|
||||||
export type { MuxBackend };
|
|
||||||
|
|
||||||
// ── Paths ──
|
// ── Paths ──
|
||||||
|
|
||||||
@@ -82,99 +76,42 @@ export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
|
|||||||
// ── Backend detection ──
|
// ── Backend detection ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect which mux backends are actually available in the current environment.
|
* Detect whether tmux is available in the current environment.
|
||||||
* Temporarily sets PI_SUBAGENT_MUX to probe each backend.
|
* Returns ["tmux"] or [].
|
||||||
*/
|
*/
|
||||||
export function getAvailableBackends(): MuxBackend[] {
|
export function getAvailableBackends(): string[] {
|
||||||
const backends: MuxBackend[] = [];
|
return isMuxAvailable() ? ["tmux"] : [];
|
||||||
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 setBackend(backend: MuxBackend): string | undefined {
|
export function focusSurface(surface: string): void {
|
||||||
const prev = process.env.PI_SUBAGENT_MUX;
|
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
|
||||||
process.env.PI_SUBAGENT_MUX = backend;
|
|
||||||
return prev;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreBackend(prev: string | undefined): void {
|
export function getFocusedSurface(): string | null {
|
||||||
if (prev === undefined) delete process.env.PI_SUBAGENT_MUX;
|
try {
|
||||||
else process.env.PI_SUBAGENT_MUX = prev;
|
const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], {
|
||||||
}
|
encoding: "utf8",
|
||||||
|
});
|
||||||
export function focusSurface(backend: MuxBackend, surface: string): void {
|
const activeLine = panes.split("\n").find((line) => line.endsWith(" 1"));
|
||||||
if (backend === "cmux") {
|
return activeLine?.split(" ")[0] ?? null;
|
||||||
const pane = getSurfacePane(backend, surface);
|
} catch {
|
||||||
if (pane) execFileSync("cmux", ["focus-pane", "--pane", pane], { encoding: "utf8" });
|
return null;
|
||||||
execFileSync("cmux", ["focus-panel", "--panel", surface], { encoding: "utf8" });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
export async function waitForFocusedSurface(
|
||||||
backend: MuxBackend,
|
|
||||||
surface: string,
|
surface: string,
|
||||||
timeout: number = PI_TIMEOUT,
|
timeout: number = PI_TIMEOUT,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeout) {
|
while (Date.now() - start < timeout) {
|
||||||
if (getFocusedSurface(backend) === surface) return;
|
if (getFocusedSurface() === surface) return;
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Timeout (${timeout}ms) waiting for focused ${backend} surface ${surface}; ` +
|
`Timeout (${timeout}ms) waiting for focused tmux pane ${surface}; ` +
|
||||||
`current focus is ${getFocusedSurface(backend) ?? "unknown"}`,
|
`current focus is ${getFocusedSurface() ?? "unknown"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,9 +120,7 @@ export async function waitForFocusedSurface(
|
|||||||
export interface TestEnv {
|
export interface TestEnv {
|
||||||
/** Temp directory serving as the test project root */
|
/** Temp directory serving as the test project root */
|
||||||
dir: string;
|
dir: string;
|
||||||
/** Active mux backend for this test run */
|
/** Panes created during the test (cleaned up automatically) */
|
||||||
backend: MuxBackend;
|
|
||||||
/** Surfaces created during the test (cleaned up automatically) */
|
|
||||||
surfaces: string[];
|
surfaces: string[];
|
||||||
/** Temp files to clean up */
|
/** Temp files to clean up */
|
||||||
tempFiles: string[];
|
tempFiles: string[];
|
||||||
@@ -195,7 +130,7 @@ export interface TestEnv {
|
|||||||
* Create an isolated test environment with test agent definitions.
|
* Create an isolated test environment with test agent definitions.
|
||||||
* The temp dir has `.pi/agents/` containing copies of all test agents.
|
* 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 dir = mkdtempSync(join(tmpdir(), "pi-integ-"));
|
||||||
const agentsDir = join(dir, ".pi", "agents");
|
const agentsDir = join(dir, ".pi", "agents");
|
||||||
mkdirSync(agentsDir, { recursive: true });
|
mkdirSync(agentsDir, { recursive: true });
|
||||||
@@ -209,7 +144,7 @@ export function createTestEnv(backend: MuxBackend): TestEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dir, backend, surfaces: [], tempFiles: [] };
|
return { dir, surfaces: [], tempFiles: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
* Integration tests for the full subagent lifecycle.
|
* Integration tests for the full subagent lifecycle.
|
||||||
*
|
*
|
||||||
* These tests spawn REAL pi sessions with REAL LLM calls (haiku by default).
|
* 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.
|
* tool, and verifies the outcome via marker files and screen output.
|
||||||
*
|
*
|
||||||
* Costs: ~$0.01-0.05 per test run (haiku).
|
* Costs: ~$0.01-0.05 per test run (haiku).
|
||||||
* Duration: ~30-90s per test.
|
* Duration: ~30-90s per test.
|
||||||
*
|
*
|
||||||
* Run inside a supported multiplexer:
|
* Run inside tmux:
|
||||||
* cmux bash -c 'npm run test:integration'
|
|
||||||
* tmux new 'npm run test:integration'
|
* tmux new 'npm run test:integration'
|
||||||
*
|
*
|
||||||
* Configuration:
|
* Configuration:
|
||||||
@@ -21,8 +20,6 @@ import assert from "node:assert/strict";
|
|||||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||||
import {
|
import {
|
||||||
getAvailableBackends,
|
getAvailableBackends,
|
||||||
setBackend,
|
|
||||||
restoreBackend,
|
|
||||||
createTestEnv,
|
createTestEnv,
|
||||||
cleanupTestEnv,
|
cleanupTestEnv,
|
||||||
createTrackedSurface,
|
createTrackedSurface,
|
||||||
@@ -40,23 +37,20 @@ import {
|
|||||||
const backends = getAvailableBackends();
|
const backends = getAvailableBackends();
|
||||||
|
|
||||||
if (backends.length === 0) {
|
if (backends.length === 0) {
|
||||||
console.log("⚠️ No mux backend available — skipping subagent lifecycle integration tests");
|
console.log("⚠️ tmux is not available — skipping subagent lifecycle integration tests");
|
||||||
console.log(" Run inside cmux or tmux to enable these tests.");
|
console.log(" Run inside tmux to enable these tests.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const backend of backends) {
|
for (const backend of backends) {
|
||||||
describe(`subagent-lifecycle [${backend}]`, { timeout: PI_TIMEOUT * 3 }, () => {
|
describe(`subagent-lifecycle [${backend}]`, { timeout: PI_TIMEOUT * 3 }, () => {
|
||||||
let prevMux: string | undefined;
|
|
||||||
let env: TestEnv;
|
let env: TestEnv;
|
||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
prevMux = setBackend(backend);
|
env = createTestEnv();
|
||||||
env = createTestEnv(backend);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
cleanupTestEnv(env);
|
cleanupTestEnv(env);
|
||||||
restoreBackend(prevMux);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Basic spawn + completion ──
|
// ── Basic spawn + completion ──
|
||||||
|
|||||||
@@ -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,
|
* These tests exercise real tmux operations: creating panes,
|
||||||
* sending commands, reading screen output, and closing surfaces.
|
* sending commands, reading screen output, and closing panes.
|
||||||
* No LLM calls — fast and free.
|
* No LLM calls — fast and free.
|
||||||
*
|
*
|
||||||
* Run inside a supported multiplexer:
|
* Run inside tmux:
|
||||||
* cmux bash -c 'npm run test:integration'
|
|
||||||
* tmux new 'npm run test:integration'
|
* tmux new 'npm run test:integration'
|
||||||
* zellij --session pi # then run: npm run test:integration
|
|
||||||
*/
|
*/
|
||||||
import { describe, it, before, after } from "node:test";
|
import { describe, it, before, after } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { unlinkSync } from "node:fs";
|
import { unlinkSync } from "node:fs";
|
||||||
import {
|
import {
|
||||||
getAvailableBackends,
|
getAvailableBackends,
|
||||||
setBackend,
|
|
||||||
restoreBackend,
|
|
||||||
createTestEnv,
|
createTestEnv,
|
||||||
cleanupTestEnv,
|
cleanupTestEnv,
|
||||||
createTrackedSurface,
|
createTrackedSurface,
|
||||||
createTrackedSurfaceSplit,
|
createTrackedSurfaceSplit,
|
||||||
focusSurface,
|
focusSurface,
|
||||||
getFocusedSurface,
|
getFocusedSurface,
|
||||||
getSurfacePane,
|
|
||||||
waitForFocusedSurface,
|
waitForFocusedSurface,
|
||||||
untrackSurface,
|
untrackSurface,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
@@ -31,7 +26,6 @@ import {
|
|||||||
readScreen,
|
readScreen,
|
||||||
readScreenAsync,
|
readScreenAsync,
|
||||||
closeSurface,
|
closeSurface,
|
||||||
sendEscape,
|
|
||||||
sleep,
|
sleep,
|
||||||
uniqueId,
|
uniqueId,
|
||||||
trackTempFile,
|
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");
|
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
|
||||||
|
|
||||||
if (backends.length === 0) {
|
if (backends.length === 0) {
|
||||||
console.log("⚠️ No mux backend available — skipping mux-surface integration tests");
|
console.log("⚠️ tmux is not available — skipping tmux-surface integration tests");
|
||||||
console.log(" Run inside cmux or tmux to enable these tests.");
|
console.log(" Run inside tmux to enable these tests.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const backend of backends) {
|
for (const backend of backends) {
|
||||||
describe(`mux-surface [${backend}]`, { timeout: 60_000 }, () => {
|
describe(`tmux-surface [${backend}]`, { timeout: 60_000 }, () => {
|
||||||
let prevMux: string | undefined;
|
|
||||||
let env: TestEnv;
|
let env: TestEnv;
|
||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
prevMux = setBackend(backend);
|
env = createTestEnv();
|
||||||
env = createTestEnv(backend);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
cleanupTestEnv(env);
|
cleanupTestEnv(env);
|
||||||
restoreBackend(prevMux);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => {
|
it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => {
|
||||||
const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right");
|
const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right");
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|
||||||
focusSurface(backend, anchor);
|
focusSurface(anchor);
|
||||||
await waitForFocusedSurface(backend, anchor, 10_000);
|
await waitForFocusedSurface(anchor, 10_000);
|
||||||
|
|
||||||
const childA = createTrackedSurface(env, "focus-child-a");
|
const childA = createTrackedSurface(env, "focus-child-a");
|
||||||
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
||||||
assert.equal(getFocusedSurface(backend), anchor);
|
assert.equal(getFocusedSurface(), anchor);
|
||||||
|
|
||||||
const childB = createTrackedSurface(env, "focus-child-b");
|
const childB = createTrackedSurface(env, "focus-child-b");
|
||||||
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
||||||
assert.equal(getFocusedSurface(backend), anchor);
|
assert.equal(getFocusedSurface(), 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const markerA = uniqueId();
|
const markerA = uniqueId();
|
||||||
const markerB = uniqueId();
|
const markerB = uniqueId();
|
||||||
@@ -95,7 +78,7 @@ for (const backend of backends) {
|
|||||||
waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50),
|
waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50),
|
||||||
waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 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 () => {
|
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);
|
await sleep(1000);
|
||||||
|
|
||||||
const marker = uniqueId();
|
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}"`);
|
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
|
||||||
|
|
||||||
@@ -210,34 +193,5 @@ for (const backend of backends) {
|
|||||||
unlinkSync(filePath);
|
unlinkSync(filePath);
|
||||||
} catch {}
|
} 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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+4
-284
@@ -30,20 +30,7 @@ import {
|
|||||||
summarizeSessionStats,
|
summarizeSessionStats,
|
||||||
} from "../pi-extension/subagents/session.ts";
|
} from "../pi-extension/subagents/session.ts";
|
||||||
|
|
||||||
import {
|
import { shellEscape } from "../pi-extension/subagents/tmux.ts";
|
||||||
shellEscape,
|
|
||||||
isCmuxAvailable,
|
|
||||||
isWezTermAvailable,
|
|
||||||
parseCmuxFocusedSnapshot,
|
|
||||||
parseCmuxFocusedSnapshotFromJson,
|
|
||||||
parseCmuxJson,
|
|
||||||
parseCmuxPaneRefForSurface,
|
|
||||||
parseCmuxPaneRefForSurfaceFromJson,
|
|
||||||
canSplitZellijPane,
|
|
||||||
predictZellijSplitDirection,
|
|
||||||
selectZellijPlacement,
|
|
||||||
selectZellijStackPlacement,
|
|
||||||
} from "../pi-extension/subagents/cmux.ts";
|
|
||||||
import {
|
import {
|
||||||
advanceStatusState,
|
advanceStatusState,
|
||||||
capStatusLines,
|
capStatusLines,
|
||||||
@@ -69,7 +56,7 @@ import {
|
|||||||
runningChildrenCount,
|
runningChildrenCount,
|
||||||
} from "../pi-extension/subagents/subagent-done.ts";
|
} from "../pi-extension/subagents/subagent-done.ts";
|
||||||
import subagentDoneExtension 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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -1754,7 +1741,7 @@ describe("subagent-done.ts", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cmux.ts interpretExitSidecar", () => {
|
describe("tmux.ts interpretExitSidecar", () => {
|
||||||
const { interpretExitSidecar } = __pollForExitTest__;
|
const { interpretExitSidecar } = __pollForExitTest__;
|
||||||
|
|
||||||
it("no longer decodes ping payloads (ask_question keeps the session open instead)", () => {
|
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", () => {
|
describe("shellEscape", () => {
|
||||||
it("wraps in single quotes", () => {
|
it("wraps in single quotes", () => {
|
||||||
assert.equal(shellEscape("hello"), "'hello'");
|
assert.equal(shellEscape("hello"), "'hello'");
|
||||||
@@ -2688,271 +2675,4 @@ describe("cmux.ts", () => {
|
|||||||
assert.ok(escaped.includes("$world"));
|
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user