Adding support for herdr - agents in new tabs
This commit is contained in:
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
config.json
|
config.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.memory/
|
.memory/
|
||||||
|
.pi/
|
||||||
|
.worktrees/
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
# pi-interactive-subagents
|
# pi-interactive-subagents
|
||||||
|
|
||||||
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking.
|
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking.
|
||||||
|
|
||||||
**tmux-only fork.** See [Acknowledgements](#acknowledgements) for the upstream project, which also supports cmux, zellij, and WezTerm.
|
The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), otherwise tmux is used when available.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
`subagent()` returns immediately. The sub-agent runs in its own tmux pane — a right split off the parent pi pane, so pane creation never steals keyboard focus. A live widget above the input tracks every running sub-agent, and when one finishes, its result is steered into the main session as a notification that triggers a new turn.
|
`subagent()` returns immediately. In Herdr, the sub-agent runs in a new unfocused tab in the current workspace. In tmux, it runs in a right split off the parent Pi pane. A live widget above the input tracks every running sub-agent, and when one finishes, its result is steered into the main session as a notification that triggers a new turn.
|
||||||
|
|
||||||
```
|
```
|
||||||
╭─ Subagents ──────────────────────────── 2 running ─╮
|
╭─ Subagents ──────────────────────────── 2 running ─╮
|
||||||
@@ -17,19 +17,19 @@ Async subagents for [pi](https://github.com/badlogic/pi-mono), running in tmux p
|
|||||||
|
|
||||||
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
||||||
|
|
||||||
Panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …).
|
tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr subagents use separate tabs, so they do not resize the parent tab.
|
||||||
|
|
||||||
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
|
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # default: 500
|
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, tmux 500
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `subagent` | Spawn a sub-agent in a dedicated tmux pane (async) |
|
| `subagent` | Spawn a sub-agent in a dedicated terminal surface (async) |
|
||||||
| `subagent_message` | Message a sub-agent by name — steers it if running, resumes its session if finished |
|
| `subagent_message` | Message a sub-agent by name — steers it if running, resumes its session if finished |
|
||||||
| `subagents_list` | List available agent definitions |
|
| `subagents_list` | List available agent definitions |
|
||||||
| `ask_question` | *(sub-agent sessions only)* Ask the orchestrator a question and wait for the reply |
|
| `ask_question` | *(sub-agent sessions only)* Ask the orchestrator a question and wait for the reply |
|
||||||
@@ -76,9 +76,9 @@ If the reply arrives while the sub-agent is still mid-turn, it is absorbed into
|
|||||||
|
|
||||||
| Agent | Model | Tools | Role |
|
| Agent | Model | Tools | Role |
|
||||||
| ----- | ----- | ----- | ---- |
|
| ----- | ----- | ----- | ---- |
|
||||||
| **scout** | `openrouter/z-ai/glm-5.3` | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon |
|
| **scout** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon |
|
||||||
| **researcher** | `openrouter/z-ai/glm-5.3` | `web_search`, `web_fetch`, `safe_bash` | Web research, synthesized into a sourced brief |
|
| **researcher** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `web_search`, `web_fetch`, `safe_bash` | Web research, synthesized into a sourced brief |
|
||||||
| **worker** | `openrouter/z-ai/glm-5.3` | `read`, `write`, `edit`, `bash`, `web_search`, `web_fetch` + spawning | General implementer; may spawn `scout` and `researcher` |
|
| **worker** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `write`, `edit`, `bash`, `web_search`, `web_fetch` + spawning | General implementer; may spawn `scout` and `researcher` |
|
||||||
|
|
||||||
All three are autonomous (`auto-exit: true`) and carry their identity in the system prompt (`system-prompt: append`).
|
All three are autonomous (`auto-exit: true`) and carry their identity in the system prompt (`system-prompt: append`).
|
||||||
|
|
||||||
@@ -90,8 +90,8 @@ Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global).
|
|||||||
---
|
---
|
||||||
name: my-agent
|
name: my-agent
|
||||||
description: Does something specific
|
description: Does something specific
|
||||||
model: openrouter/z-ai/glm-5.3
|
model: openai-codex/gpt-5.6-luna
|
||||||
thinking: medium
|
thinking: xhigh
|
||||||
tools: read, edit, write, safe_bash, web_search
|
tools: read, edit, write, safe_bash, web_search
|
||||||
session-mode: lineage-only
|
session-mode: lineage-only
|
||||||
auto-exit: true
|
auto-exit: true
|
||||||
@@ -178,7 +178,13 @@ Status display is configured via `config.json` in the extension directory (copy
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- [pi](https://github.com/badlogic/pi-mono)
|
- [pi](https://github.com/badlogic/pi-mono)
|
||||||
- [tmux](https://github.com/tmux/tmux)
|
- One supported terminal manager:
|
||||||
|
- [Herdr](https://herdr.dev), with Pi launched inside Herdr
|
||||||
|
- [tmux](https://github.com/tmux/tmux)
|
||||||
|
|
||||||
|
Herdr requires no extension configuration once its Pi integration is installed. The extension detects `HERDR_ENV=1` and creates subagents as unfocused tabs in the current workspace.
|
||||||
|
|
||||||
|
For tmux:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tmux new -A -s pi 'pi'
|
tmux new -A -s pi 'pi'
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
name: researcher
|
name: researcher
|
||||||
description: Web researcher — searches the web and synthesizes findings
|
description: Web researcher — searches the web and synthesizes findings
|
||||||
tools: web_search, web_fetch, safe_bash
|
tools: web_search, web_fetch, safe_bash
|
||||||
model: openrouter/z-ai/glm-5.3
|
model: openai-codex/gpt-5.6-luna
|
||||||
thinking: medium
|
thinking: xhigh
|
||||||
system-prompt: append
|
system-prompt: append
|
||||||
auto-exit: true
|
auto-exit: true
|
||||||
---
|
---
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
name: scout
|
name: scout
|
||||||
description: Fast codebase recon — explores files, finds patterns, maps architecture
|
description: Fast codebase recon — explores files, finds patterns, maps architecture
|
||||||
tools: read, grep, find, ls
|
tools: read, grep, find, ls
|
||||||
model: openrouter/z-ai/glm-5.3
|
model: openai-codex/gpt-5.6-luna
|
||||||
thinking: low
|
thinking: xhigh
|
||||||
system-prompt: append
|
system-prompt: append
|
||||||
auto-exit: true
|
auto-exit: true
|
||||||
---
|
---
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@ name: worker
|
|||||||
description: General-purpose worker — reads, writes, and edits code
|
description: General-purpose worker — reads, writes, and edits code
|
||||||
tools: read, write, edit, bash, web_search, web_fetch
|
tools: read, write, edit, bash, web_search, web_fetch
|
||||||
subagent_agents: scout, researcher
|
subagent_agents: scout, researcher
|
||||||
model: openrouter/z-ai/glm-5.3
|
model: openai-codex/gpt-5.6-luna
|
||||||
thinking: high
|
thinking: xhigh
|
||||||
system-prompt: append
|
system-prompt: append
|
||||||
auto-exit: true
|
auto-exit: true
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"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 tmux",
|
"description": "Interactive subagents for pi - spawn, orchestrate, and manage sub-agent sessions in Herdr or tmux",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi-package"
|
"pi-package"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/** Herdr surface backend. Subagents run in unfocused tabs in the current workspace. */
|
||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const commandAvailability = new Map<string, boolean>();
|
||||||
|
const tabByPane = new Map<string, string>();
|
||||||
|
|
||||||
|
interface HerdrTabCreated {
|
||||||
|
result?: {
|
||||||
|
root_pane?: { pane_id?: unknown };
|
||||||
|
tab?: { tab_id?: unknown };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HerdrPaneCreated {
|
||||||
|
result?: {
|
||||||
|
pane?: { pane_id?: unknown };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCommand(command: string): boolean {
|
||||||
|
const cached = commandAvailability.get(command);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
let available = false;
|
||||||
|
try {
|
||||||
|
execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
|
||||||
|
available = true;
|
||||||
|
} catch {
|
||||||
|
available = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
commandAvailability.set(command, available);
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAvailable(): boolean {
|
||||||
|
return (
|
||||||
|
process.env.HERDR_ENV === "1" &&
|
||||||
|
!!process.env.HERDR_WORKSPACE_ID &&
|
||||||
|
!!process.env.HERDR_PANE_ID &&
|
||||||
|
hasCommand("herdr")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupHint(): string {
|
||||||
|
return "Start pi inside a Herdr-managed pane with the `herdr` CLI on PATH.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireHerdr(): void {
|
||||||
|
if (!isAvailable()) {
|
||||||
|
throw new Error(`Herdr is required for this surface. ${setupHint()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson<T>(command: string, output: string): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(output) as T;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Unexpected Herdr ${command} output: ${output.trim() || "(empty)"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireId(value: unknown, command: string, field: string): string {
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw new Error(`Herdr ${command} did not return ${field}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreatedTab(output: string): { paneId: string; tabId: string } {
|
||||||
|
const response = parseJson<HerdrTabCreated>("tab create", output);
|
||||||
|
return {
|
||||||
|
paneId: requireId(response.result?.root_pane?.pane_id, "tab create", "result.root_pane.pane_id"),
|
||||||
|
tabId: requireId(response.result?.tab?.tab_id, "tab create", "result.tab.tab_id"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreatedPane(output: string): string {
|
||||||
|
const response = parseJson<HerdrPaneCreated>("pane split", output);
|
||||||
|
return requireId(response.result?.pane?.pane_id, "pane split", "result.pane.pane_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurface(name: string): string {
|
||||||
|
requireHerdr();
|
||||||
|
const workspace = process.env.HERDR_WORKSPACE_ID!;
|
||||||
|
const output = execFileSync(
|
||||||
|
"herdr",
|
||||||
|
[
|
||||||
|
"tab",
|
||||||
|
"create",
|
||||||
|
"--workspace",
|
||||||
|
workspace,
|
||||||
|
"--cwd",
|
||||||
|
process.cwd(),
|
||||||
|
"--label",
|
||||||
|
name,
|
||||||
|
"--no-focus",
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
const { paneId, tabId } = parseCreatedTab(output);
|
||||||
|
tabByPane.set(paneId, tabId);
|
||||||
|
return paneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurfaceSplit(
|
||||||
|
name: string,
|
||||||
|
direction: "left" | "right" | "up" | "down",
|
||||||
|
fromSurface?: string,
|
||||||
|
): string {
|
||||||
|
requireHerdr();
|
||||||
|
if (direction === "left" || direction === "up") {
|
||||||
|
throw new Error(`Herdr pane splits only support right or down, not ${direction}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = fromSurface ?? process.env.HERDR_PANE_ID!;
|
||||||
|
const output = execFileSync(
|
||||||
|
"herdr",
|
||||||
|
[
|
||||||
|
"pane",
|
||||||
|
"split",
|
||||||
|
"--pane",
|
||||||
|
source,
|
||||||
|
"--direction",
|
||||||
|
direction,
|
||||||
|
"--cwd",
|
||||||
|
process.cwd(),
|
||||||
|
"--no-focus",
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
const paneId = parseCreatedPane(output);
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync("herdr", ["pane", "rename", paneId, name], { encoding: "utf8" });
|
||||||
|
} catch {
|
||||||
|
// A cosmetic label must not break a successful split.
|
||||||
|
}
|
||||||
|
|
||||||
|
return paneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendCommand(surface: string, command: string): void {
|
||||||
|
requireHerdr();
|
||||||
|
execFileSync("herdr", ["pane", "run", surface, command], { encoding: "utf8" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readScreen(surface: string, lines = 50): string {
|
||||||
|
requireHerdr();
|
||||||
|
return execFileSync(
|
||||||
|
"herdr",
|
||||||
|
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||||
|
requireHerdr();
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
"herdr",
|
||||||
|
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSurface(surface: string): void {
|
||||||
|
requireHerdr();
|
||||||
|
const tabId = tabByPane.get(surface);
|
||||||
|
if (tabId) {
|
||||||
|
execFileSync("herdr", ["tab", "close", tabId], { encoding: "utf8" });
|
||||||
|
tabByPane.delete(surface);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
execFileSync("herdr", ["pane", "close", surface], { encoding: "utf8" });
|
||||||
|
}
|
||||||
@@ -24,7 +24,9 @@ import {
|
|||||||
closeSurface,
|
closeSurface,
|
||||||
shellEscape,
|
shellEscape,
|
||||||
readScreen,
|
readScreen,
|
||||||
} from "./tmux.ts";
|
getMuxBackend,
|
||||||
|
wrapWithCompletionFile,
|
||||||
|
} from "./surface.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
countSessionEntryLines,
|
countSessionEntryLines,
|
||||||
@@ -497,10 +499,15 @@ function widgetIcon(kind: StatusSnapshot["kind"]): string {
|
|||||||
* (for example direnv/devenv), so the delay is configurable for users who hit
|
* (for example direnv/devenv), so the delay is configurable for users who hit
|
||||||
* dropped commands. Keep the historical default at 500ms.
|
* dropped commands. Keep the historical default at 500ms.
|
||||||
*/
|
*/
|
||||||
|
function defaultShellReadyDelayMs(backend: ReturnType<typeof getMuxBackend>): number {
|
||||||
|
return backend === "herdr" ? 1000 : 500;
|
||||||
|
}
|
||||||
|
|
||||||
function getShellReadyDelayMs(): number {
|
function getShellReadyDelayMs(): number {
|
||||||
const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
|
const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
|
||||||
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
||||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||||
|
return defaultShellReadyDelayMs(getMuxBackend());
|
||||||
}
|
}
|
||||||
|
|
||||||
function muxUnavailableResult() {
|
function muxUnavailableResult() {
|
||||||
@@ -508,10 +515,10 @@ function muxUnavailableResult() {
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: `Subagents require tmux. ${muxSetupHint()}`,
|
text: `Subagents require a supported terminal multiplexer. ${muxSetupHint()}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: { error: "tmux not available" },
|
details: { error: "terminal multiplexer not available" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +625,7 @@ interface RunningSubagent {
|
|||||||
abortController?: AbortController;
|
abortController?: AbortController;
|
||||||
cli?: string;
|
cli?: string;
|
||||||
sentinelFile?: string;
|
sentinelFile?: string;
|
||||||
|
completionFile?: string;
|
||||||
statusState: SubagentStatusState;
|
statusState: SubagentStatusState;
|
||||||
/**
|
/**
|
||||||
* When true, status transitions (stalled/recovered) do not wake the parent
|
* When true, status transitions (stalled/recovered) do not wake the parent
|
||||||
@@ -1008,7 +1016,7 @@ function steerSubagent(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
return {
|
||||||
error:
|
error:
|
||||||
`Failed to deliver message to subagent "${running.name}" via tmux: ` +
|
`Failed to deliver message to subagent "${running.name}" via the terminal surface: ` +
|
||||||
`${error?.message ?? String(error)}`,
|
`${error?.message ?? String(error)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1121,6 +1129,7 @@ function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolea
|
|||||||
|
|
||||||
export const __test__ = {
|
export const __test__ = {
|
||||||
borderLine,
|
borderLine,
|
||||||
|
defaultShellReadyDelayMs,
|
||||||
getShellReadyDelayMs,
|
getShellReadyDelayMs,
|
||||||
renderSubagentWidgetLines,
|
renderSubagentWidgetLines,
|
||||||
loadAgentDefaults,
|
loadAgentDefaults,
|
||||||
@@ -1141,6 +1150,7 @@ export const __test__ = {
|
|||||||
handleSubagentSteer,
|
handleSubagentSteer,
|
||||||
resolveResultPresentation,
|
resolveResultPresentation,
|
||||||
resolveResumeLaunchBehavior,
|
resolveResumeLaunchBehavior,
|
||||||
|
wrapWithCompletionFile,
|
||||||
runningSubagents,
|
runningSubagents,
|
||||||
formatElapsed,
|
formatElapsed,
|
||||||
formatTokens,
|
formatTokens,
|
||||||
@@ -1160,7 +1170,7 @@ function startWidgetRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch a subagent: creates the multiplexer pane, builds the command, and
|
* Launch a subagent: creates a terminal surface, builds the command, and
|
||||||
* sends it. Returns a RunningSubagent — does NOT poll.
|
* sends it. Returns a RunningSubagent — does NOT poll.
|
||||||
*
|
*
|
||||||
* Call watchSubagent() on the returned object to observe completion.
|
* Call watchSubagent() on the returned object to observe completion.
|
||||||
@@ -1184,6 +1194,8 @@ async function launchSubagent(
|
|||||||
if (!sessionFile) throw new Error("No session file");
|
if (!sessionFile) throw new Error("No session file");
|
||||||
const sessionId = ctx.sessionManager.getSessionId();
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
|
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
|
||||||
|
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
|
||||||
|
mkdirSync(dirname(completionFile), { recursive: true });
|
||||||
|
|
||||||
const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs);
|
const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs);
|
||||||
const targetCwdForSession = effectiveCwd ?? ctx.cwd;
|
const targetCwdForSession = effectiveCwd ?? ctx.cwd;
|
||||||
@@ -1201,14 +1213,15 @@ async function launchSubagent(
|
|||||||
].join("-");
|
].join("-");
|
||||||
const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
|
const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
|
||||||
|
|
||||||
// Use pre-created surface (parallel mode) or create a new one.
|
// Use a pre-created surface or create a new one. New shells need a brief
|
||||||
// For new surfaces, pause briefly so the shell is ready before sending the command.
|
// startup window before terminal input is reliable.
|
||||||
const surfacePreCreated = !!options?.surface;
|
const surfacePreCreated = !!options?.surface;
|
||||||
const surface = options?.surface ?? createSurface(params.name);
|
const surface = options?.surface ?? createSurface(params.name);
|
||||||
if (!surfacePreCreated) {
|
if (!surfacePreCreated) {
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const launchBehavior = resolveLaunchBehavior(params, agentDefs);
|
const launchBehavior = resolveLaunchBehavior(params, agentDefs);
|
||||||
|
|
||||||
if (launchBehavior.seededSessionMode) {
|
if (launchBehavior.seededSessionMode) {
|
||||||
@@ -1271,7 +1284,7 @@ async function launchSubagent(
|
|||||||
cmdParts.push(shellEscape(params.task));
|
cmdParts.push(shellEscape(params.task));
|
||||||
|
|
||||||
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
|
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
|
||||||
const command = `${cdPrefix}${cmdParts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
|
const command = wrapWithCompletionFile(`${cdPrefix}${cmdParts.join(" ")}`, completionFile);
|
||||||
|
|
||||||
const launchScriptName = `${(params.name || "subagent")
|
const launchScriptName = `${(params.name || "subagent")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -1301,6 +1314,7 @@ async function launchSubagent(
|
|||||||
launchScriptFile,
|
launchScriptFile,
|
||||||
cli: "claude",
|
cli: "claude",
|
||||||
sentinelFile,
|
sentinelFile,
|
||||||
|
completionFile,
|
||||||
interactive: effectiveInteractive,
|
interactive: effectiveInteractive,
|
||||||
statusState: createStatusState({
|
statusState: createStatusState({
|
||||||
source: "claude",
|
source: "claude",
|
||||||
@@ -1414,7 +1428,7 @@ async function launchSubagent(
|
|||||||
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
|
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
|
||||||
|
|
||||||
const piCommand = cdPrefix + envPrefix + parts.join(" ");
|
const piCommand = cdPrefix + envPrefix + parts.join(" ");
|
||||||
const command = `${piCommand}; echo '__SUBAGENT_DONE_'$?'__'`;
|
const command = wrapWithCompletionFile(piCommand, completionFile);
|
||||||
const launchScriptName = `${(params.name || "subagent")
|
const launchScriptName = `${(params.name || "subagent")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\s-]/g, "")
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
@@ -1442,6 +1456,7 @@ async function launchSubagent(
|
|||||||
sessionFile: subagentSessionFile,
|
sessionFile: subagentSessionFile,
|
||||||
launchScriptFile,
|
launchScriptFile,
|
||||||
activityFile,
|
activityFile,
|
||||||
|
completionFile,
|
||||||
interactive: effectiveInteractive,
|
interactive: effectiveInteractive,
|
||||||
statusState: createStatusState({
|
statusState: createStatusState({
|
||||||
source: "pi",
|
source: "pi",
|
||||||
@@ -1451,6 +1466,17 @@ async function launchSubagent(
|
|||||||
|
|
||||||
runningSubagents.set(id, running);
|
runningSubagents.set(id, running);
|
||||||
return running;
|
return running;
|
||||||
|
} catch (error) {
|
||||||
|
runningSubagents.delete(id);
|
||||||
|
if (!surfacePreCreated) {
|
||||||
|
try {
|
||||||
|
closeSurface(surface);
|
||||||
|
} catch {
|
||||||
|
// Preserve the launch error; cleanup is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1532,6 +1558,7 @@ async function watchSubagent(
|
|||||||
interval: 1000,
|
interval: 1000,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
sentinelFile: running.sentinelFile,
|
sentinelFile: running.sentinelFile,
|
||||||
|
completionFile: running.completionFile,
|
||||||
onTick() {
|
onTick() {
|
||||||
observeRunningSubagent(running);
|
observeRunningSubagent(running);
|
||||||
deliverPendingQuestion(running);
|
deliverPendingQuestion(running);
|
||||||
@@ -2152,6 +2179,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
const surface = createSurface(name);
|
const surface = createSurface(name);
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
||||||
|
|
||||||
|
try {
|
||||||
// Build pi resume command
|
// Build pi resume command
|
||||||
const parts = ["pi", "--session", shellEscape(sessionPath)];
|
const parts = ["pi", "--session", shellEscape(sessionPath)];
|
||||||
|
|
||||||
@@ -2162,7 +2190,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
const sessionId = ctx.sessionManager.getSessionId();
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
|
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
|
||||||
const activityFile = getSubagentActivityFile(artifactDir, id);
|
const activityFile = getSubagentActivityFile(artifactDir, id);
|
||||||
|
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
|
||||||
mkdirSync(dirname(activityFile), { recursive: true });
|
mkdirSync(dirname(activityFile), { recursive: true });
|
||||||
|
mkdirSync(dirname(completionFile), { recursive: true });
|
||||||
|
|
||||||
// Replay the model, identity, and default-deny tool/extension sandbox.
|
// Replay the model, identity, and default-deny tool/extension sandbox.
|
||||||
applySandboxToParts(parts, loadout, { artifactDir, name });
|
applySandboxToParts(parts, loadout, { artifactDir, name });
|
||||||
@@ -2212,7 +2242,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
// operate where they did before.
|
// operate where they did before.
|
||||||
const resumeCdPrefix = loadout.cwd ? `cd ${shellEscape(loadout.cwd)} && ` : "";
|
const resumeCdPrefix = loadout.cwd ? `cd ${shellEscape(loadout.cwd)} && ` : "";
|
||||||
|
|
||||||
const command = `${resumeCdPrefix}${resumeEnvPrefix}${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
|
const command = wrapWithCompletionFile(
|
||||||
|
`${resumeCdPrefix}${resumeEnvPrefix}${parts.join(" ")}`,
|
||||||
|
completionFile,
|
||||||
|
);
|
||||||
const launchScriptFile = join(
|
const launchScriptFile = join(
|
||||||
artifactDir,
|
artifactDir,
|
||||||
"subagent-scripts",
|
"subagent-scripts",
|
||||||
@@ -2244,6 +2277,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
sessionFile: sessionPath,
|
sessionFile: sessionPath,
|
||||||
launchScriptFile,
|
launchScriptFile,
|
||||||
activityFile,
|
activityFile,
|
||||||
|
completionFile,
|
||||||
interactive,
|
interactive,
|
||||||
statusState: createStatusState({
|
statusState: createStatusState({
|
||||||
source: "pi",
|
source: "pi",
|
||||||
@@ -2316,6 +2350,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
status: "started",
|
status: "started",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
runningSubagents.delete(id);
|
||||||
|
try {
|
||||||
|
closeSurface(surface);
|
||||||
|
} catch {
|
||||||
|
// Preserve the resume error; cleanup is best-effort.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Backend-neutral terminal surface facade.
|
||||||
|
*
|
||||||
|
* Herdr is selected when Pi is running in a Herdr-managed pane. tmux remains
|
||||||
|
* the fallback when Pi is running inside tmux. Selection is automatic so a
|
||||||
|
* process uses one backend consistently for its whole lifetime.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import * as herdr from "./herdr.ts";
|
||||||
|
import * as tmux from "./tmux.ts";
|
||||||
|
|
||||||
|
export type MuxBackend = "herdr" | "tmux";
|
||||||
|
type Direction = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
|
type SurfaceBackend = {
|
||||||
|
createSurface(name: string): string;
|
||||||
|
createSurfaceSplit(name: string, direction: Direction, fromSurface?: string): string;
|
||||||
|
sendCommand(surface: string, command: string): void;
|
||||||
|
readScreen(surface: string, lines?: number): string;
|
||||||
|
readScreenAsync(surface: string, lines?: number): Promise<string>;
|
||||||
|
closeSurface(surface: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function selectMuxBackend(availability: {
|
||||||
|
herdr: boolean;
|
||||||
|
tmux: boolean;
|
||||||
|
}): MuxBackend | null {
|
||||||
|
if (availability.herdr) return "herdr";
|
||||||
|
if (availability.tmux) return "tmux";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMuxBackend(): MuxBackend | null {
|
||||||
|
return selectMuxBackend({ herdr: herdr.isAvailable(), tmux: tmux.isAvailable() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackend(): SurfaceBackend {
|
||||||
|
const backend = getMuxBackend();
|
||||||
|
if (backend === "herdr") return herdr;
|
||||||
|
if (backend === "tmux") return tmux;
|
||||||
|
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMuxAvailable(): boolean {
|
||||||
|
return getMuxBackend() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function muxSetupHint(): string {
|
||||||
|
if (process.env.HERDR_ENV === "1") return herdr.setupHint();
|
||||||
|
if (process.env.TMUX) return tmux.setupHint();
|
||||||
|
return "Start pi inside Herdr or tmux.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurface(name: string): string {
|
||||||
|
return getBackend().createSurface(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurfaceSplit(
|
||||||
|
name: string,
|
||||||
|
direction: Direction,
|
||||||
|
fromSurface?: string,
|
||||||
|
): string {
|
||||||
|
return getBackend().createSurfaceSplit(name, direction, fromSurface);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendCommand(surface: string, command: string): void {
|
||||||
|
getBackend().sendCommand(surface, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readScreen(surface: string, lines = 50): string {
|
||||||
|
return getBackend().readScreen(surface, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||||
|
return getBackend().readScreenAsync(surface, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSurface(surface: string): void {
|
||||||
|
getBackend().closeSurface(surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shellEscape(value: string): string {
|
||||||
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapWithCompletionFile(command: string, completionFile: string): string {
|
||||||
|
return (
|
||||||
|
`${command}; exit_code=$?; ` +
|
||||||
|
`printf '%s\\n' "$exit_code" > ${shellEscape(completionFile)}; ` +
|
||||||
|
`echo "__SUBAGENT_DONE_\${exit_code}__"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendLongCommand(
|
||||||
|
surface: string,
|
||||||
|
command: string,
|
||||||
|
options?: { scriptPath?: string; scriptPreamble?: string },
|
||||||
|
): string {
|
||||||
|
const scriptPath =
|
||||||
|
options?.scriptPath ??
|
||||||
|
join(
|
||||||
|
tmpdir(),
|
||||||
|
"pi-subagent-scripts",
|
||||||
|
`cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
|
||||||
|
);
|
||||||
|
mkdirSync(dirname(scriptPath), { recursive: true });
|
||||||
|
|
||||||
|
const scriptParts = ["#!/bin/bash"];
|
||||||
|
if (options?.scriptPreamble) scriptParts.push(options.scriptPreamble.trimEnd());
|
||||||
|
scriptParts.push(command);
|
||||||
|
|
||||||
|
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
|
||||||
|
sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
|
||||||
|
return scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollResult {
|
||||||
|
reason: "done" | "sentinel" | "error";
|
||||||
|
exitCode: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpretExitSidecar(data: unknown): PollResult {
|
||||||
|
const payload = data as { type?: unknown; errorMessage?: unknown } | null;
|
||||||
|
if (payload?.type === "error") {
|
||||||
|
const errorMessage =
|
||||||
|
typeof payload.errorMessage === "string" && payload.errorMessage.trim() !== ""
|
||||||
|
? payload.errorMessage
|
||||||
|
: "Subagent exited with stopReason=error (no errorMessage in sidecar).";
|
||||||
|
return { reason: "error", exitCode: 1, errorMessage };
|
||||||
|
}
|
||||||
|
return { reason: "done", exitCode: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCompletionFile(path: string): PollResult | null {
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
const raw = readFileSync(path, "utf8").trim();
|
||||||
|
const exitCode = Number.parseInt(raw, 10);
|
||||||
|
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(exitCode)) return null;
|
||||||
|
rmSync(path, { force: true });
|
||||||
|
return { reason: "sentinel", exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __pollForExitTest__ = { interpretExitSidecar, readCompletionFile };
|
||||||
|
|
||||||
|
export async function pollForExit(
|
||||||
|
surface: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
options: {
|
||||||
|
interval: number;
|
||||||
|
sessionFile?: string;
|
||||||
|
sentinelFile?: string;
|
||||||
|
completionFile?: string;
|
||||||
|
onTick?: (elapsed: number) => void;
|
||||||
|
},
|
||||||
|
): Promise<PollResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (signal.aborted) throw new Error("Aborted while waiting for subagent to finish");
|
||||||
|
|
||||||
|
if (options.sessionFile) {
|
||||||
|
try {
|
||||||
|
const exitFile = `${options.sessionFile}.exit`;
|
||||||
|
if (existsSync(exitFile)) {
|
||||||
|
const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8")));
|
||||||
|
rmSync(exitFile, { force: true });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.sentinelFile) {
|
||||||
|
try {
|
||||||
|
if (existsSync(options.sentinelFile)) {
|
||||||
|
return { reason: "sentinel", exitCode: 0 };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.completionFile) {
|
||||||
|
try {
|
||||||
|
const result = readCompletionFile(options.completionFile);
|
||||||
|
if (result) return result;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const screen = await readScreenAsync(surface, 5);
|
||||||
|
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
|
||||||
|
if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
|
||||||
|
} catch {
|
||||||
|
// Surface reads are best-effort. Filesystem completion remains authoritative.
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.floor((Date.now() - start) / 1000);
|
||||||
|
options.onTick?.(elapsed);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (signal.aborted) return reject(new Error("Aborted"));
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, options.interval);
|
||||||
|
function onAbort() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Aborted"));
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-249
@@ -1,31 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* tmux surface layer — the only terminal multiplexer this extension supports.
|
* tmux surface backend.
|
||||||
*
|
*
|
||||||
* Everything the extension does to a pane goes through the small API in this
|
* This module only implements tmux-specific terminal operations. Backend
|
||||||
* file: create/split a pane, type a command into it, read its screen, close
|
* selection, command scripts, and exit polling live in surface.ts.
|
||||||
* 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 { execFile, execFileSync } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
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);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
// ── Availability ──
|
|
||||||
|
|
||||||
const commandAvailability = new Map<string, boolean>();
|
const commandAvailability = new Map<string, boolean>();
|
||||||
|
|
||||||
function hasCommand(command: string): boolean {
|
function hasCommand(command: string): boolean {
|
||||||
if (commandAvailability.has(command)) {
|
const cached = commandAvailability.get(command);
|
||||||
return commandAvailability.get(command)!;
|
if (cached !== undefined) return cached;
|
||||||
}
|
|
||||||
|
|
||||||
let available = false;
|
let available = false;
|
||||||
try {
|
try {
|
||||||
@@ -39,62 +26,31 @@ function hasCommand(command: string): boolean {
|
|||||||
return available;
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function isAvailable(): boolean {
|
||||||
* 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");
|
return !!process.env.TMUX && hasCommand("tmux");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMuxAvailable(): boolean {
|
export function setupHint(): string {
|
||||||
return isTmuxAvailable();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function muxSetupHint(): string {
|
|
||||||
return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
|
return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireTmux(): void {
|
function requireTmux(): void {
|
||||||
if (!isTmuxAvailable()) {
|
if (!isAvailable()) {
|
||||||
throw new Error(`tmux is required for subagents. ${muxSetupHint()}`);
|
throw new Error(`tmux is required for this surface. ${setupHint()}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shell helpers ──
|
/** tmux layout used to keep sibling panes evenly sized. */
|
||||||
|
|
||||||
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";
|
const SUBAGENT_TMUX_LAYOUT = "even-horizontal";
|
||||||
|
|
||||||
let rebalanceTimer: ReturnType<typeof setTimeout> | null = null;
|
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 {
|
function rebalanceSurfaces(hintPane?: string): void {
|
||||||
// Prefer the parent pi pane (stable; survives a closing subagent pane).
|
|
||||||
const target = process.env.TMUX_PANE ?? hintPane;
|
const target = process.env.TMUX_PANE ?? hintPane;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
if (rebalanceTimer) clearTimeout(rebalanceTimer);
|
if (rebalanceTimer) clearTimeout(rebalanceTimer);
|
||||||
rebalanceTimer = setTimeout(() => {
|
rebalanceTimer = setTimeout(() => {
|
||||||
rebalanceTimer = null;
|
rebalanceTimer = null;
|
||||||
try {
|
try {
|
||||||
// -t <pane> resolves to that pane's window; does not change focus.
|
|
||||||
execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], {
|
execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
@@ -104,24 +60,10 @@ function rebalanceSurfaces(hintPane?: string): void {
|
|||||||
}, 120);
|
}, 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 {
|
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);
|
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(
|
export function createSurfaceSplit(
|
||||||
name: string,
|
name: string,
|
||||||
direction: "left" | "right" | "up" | "down",
|
direction: "left" | "right" | "up" | "down",
|
||||||
@@ -131,17 +73,9 @@ export function createSurfaceSplit(
|
|||||||
requireTmux();
|
requireTmux();
|
||||||
|
|
||||||
const args = ["split-window", "-d"];
|
const args = ["split-window", "-d"];
|
||||||
if (direction === "left" || direction === "right") {
|
args.push(direction === "left" || direction === "right" ? "-h" : "-v");
|
||||||
args.push("-h");
|
if (direction === "left" || direction === "up") args.push("-b");
|
||||||
} else {
|
if (fromSurface) args.push("-t", fromSurface);
|
||||||
args.push("-v");
|
|
||||||
}
|
|
||||||
if (direction === "left" || direction === "up") {
|
|
||||||
args.push("-b");
|
|
||||||
}
|
|
||||||
if (fromSurface) {
|
|
||||||
args.push("-t", fromSurface);
|
|
||||||
}
|
|
||||||
args.push("-P", "-F", "#{pane_id}");
|
args.push("-P", "-F", "#{pane_id}");
|
||||||
|
|
||||||
const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
|
const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
|
||||||
@@ -153,72 +87,21 @@ export function createSurfaceSplit(
|
|||||||
return 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 {
|
export function sendCommand(surface: string, command: string): void {
|
||||||
requireTmux();
|
requireTmux();
|
||||||
execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
|
execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
|
||||||
execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { 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 {
|
export function readScreen(surface: string, lines = 50): string {
|
||||||
requireTmux();
|
requireTmux();
|
||||||
return execFileSync(
|
return execFileSync(
|
||||||
"tmux",
|
"tmux",
|
||||||
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
||||||
{
|
{ encoding: "utf8" },
|
||||||
encoding: "utf8",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the screen contents of a pane (async).
|
|
||||||
*/
|
|
||||||
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||||
requireTmux();
|
requireTmux();
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
@@ -229,126 +112,8 @@ export async function readScreenAsync(surface: string, lines = 50): Promise<stri
|
|||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Close a pane.
|
|
||||||
*/
|
|
||||||
export function closeSurface(surface: string): void {
|
export function closeSurface(surface: string): void {
|
||||||
requireTmux();
|
requireTmux();
|
||||||
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
||||||
rebalanceSurfaces();
|
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 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+51
-14
@@ -2,9 +2,9 @@
|
|||||||
* Integration test harness for pi-interactive-subagents.
|
* Integration test harness for pi-interactive-subagents.
|
||||||
*
|
*
|
||||||
* Provides utilities to:
|
* Provides utilities to:
|
||||||
* - Detect whether tmux is available
|
* - Detect the active terminal-surface backend
|
||||||
* - Create isolated test environments with test agent definitions
|
* - Create isolated test environments with test agent definitions
|
||||||
* - Start real pi sessions in tmux panes
|
* - Start real pi sessions in Herdr tabs or tmux panes
|
||||||
* - Poll for file creation and screen output
|
* - Poll for file creation and screen output
|
||||||
* - Clean up panes and temp files after tests
|
* - Clean up panes and temp files after tests
|
||||||
*/
|
*/
|
||||||
@@ -18,12 +18,13 @@ import {
|
|||||||
existsSync,
|
existsSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
unlinkSync,
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { join, resolve, dirname } from "node:path";
|
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 {
|
||||||
isMuxAvailable,
|
getMuxBackend,
|
||||||
createSurface,
|
createSurface,
|
||||||
createSurfaceSplit,
|
createSurfaceSplit,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
@@ -31,10 +32,13 @@ import {
|
|||||||
readScreen,
|
readScreen,
|
||||||
readScreenAsync,
|
readScreenAsync,
|
||||||
closeSurface,
|
closeSurface,
|
||||||
|
pollForExit,
|
||||||
shellEscape,
|
shellEscape,
|
||||||
} from "../../pi-extension/subagents/tmux.ts";
|
wrapWithCompletionFile,
|
||||||
|
type MuxBackend,
|
||||||
|
} from "../../pi-extension/subagents/surface.ts";
|
||||||
|
|
||||||
// Re-export tmux primitives for tests
|
// Re-export backend-neutral surface primitives for tests
|
||||||
export {
|
export {
|
||||||
createSurface,
|
createSurface,
|
||||||
createSurfaceSplit,
|
createSurfaceSplit,
|
||||||
@@ -43,7 +47,9 @@ export {
|
|||||||
readScreen,
|
readScreen,
|
||||||
readScreenAsync,
|
readScreenAsync,
|
||||||
closeSurface,
|
closeSurface,
|
||||||
|
pollForExit,
|
||||||
shellEscape,
|
shellEscape,
|
||||||
|
wrapWithCompletionFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Paths ──
|
// ── Paths ──
|
||||||
@@ -68,27 +74,49 @@ const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.
|
|||||||
// ── Configuration ──
|
// ── Configuration ──
|
||||||
|
|
||||||
/** Model used for integration tests. Override with PI_TEST_MODEL env var. */
|
/** Model used for integration tests. Override with PI_TEST_MODEL env var. */
|
||||||
export const TEST_MODEL = process.env.PI_TEST_MODEL ?? "anthropic/claude-haiku-4-5";
|
const activePiModel =
|
||||||
|
process.env.PI_PROVIDER && process.env.PI_MODEL
|
||||||
|
? `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}`
|
||||||
|
: undefined;
|
||||||
|
export const TEST_MODEL =
|
||||||
|
process.env.PI_TEST_MODEL ?? activePiModel ?? "anthropic/claude-haiku-4-5";
|
||||||
|
|
||||||
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
|
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
|
||||||
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
|
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
|
||||||
|
|
||||||
// ── Backend detection ──
|
// ── Backend detection ──
|
||||||
|
|
||||||
/**
|
/** Return the one backend selected automatically for this process. */
|
||||||
* Detect whether tmux is available in the current environment.
|
export function getAvailableBackends(): MuxBackend[] {
|
||||||
* Returns ["tmux"] or [].
|
const backend = getMuxBackend();
|
||||||
*/
|
return backend ? [backend] : [];
|
||||||
export function getAvailableBackends(): string[] {
|
}
|
||||||
return isMuxAvailable() ? ["tmux"] : [];
|
|
||||||
|
function parseHerdrJson(command: string, args: string[]): any {
|
||||||
|
const output = execFileSync(command, args, { encoding: "utf8" });
|
||||||
|
return JSON.parse(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focusSurface(surface: string): void {
|
export function focusSurface(surface: string): void {
|
||||||
|
if (getMuxBackend() === "herdr") {
|
||||||
|
const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
|
||||||
|
const tabId = pane.result?.pane?.tab_id;
|
||||||
|
if (!tabId) throw new Error(`Herdr pane ${surface} has no tab id`);
|
||||||
|
execFileSync("herdr", ["tab", "focus", tabId], { encoding: "utf8" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
|
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFocusedSurface(): string | null {
|
export function getFocusedSurface(): string | null {
|
||||||
try {
|
try {
|
||||||
|
if (getMuxBackend() === "herdr") {
|
||||||
|
const workspace = process.env.HERDR_WORKSPACE_ID;
|
||||||
|
if (!workspace) return null;
|
||||||
|
const response = parseHerdrJson("herdr", ["pane", "list", "--workspace", workspace]);
|
||||||
|
return response.result?.panes?.find((pane: any) => pane.focused)?.pane_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], {
|
const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
@@ -110,7 +138,7 @@ export async function waitForFocusedSurface(
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Timeout (${timeout}ms) waiting for focused tmux pane ${surface}; ` +
|
`Timeout (${timeout}ms) waiting for focused terminal surface ${surface}; ` +
|
||||||
`current focus is ${getFocusedSurface() ?? "unknown"}`,
|
`current focus is ${getFocusedSurface() ?? "unknown"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,7 +167,13 @@ export function createTestEnv(): TestEnv {
|
|||||||
if (existsSync(TEST_AGENTS_SRC)) {
|
if (existsSync(TEST_AGENTS_SRC)) {
|
||||||
for (const file of readdirSync(TEST_AGENTS_SRC)) {
|
for (const file of readdirSync(TEST_AGENTS_SRC)) {
|
||||||
if (file.endsWith(".md")) {
|
if (file.endsWith(".md")) {
|
||||||
cpSync(join(TEST_AGENTS_SRC, file), join(agentsDir, file));
|
const destination = join(agentsDir, file);
|
||||||
|
cpSync(join(TEST_AGENTS_SRC, file), destination);
|
||||||
|
const definition = readFileSync(destination, "utf8").replace(
|
||||||
|
/^model:\s*.+$/m,
|
||||||
|
`model: ${TEST_MODEL}`,
|
||||||
|
);
|
||||||
|
writeFileSync(destination, definition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,6 +261,9 @@ export function startPi(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
// Herdr does not retain terminal output for tabs that have never been shown.
|
||||||
|
// Focus the test runner surface so waitForScreen can observe its transcript.
|
||||||
|
focusSurface(surface);
|
||||||
sendLongCommand(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, {
|
sendLongCommand(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, {
|
||||||
scriptPath: join(testDir, `test-launch-${Date.now()}.sh`),
|
scriptPath: join(testDir, `test-launch-${Date.now()}.sh`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +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 tmux pane, runs pi with a task that uses the subagent
|
* Each test creates a terminal surface, 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 tmux:
|
* Run inside Herdr, or inside tmux:
|
||||||
* tmux new 'npm run test:integration'
|
* tmux new 'npm run test:integration'
|
||||||
*
|
*
|
||||||
* Configuration:
|
* Configuration:
|
||||||
@@ -37,8 +37,8 @@ import {
|
|||||||
const backends = getAvailableBackends();
|
const backends = getAvailableBackends();
|
||||||
|
|
||||||
if (backends.length === 0) {
|
if (backends.length === 0) {
|
||||||
console.log("⚠️ tmux is not available — skipping subagent lifecycle integration tests");
|
console.log("⚠️ Herdr/tmux is not available — skipping subagent lifecycle integration tests");
|
||||||
console.log(" Run inside tmux to enable these tests.");
|
console.log(" Run inside Herdr or tmux to enable these tests.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const backend of backends) {
|
for (const backend of backends) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Integration tests for the tmux surface layer.
|
* Integration tests for the selected terminal-surface backend.
|
||||||
*
|
*
|
||||||
* These tests exercise real tmux operations: creating panes,
|
* These tests exercise real Herdr or tmux operations: creating surfaces,
|
||||||
* sending commands, reading screen output, and closing panes.
|
* sending commands, reading screen output, and closing panes.
|
||||||
* No LLM calls — fast and free.
|
* No LLM calls — fast and free.
|
||||||
*
|
*
|
||||||
* Run inside tmux:
|
* Run inside Herdr, or inside tmux:
|
||||||
* tmux new 'npm run test:integration'
|
* tmux new 'npm run test:integration'
|
||||||
*/
|
*/
|
||||||
import { describe, it, before, after } from "node:test";
|
import { describe, it, before, after } from "node:test";
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
createTestEnv,
|
createTestEnv,
|
||||||
cleanupTestEnv,
|
cleanupTestEnv,
|
||||||
createTrackedSurface,
|
createTrackedSurface,
|
||||||
createTrackedSurfaceSplit,
|
|
||||||
focusSurface,
|
focusSurface,
|
||||||
getFocusedSurface,
|
getFocusedSurface,
|
||||||
waitForFocusedSurface,
|
waitForFocusedSurface,
|
||||||
@@ -26,6 +25,8 @@ import {
|
|||||||
readScreen,
|
readScreen,
|
||||||
readScreenAsync,
|
readScreenAsync,
|
||||||
closeSurface,
|
closeSurface,
|
||||||
|
pollForExit,
|
||||||
|
wrapWithCompletionFile,
|
||||||
sleep,
|
sleep,
|
||||||
uniqueId,
|
uniqueId,
|
||||||
trackTempFile,
|
trackTempFile,
|
||||||
@@ -38,12 +39,12 @@ 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("⚠️ tmux is not available — skipping tmux-surface integration tests");
|
console.log("⚠️ Herdr/tmux is not available — skipping surface integration tests");
|
||||||
console.log(" Run inside tmux to enable these tests.");
|
console.log(" Run inside Herdr or tmux to enable these tests.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const backend of backends) {
|
for (const backend of backends) {
|
||||||
describe(`tmux-surface [${backend}]`, { timeout: 60_000 }, () => {
|
describe(`surface [${backend}]`, { timeout: 60_000 }, () => {
|
||||||
let env: TestEnv;
|
let env: TestEnv;
|
||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
@@ -55,7 +56,7 @@ for (const backend of backends) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 = createTrackedSurface(env, "focus-anchor");
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|
||||||
focusSurface(anchor);
|
focusSurface(anchor);
|
||||||
@@ -71,19 +72,40 @@ for (const backend of backends) {
|
|||||||
|
|
||||||
const markerA = uniqueId();
|
const markerA = uniqueId();
|
||||||
const markerB = uniqueId();
|
const markerB = uniqueId();
|
||||||
|
focusSurface(childA);
|
||||||
sendCommand(childA, `echo "FOCUS_A_${markerA}"`);
|
sendCommand(childA, `echo "FOCUS_A_${markerA}"`);
|
||||||
|
await waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50);
|
||||||
|
focusSurface(childB);
|
||||||
sendCommand(childB, `echo "FOCUS_B_${markerB}"`);
|
sendCommand(childB, `echo "FOCUS_B_${markerB}"`);
|
||||||
|
await waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50);
|
||||||
|
focusSurface(anchor);
|
||||||
|
await waitForFocusedSurface(anchor, 10_000);
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.all([
|
it("polls file-based completion and closes an unfocused surface", async () => {
|
||||||
waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50),
|
const focusedBefore = getFocusedSurface();
|
||||||
waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50),
|
const surface = createTrackedSurface(env, "background-completion");
|
||||||
]);
|
await sleep(1000);
|
||||||
assert.equal(getFocusedSurface(), anchor);
|
|
||||||
|
const completionFile = `${env.dir}/background.exit-code`;
|
||||||
|
sendLongCommand(surface, wrapWithCompletionFile("false", completionFile));
|
||||||
|
const result = await pollForExit(surface, new AbortController().signal, {
|
||||||
|
interval: 100,
|
||||||
|
completionFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { reason: "sentinel", exitCode: 1 });
|
||||||
|
assert.equal(getFocusedSurface(), focusedBefore);
|
||||||
|
|
||||||
|
closeSurface(surface);
|
||||||
|
untrackSurface(env, surface);
|
||||||
|
assert.throws(() => readScreen(surface, 5));
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
const surface = createTrackedSurface(env, "echo-test");
|
const surface = createTrackedSurface(env, "echo-test");
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
focusSurface(surface);
|
||||||
|
|
||||||
const marker = uniqueId();
|
const marker = uniqueId();
|
||||||
sendCommand(surface, `echo "MARKER_${marker}"`);
|
sendCommand(surface, `echo "MARKER_${marker}"`);
|
||||||
@@ -102,6 +124,7 @@ for (const backend of backends) {
|
|||||||
it("preserves shell special characters in echo output", async () => {
|
it("preserves shell special characters in echo output", async () => {
|
||||||
const surface = createTrackedSurface(env, "escape-test");
|
const surface = createTrackedSurface(env, "escape-test");
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
focusSurface(surface);
|
||||||
|
|
||||||
const marker = uniqueId();
|
const marker = uniqueId();
|
||||||
// Single-quoted string — $ and " are literal inside single quotes
|
// Single-quoted string — $ and " are literal inside single quotes
|
||||||
@@ -123,6 +146,7 @@ for (const backend of backends) {
|
|||||||
it("sends a long command via script file without truncation", async () => {
|
it("sends a long command via script file without truncation", async () => {
|
||||||
const surface = createTrackedSurface(env, "long-cmd-test");
|
const surface = createTrackedSurface(env, "long-cmd-test");
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
focusSurface(surface);
|
||||||
|
|
||||||
const marker = uniqueId();
|
const marker = uniqueId();
|
||||||
const longValue = "X".repeat(500);
|
const longValue = "X".repeat(500);
|
||||||
@@ -145,6 +169,7 @@ for (const backend of backends) {
|
|||||||
it("reads screen asynchronously", async () => {
|
it("reads screen asynchronously", async () => {
|
||||||
const surface = createTrackedSurface(env, "async-read-test");
|
const surface = createTrackedSurface(env, "async-read-test");
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
focusSurface(surface);
|
||||||
|
|
||||||
const marker = uniqueId();
|
const marker = uniqueId();
|
||||||
sendCommand(surface, `echo "ASYNC_${marker}"`);
|
sendCommand(surface, `echo "ASYNC_${marker}"`);
|
||||||
@@ -164,7 +189,10 @@ for (const backend of backends) {
|
|||||||
|
|
||||||
const m1 = uniqueId();
|
const m1 = uniqueId();
|
||||||
const m2 = uniqueId();
|
const m2 = uniqueId();
|
||||||
|
focusSurface(s1);
|
||||||
sendCommand(s1, `echo "S1_${m1}"`);
|
sendCommand(s1, `echo "S1_${m1}"`);
|
||||||
|
await sleep(750);
|
||||||
|
focusSurface(s2);
|
||||||
sendCommand(s2, `echo "S2_${m2}"`);
|
sendCommand(s2, `echo "S2_${m2}"`);
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
|
|
||||||
@@ -178,9 +206,10 @@ for (const backend of backends) {
|
|||||||
it("writes output to a file and verifies via surface", async () => {
|
it("writes output to a file and verifies via surface", async () => {
|
||||||
const surface = createTrackedSurface(env, "file-test");
|
const surface = createTrackedSurface(env, "file-test");
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
focusSurface(surface);
|
||||||
|
|
||||||
const marker = uniqueId();
|
const marker = uniqueId();
|
||||||
const filePath = `/tmp/pi-tmux-test-${marker}.txt`;
|
const filePath = `/tmp/pi-surface-test-${marker}.txt`;
|
||||||
|
|
||||||
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
|
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
|
||||||
|
|
||||||
+74
-19
@@ -30,7 +30,12 @@ import {
|
|||||||
summarizeSessionStats,
|
summarizeSessionStats,
|
||||||
} from "../pi-extension/subagents/session.ts";
|
} from "../pi-extension/subagents/session.ts";
|
||||||
|
|
||||||
import { shellEscape } from "../pi-extension/subagents/tmux.ts";
|
import {
|
||||||
|
__pollForExitTest__,
|
||||||
|
selectMuxBackend,
|
||||||
|
shellEscape,
|
||||||
|
} from "../pi-extension/subagents/surface.ts";
|
||||||
|
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
||||||
import {
|
import {
|
||||||
advanceStatusState,
|
advanceStatusState,
|
||||||
capStatusLines,
|
capStatusLines,
|
||||||
@@ -56,7 +61,6 @@ 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/tmux.ts";
|
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -1172,10 +1176,12 @@ describe("subagent discovery", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bundled scout/researcher/worker all resolve as non-interactive (auto-exit)", () => {
|
it("bundled scout/researcher/worker use the configured model and resolve as non-interactive", () => {
|
||||||
for (const name of ["scout", "researcher", "worker"]) {
|
for (const name of ["scout", "researcher", "worker"]) {
|
||||||
const defs = testApi.loadAgentDefaults(name);
|
const defs = testApi.loadAgentDefaults(name);
|
||||||
assert.ok(defs, `expected bundled agent ${name} to be discoverable`);
|
assert.ok(defs, `expected bundled agent ${name} to be discoverable`);
|
||||||
|
assert.equal(defs.model, "openai-codex/gpt-5.6-luna");
|
||||||
|
assert.equal(defs.thinking, "xhigh");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
testApi.resolveEffectiveInteractive({ name, task: "" }, defs),
|
testApi.resolveEffectiveInteractive({ name, task: "" }, defs),
|
||||||
false,
|
false,
|
||||||
@@ -1209,10 +1215,16 @@ describe("subagent discovery", () => {
|
|||||||
it("getToolExtensionPath maps custom tools and skips built-ins", () => {
|
it("getToolExtensionPath maps custom tools and skips built-ins", () => {
|
||||||
assert.equal(testApi.getToolExtensionPath("read"), undefined);
|
assert.equal(testApi.getToolExtensionPath("read"), undefined);
|
||||||
assert.equal(testApi.getToolExtensionPath("bash"), undefined);
|
assert.equal(testApi.getToolExtensionPath("bash"), undefined);
|
||||||
assert.ok(testApi.getToolExtensionPath("web_search")?.endsWith("web-search/index.ts"));
|
|
||||||
assert.ok(testApi.getToolExtensionPath("safe_bash")?.endsWith("tools/safe-bash.ts"));
|
assert.ok(testApi.getToolExtensionPath("safe_bash")?.endsWith("tools/safe-bash.ts"));
|
||||||
// Spawning tools are registered by this extension itself.
|
// Spawning tools are registered by this extension itself.
|
||||||
assert.ok(testApi.getToolExtensionPath("subagent")?.endsWith("index.ts"));
|
assert.ok(testApi.getToolExtensionPath("subagent")?.endsWith("index.ts"));
|
||||||
|
|
||||||
|
withTempDir((dir) => {
|
||||||
|
const extensionPath = join(dir, "portable-test-tool.ts");
|
||||||
|
writeFileSync(extensionPath, "export default function () {}\n");
|
||||||
|
subagentsModule.registerToolExtension("portable_test_tool", extensionPath);
|
||||||
|
assert.equal(testApi.getToolExtensionPath("portable_test_tool"), extensionPath);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores invalid session-mode values", async () => {
|
it("ignores invalid session-mode values", async () => {
|
||||||
@@ -1741,8 +1753,21 @@ describe("subagent-done.ts", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("tmux.ts interpretExitSidecar", () => {
|
describe("surface exit signals", () => {
|
||||||
const { interpretExitSidecar } = __pollForExitTest__;
|
const { interpretExitSidecar, readCompletionFile } = __pollForExitTest__;
|
||||||
|
|
||||||
|
it("reads and removes a shell completion file", () => {
|
||||||
|
withTempDir((dir) => {
|
||||||
|
const completionFile = join(dir, "child.exit-code");
|
||||||
|
writeFileSync(completionFile, "7\n");
|
||||||
|
assert.deepEqual(readCompletionFile(completionFile), {
|
||||||
|
reason: "sentinel",
|
||||||
|
exitCode: 7,
|
||||||
|
});
|
||||||
|
assert.equal(existsSync(completionFile), false);
|
||||||
|
assert.equal(readCompletionFile(completionFile), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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)", () => {
|
||||||
// ask_question writes a `.ask` signal, not a `.exit` ping sidecar, so an
|
// ask_question writes a `.ask` signal, not a `.exit` ping sidecar, so an
|
||||||
@@ -2460,19 +2485,11 @@ describe("subagent status renderer", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("subagent startup delay", () => {
|
describe("subagent startup delay", () => {
|
||||||
it("defaults to 500ms when no env var is set", () => {
|
it("uses backend-specific defaults", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
|
||||||
assert.equal(typeof testApi.getShellReadyDelayMs, "function");
|
assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
|
||||||
|
assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
|
||||||
const original = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
|
||||||
delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
|
||||||
try {
|
|
||||||
assert.equal(testApi.getShellReadyDelayMs(), 500);
|
|
||||||
} finally {
|
|
||||||
if (original == null) delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
|
||||||
else process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = original;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses PI_SUBAGENT_SHELL_READY_DELAY_MS when it is set", () => {
|
it("uses PI_SUBAGENT_SHELL_READY_DELAY_MS when it is set", () => {
|
||||||
@@ -2652,7 +2669,45 @@ describe("subagent display helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("tmux.ts", () => {
|
describe("surface backend selection", () => {
|
||||||
|
it("prefers Herdr and otherwise falls back to tmux", () => {
|
||||||
|
assert.equal(selectMuxBackend({ herdr: true, tmux: true }), "herdr");
|
||||||
|
assert.equal(selectMuxBackend({ herdr: true, tmux: false }), "herdr");
|
||||||
|
assert.equal(selectMuxBackend({ herdr: false, tmux: true }), "tmux");
|
||||||
|
assert.equal(selectMuxBackend({ herdr: false, tmux: false }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses Herdr tab and pane creation responses", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseCreatedTab(JSON.stringify({
|
||||||
|
result: {
|
||||||
|
root_pane: { pane_id: "w3:p9" },
|
||||||
|
tab: { tab_id: "w3:t6" },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{ paneId: "w3:p9", tabId: "w3:t6" },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
parseCreatedPane(JSON.stringify({ result: { pane: { pane_id: "w3:p10" } } })),
|
||||||
|
"w3:p10",
|
||||||
|
);
|
||||||
|
assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subagent completion wrapper", () => {
|
||||||
|
it("records the child exit code without losing the terminal sentinel", () => {
|
||||||
|
const wrapped = (subagentsModule as any).__test__.wrapWithCompletionFile(
|
||||||
|
"pi --session test.jsonl",
|
||||||
|
"/tmp/subagent exit",
|
||||||
|
);
|
||||||
|
assert.match(wrapped, /^pi --session test\.jsonl; exit_code=\$\?/);
|
||||||
|
assert.match(wrapped, /printf '%s\\n' "\$exit_code" > '\/tmp\/subagent exit'/);
|
||||||
|
assert.match(wrapped, /__SUBAGENT_DONE_\$\{exit_code\}__/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("surface.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'");
|
||||||
|
|||||||
Reference in New Issue
Block a user