Compare commits
10
Commits
20332d854c
...
b3884f6c35
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3884f6c35 | ||
|
|
c3e8b53c07 | ||
|
|
076021b603 | ||
|
|
f9d698d683 | ||
|
|
9cbc5b4878 | ||
|
|
865648c4f1 | ||
|
|
d9d01c30ff | ||
|
|
b9ed239c46 | ||
|
|
67495c38fb | ||
|
|
a541b3d98b |
+3
-1
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
config.json
|
||||
.DS_Store
|
||||
.memory/
|
||||
.memory/
|
||||
.pi/
|
||||
.worktrees/
|
||||
@@ -1,456 +1,198 @@
|
||||
# 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), 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.
|
||||
|
||||
## How It Works
|
||||
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.
|
||||
|
||||
Call `subagent()` and it **returns immediately**. The sub-agent runs in its own terminal pane. A live widget above the input shows all running agents with their current state — `starting`, `active`, `waiting`, `stalled`, or `running`. When a sub-agent finishes, its result is **steered back** into the main session as an async notification — triggering a new turn so the agent can process it.
|
||||
## How it works
|
||||
|
||||
`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 ─╮
|
||||
│ 00:23 Scout: Auth (scout) active · bash 7m │
|
||||
│ 00:45 Scout: DB (scout) waiting 2m │
|
||||
│ 00:23 scout active · bash 7m │
|
||||
│ 00:45 scout-2 waiting 2m │
|
||||
╰────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
For parallel execution, just call `subagent` multiple times — they all run concurrently:
|
||||
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
||||
|
||||
```typescript
|
||||
subagent({ name: "Scout: Auth", agent: "scout", task: "Analyze auth module" });
|
||||
subagent({ name: "Scout: DB", agent: "scout", task: "Map database schema" });
|
||||
// Both return immediately, results steer back independently
|
||||
```
|
||||
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 subagent commands sometimes get dropped before the prompt is ready, set `PI_SUBAGENT_SHELL_READY_DELAY_MS` to a higher value (defaults to `500`):
|
||||
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
|
||||
|
||||
```bash
|
||||
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500
|
||||
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, tmux 500
|
||||
```
|
||||
|
||||
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.
|
||||
## Tools
|
||||
|
||||
### Pane layout (tmux)
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
| `subagents_list` | List available agent definitions |
|
||||
| `ask_question` | *(sub-agent sessions only)* Ask the orchestrator a question and wait for the reply |
|
||||
|
||||
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.
|
||||
There is also a `/subagent <agent> <task>` command for spawning directly.
|
||||
|
||||
The layout is a single constant in `pi-extension/subagents/cmux.ts`:
|
||||
|
||||
```ts
|
||||
const SUBAGENT_TMUX_LAYOUT = "even-horizontal";
|
||||
```
|
||||
|
||||
Change it to switch the geometry, then reload the extension (or restart pi):
|
||||
|
||||
| Value | Geometry |
|
||||
| ------------------ | ------------------------------------------------------------------------ |
|
||||
| `even-horizontal` | Equal columns across the window (matches a manual `Ctrl+b` then `Alt+1`). Default. |
|
||||
| `main-vertical` | One large main pane on the left, subagents tiled in a column on the right. |
|
||||
| `tiled` | Grid of roughly equal panes — scales best when many subagents run at once. |
|
||||
|
||||
The value is passed straight to `tmux select-layout`, so any named tmux layout works. An unrecognized value is ignored silently (the resize is best-effort and never blocks spawning), so double-check the spelling if a change seems to have no effect.
|
||||
|
||||
## What's Included
|
||||
|
||||
### Extensions
|
||||
|
||||
**Subagents** — 3 main-session tools + 1 command, plus 1 subagent-only tool:
|
||||
|
||||
| Tool | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| `subagent` | Spawn a sub-agent in a dedicated multiplexer pane (async — returns immediately) |
|
||||
| `subagent_message` | Steer a running subagent (by `name`) or resume a finished one (by `sessionId`) |
|
||||
| `subagents_list` | List available agent definitions |
|
||||
|
||||
| Command | Description |
|
||||
| -------------------------- | ------------------------------------ |
|
||||
| `/subagent <agent> <task>` | Spawn a named agent directly |
|
||||
|
||||
### Bundled Agents
|
||||
|
||||
| Agent | Model | Tools | Role |
|
||||
| -------------- | ------ | ------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| **scout** | Haiku | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon — maps files, patterns, conventions |
|
||||
| **researcher** | Sonnet | `web_search`, `web_fetch` | Web research — searches and synthesizes a sourced brief |
|
||||
| **worker** | Sonnet | `read`, `write`, `edit`, `safe_bash`, `web_search`, `web_fetch` + spawning | General implementer — writes code, runs commands; may dispatch `scout`/`researcher` to protect its context |
|
||||
|
||||
The **worker** is the only bundled agent granted the spawning toolset, and it may only spawn `scout` and `researcher` (see [`subagent_agents`](#subagent_agents)). `scout` and `researcher` are autonomous (`auto-exit: true`); `worker` runs interactively.
|
||||
|
||||
Agent discovery follows priority: **project-local** (`.pi/agents/`) > **global** (`~/.pi/agent/agents/`) > **package-bundled**. Override any bundled agent by placing your own version in the higher-priority location.
|
||||
|
||||
---
|
||||
|
||||
## Async Subagent Flow
|
||||
|
||||
```
|
||||
1. Agent calls subagent() → returns immediately ("started")
|
||||
2. Sub-agent runs in mux pane → widget shows live status
|
||||
3. User keeps chatting → main session fully interactive
|
||||
4. Sub-agent finishes → result steered back as a normal completion/failure
|
||||
5. Main agent processes result → continues with new context
|
||||
```
|
||||
|
||||
Multiple subagents run concurrently — each steers its result back independently as it finishes. The live widget above the input tracks all running agents:
|
||||
|
||||
```
|
||||
╭─ Subagents ───────────────────────────────── 3 running ─╮
|
||||
│ 01:23 Scout: Auth (scout) active · write 7m │
|
||||
│ 00:45 Researcher (researcher) stalled 4m │
|
||||
│ 00:12 Scout: DB (scout) starting… │
|
||||
╰─────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
Completion messages render with a colored background and are expandable with `Ctrl+O` to show the full summary and session file path.
|
||||
|
||||
### In-progress status updates
|
||||
|
||||
The widget tracks each Pi-backed sub-agent from a child-written runtime snapshot and labels it with a coarse state:
|
||||
|
||||
- `starting` — launched, but no valid child snapshot has been observed yet
|
||||
- `active` — the child is doing observed runtime work: agent turn, provider request, streaming, or tool execution
|
||||
- `waiting` — the child finished a turn and is intentionally open for more input or another stage
|
||||
- `stalled` — the parent has gone too long without a valid current child snapshot and can no longer trust the run is healthy
|
||||
- `running` — fallback for backends without child snapshots (e.g. Claude)
|
||||
|
||||
These labels are no longer derived from session-file growth. Session JSONL is still used for transcript, resume, lineage, and result extraction, but Pi-backed liveness now comes from a small activity snapshot written by the child extension. A fixed internal watchdog marks a run as `stalled` when valid snapshots never appear, stop being readable, or stop matching the current child; valid long-running `active` or `waiting` states do not become `stalled` just because time passes. When a run enters `stalled` or recovers from it, the parent agent receives a steer message so it can react. All other status transitions stay in the widget only.
|
||||
|
||||
**Interactive subagents stay silent.** Long-running user-driven subagents (e.g. `worker`) do not wake the parent session on `stalled`/`recovered` transitions — the user is working directly in the subagent's pane, and a steer message there would just burn an orchestrator turn on a no-op "still waiting" ping. The widget still updates normally, and child snapshots are still recorded/classified regardless of the `interactive` setting. By default, agents with `auto-exit: true` are treated as autonomous and get stall pings; agents without it are treated as interactive and stay quiet. Override per-agent with `interactive: true|false` in frontmatter.
|
||||
|
||||
#### Configuration
|
||||
|
||||
Status display is controlled by `config.json` in the extension directory. Copy `config.json.example` to get started:
|
||||
|
||||
```bash
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`config.json` is gitignored so local overrides don't get committed.
|
||||
|
||||
---
|
||||
|
||||
## Spawning Subagents
|
||||
### Spawning
|
||||
|
||||
```typescript
|
||||
// Spawn an agent by its profile name
|
||||
subagent({ agent: "scout", task: "Analyze the codebase..." });
|
||||
|
||||
// Cosmetic pane label (defaults to the agent name when omitted)
|
||||
subagent({ agent: "scout", task: "Analyze the auth module" });
|
||||
subagent({ agent: "worker", name: "dark-mode", task: "Implement the dark mode toggle" });
|
||||
|
||||
// Custom working directory
|
||||
subagent({ agent: "game-designer", cwd: "agents/game-designer", task: "..." });
|
||||
```
|
||||
|
||||
### Parameters
|
||||
| Parameter | Type | Default | Description |
|
||||
| --------- | ---- | ------- | ----------- |
|
||||
| `agent` | string | required | Which agent to spawn (must be known and permitted) |
|
||||
| `task` | string | required | Task prompt |
|
||||
| `name` | string | agent name | Display name for the pane and widget. Must be unique — duplicates are auto-suffixed (`scout`, `scout-2`, …) |
|
||||
| `model` | string | agent's model | Override the model for this spawn |
|
||||
| `cwd` | string | agent's `cwd` | Working directory (see [Role folders](#role-folders)) |
|
||||
|
||||
The schema is intentionally small: each agent has a fixed, well-defined loadout (model, tools, system prompt) baked into its definition, so per-spawn override knobs were removed. You pick an `agent` and give it a `task`.
|
||||
### Messaging
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| --------- | ------ | -------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `agent` | string | required | Which agent to spawn. Must be a known/permitted agent (see [`subagents_list`](#subagents_list)). Loads that agent's fixed profile. |
|
||||
| `task` | string | required | Task prompt for the sub-agent |
|
||||
| `name` | string | _agent name_ | Optional cosmetic label for the pane and widget row. Defaults to the agent name. Does not select the agent. |
|
||||
| `model` | string | — | Override the agent's default model for this spawn |
|
||||
| `cwd` | string | — | Working directory for the sub-agent (see [Role Folders](#role-folders)) |
|
||||
|
||||
---
|
||||
|
||||
## Messaging a subagent
|
||||
|
||||
`subagent_message` is the single tool for talking to a subagent after it has been spawned. It has two modes, selected by which argument you pass:
|
||||
|
||||
**Steer a running subagent** — pass `name` to type a follow-up instruction directly into the live pane:
|
||||
`subagent_message` is addressed **by name only**. Names are unique per session and persist after a sub-agent finishes, so the same name works either way:
|
||||
|
||||
```typescript
|
||||
subagent_message({ name: "Scout", message: "Also check the auth middleware" });
|
||||
subagent_message({ name: "scout", message: "Also check the auth middleware" });
|
||||
```
|
||||
|
||||
The message is delivered into the child's TUI editor (newlines are flattened to spaces so it fires as one turn). The child picks it up at its next turn boundary. The call returns immediately and does **not**, by itself, produce a new result — the subagent's eventual completion still arrives as a steer message. The widget moves the child to `waiting` until it resumes work.
|
||||
- **Running** — the message is typed into the live pane (newlines flattened) and picked up at the next turn boundary. The call returns immediately; the eventual completion still arrives as a steer message.
|
||||
- **Finished** — the session is resumed with the message as the follow-up task, like a fresh spawn: fire-and-forget, always autonomous, result steered back later. The resumed run reclaims its original name.
|
||||
|
||||
**Resume a finished subagent** — pass `sessionId` (returned in the completed subagent's result) to relaunch and continue that session:
|
||||
Every spawn records name → session file in `artifacts/<sessionId>/subagent-registry.json`, so names stay addressable across pi restarts. A nested sub-agent that spawns children gets its own registry keyed by its own session id. Resume is refused with a clear error (listing known names) if the name isn't registered, the session file is gone, or the session predates sandboxed resume.
|
||||
|
||||
```typescript
|
||||
subagent_message({ sessionId: "019f05b2-f1c3", message: "Now write the tests too" });
|
||||
```
|
||||
**Resume replays the original sandbox.** At spawn time the fully-resolved loadout — tool allowlist, backing extensions, model, thinking level, system prompt, spawn whitelist, cwd — is snapshotted to `<session>.loadout.json`. Resume rebuilds the exact same restricted process from that snapshot rather than relaunching unrestricted.
|
||||
|
||||
Resuming is fire-and-forget async: the relaunched session always runs its follow-up task autonomously and its result is delivered later as a steer message, exactly like a fresh `subagent` spawn. (There is no per-call behavior knob — resume is always autonomous, matching this result-delivery model.)
|
||||
### ask_question
|
||||
|
||||
> **Guard:** a `sessionId` that maps to a still-running subagent is rejected — resuming would launch a second process mutating the same session file. Steer it by `name` instead. There is no hard-abort tool; to forcibly stop a subagent, use its pane directly.
|
||||
A sub-agent can ask its orchestrator a single freeform question when requirements are ambiguous or a decision materially affects the work. The session **stays open** (parked as `waiting`) instead of exiting; the parent is notified with the sub-agent's name, replies via `subagent_message({ name, message })`, and the reply arrives as the sub-agent's next turn. Parallel questions are supported — each waiting sub-agent has its own name.
|
||||
|
||||
**`subagent_message` parameters:**
|
||||
- `name` — exact display name of a running subagent to steer (mutually exclusive with `sessionId`)
|
||||
- `sessionId` — id (or id prefix) of a finished session to resume (mutually exclusive with `name`)
|
||||
- `message` (required) — the instruction or next task to deliver
|
||||
If the reply arrives while the sub-agent is still mid-turn, it is absorbed into the current turn — either way the question is marked answered and the session exits normally when the work is done. If the parent never replies, the pane stays open until a human closes it. Only available inside sub-agent sessions.
|
||||
|
||||
---
|
||||
## Bundled agents
|
||||
|
||||
## ask_question — Subagent Asks the Orchestrator
|
||||
| Agent | Model | Tools | Role |
|
||||
| ----- | ----- | ----- | ---- |
|
||||
| **scout** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon |
|
||||
| **researcher** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `web_search`, `web_fetch`, `safe_bash` | Web research, synthesized into a sourced brief |
|
||||
| **worker** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `write`, `edit`, `bash`, `web_search`, `web_fetch` + spawning | General implementer; may spawn `scout` and `researcher` |
|
||||
|
||||
The `ask_question` tool lets a subagent ask its parent (the orchestrator that spawned it) a single freeform question — for ambiguous requirements, a decision that materially affects the work, a blocker, or confirmation only the parent has. It is framed like the top-level `ask_user_question` tool so agents reach for it proactively instead of guessing.
|
||||
All three are autonomous (`auto-exit: true`) and carry their identity in the system prompt (`system-prompt: append`).
|
||||
|
||||
Unlike the old `caller_ping`, **the session stays open while it waits.** Auto-exit is suppressed for that turn, the subagent parks in a `waiting` state, and the parent replies with `subagent_message` — which lands as the subagent's next turn so it continues right where it paused.
|
||||
## Custom agents
|
||||
|
||||
**`ask_question` parameters:**
|
||||
- `question` (required): The single freeform question. Include enough context to answer it directly. No options/multiple-choice — ask one question per call.
|
||||
|
||||
**Interaction flow:**
|
||||
1. Child calls `ask_question({ question: "Found two migration files — use v1 or v2?" })`
|
||||
2. The child's turn ends but the **session stays open** (parked as `waiting`); the tool returns *"Question sent — stop and wait for the reply."*
|
||||
3. The parent's watcher picks up the question and steers a notification: *"Sub-agent worker-2 asks: Found two migration files — use v1 or v2?"* plus the unique subagent name (and session id as backup)
|
||||
4. Parent replies while it runs via `subagent_message({ name, message })` (or `subagent_message({ sessionId, message })` if it has since exited)
|
||||
5. The reply arrives as the subagent's next message and it continues with the parent's guidance
|
||||
|
||||
**Parallel questions are supported.** Each subagent has its own session and its own watcher, so multiple subagents can be waiting on answers at once. Default names are deduped at spawn (`worker`, `worker-2`, …), and every notification surfaces that unique name, so each reply targets the right subagent.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Inside a worker subagent
|
||||
await ask_question({
|
||||
question: "Found two conflicting migration files — should I use v1 or v2?"
|
||||
});
|
||||
// Session stays open. Parent answers with subagent_message({ name: "worker", message: "Use v2…" }),
|
||||
// which arrives as this subagent's next turn.
|
||||
```
|
||||
|
||||
> **Note:** `ask_question` is only available inside subagent contexts. Calling it from a standalone pi session returns an error.
|
||||
>
|
||||
> If the parent never replies, the subagent stays open indefinitely (shown as `waiting`) until the human closes its pane — mirroring interactive-agent behavior. There is no timeout.
|
||||
|
||||
---
|
||||
|
||||
## Custom Agents
|
||||
|
||||
Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global):
|
||||
Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global). Discovery priority: **project > global > package-bundled** — a project-local file overrides a bundled agent with the same name.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-agent
|
||||
description: Does something specific
|
||||
model: anthropic/claude-sonnet-4-6
|
||||
thinking: minimal
|
||||
model: openai-codex/gpt-5.6-luna
|
||||
thinking: xhigh
|
||||
tools: read, edit, write, safe_bash, web_search
|
||||
session-mode: lineage-only
|
||||
auto-exit: true
|
||||
---
|
||||
|
||||
# My Agent
|
||||
|
||||
You are a specialized agent that does X...
|
||||
```
|
||||
|
||||
The `tools` field is a strict allowlist. The child process is launched with extension discovery disabled (`--no-extensions`) and only the extensions backing the listed tools are loaded back in. An agent that lists no spawning tools and no `subagent_agents` simply cannot spawn anything.
|
||||
### Frontmatter reference
|
||||
|
||||
### Frontmatter Reference
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `name` | string | Agent name (used in `agent: "my-agent"`) |
|
||||
| `description` | string | Shown in `subagents_list` |
|
||||
| `model` | string | Default model |
|
||||
| `thinking` | string | `minimal`, `low`, `medium`, or `high` |
|
||||
| `tools` | string | Strict tool allowlist. Built-ins: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`. Extension-backed: `web_search`, `web_fetch`, `safe_bash`, `video_extract`, `youtube_search`, `google_image_search`. Only the extensions backing the listed tools are loaded into the child |
|
||||
| `subagent_agents` | string | Comma-separated agent names this agent may spawn. **Presence of this field grants the spawning toolset** (`subagent`, `subagent_message`, `subagents_list`) and restricts spawn targets to the list. Omit it and the agent cannot spawn at all |
|
||||
| `skills` | string | Comma-separated skill names to auto-load |
|
||||
| `session-mode` | string | `standalone` (default), `lineage-only`, or `fork` — see below |
|
||||
| `system-prompt` | string | `append` or `replace`: pass the body as the child's `--append-system-prompt` / `--system-prompt`. Omit and the body is prepended to the task prompt instead |
|
||||
| `auto-exit` | boolean | Auto-shutdown when the agent finishes (see below) |
|
||||
| `interactive` | boolean | Whether stall/recovery transitions wake the parent (see below) |
|
||||
| `cwd` | string | Default working directory |
|
||||
| `disable-model-invocation` | boolean | Hide from `subagents_list`; still spawnable by explicit name |
|
||||
| `cli` | string | `claude` runs the agent via the Claude Code CLI instead of pi |
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | string | Agent name (used in `agent: "my-agent"`) |
|
||||
| `description` | string | Shown in `subagents_list` output |
|
||||
| `model` | string | Default model (e.g. `anthropic/claude-sonnet-4-6`) |
|
||||
| `thinking` | string | Thinking level: `minimal`, `medium`, `high` |
|
||||
| `tools` | string | Strict allowlist of tool names. Built-ins (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`) plus custom-extension tools (`web_search`, `web_fetch`, `safe_bash`, `video_extract`, `youtube_search`, `google_image_search`). Only the extensions backing these tools are loaded into the child. |
|
||||
| `subagent_agents` | string | Comma-separated list of agent names this agent may spawn. **Presence of this field grants the full spawning toolset** (`subagent`, `subagent_message`, `subagents_list`) and restricts spawn targets to the listed agents. Omit it and the agent cannot spawn at all. |
|
||||
| `skills` | string | Comma-separated skill names to auto-load |
|
||||
| `session-mode` | string | Default child-session mode: `standalone`, `lineage-only`, or `fork` |
|
||||
| `auto-exit` | boolean | Auto-shutdown when the agent finishes its turn. If the user sends any input, auto-exit is permanently disabled and the user takes over the session. Recommended for autonomous agents (scout, researcher); not for interactive ones (worker). Also determines the default value of `interactive` (see below). |
|
||||
| `interactive` | boolean | derived | Override whether stall/recovery transitions wake the parent session. Defaults to the inverse of `auto-exit`: autonomous agents (`auto-exit: true`) are non-interactive and get stall pings; agents without `auto-exit` are interactive and stay quiet. Explicit values take precedence. |
|
||||
| `cwd` | string | Default working directory (absolute or relative to project root) |
|
||||
| `disable-model-invocation` | boolean | Hide this agent from discovery surfaces like `subagents_list`. The agent still remains directly invokable by explicit name via `subagent({ agent: "name", ... })`. |
|
||||
### session-mode
|
||||
|
||||
---
|
||||
- `standalone` — fresh session, no lineage link to the caller (default)
|
||||
- `lineage-only` — fresh session with `parentSession` linkage for discovery/fork UX, but no copied turns
|
||||
- `fork` — child session seeded with the caller's conversation context
|
||||
|
||||
Discovery still resolves precedence before visibility filtering. If a project-local hidden agent has the same name as a visible global or bundled agent, the hidden project agent wins and the lower-precedence agent does not appear in `subagents_list`.
|
||||
### auto-exit
|
||||
|
||||
### `session-mode`
|
||||
With `auto-exit: true`, the session shuts down when the agent's turn ends — the agent just writes its final message and stops (there is no "done" tool). The last assistant message becomes the summary returned to the parent. Recommended for all autonomous agents.
|
||||
|
||||
Choose how a subagent session starts:
|
||||
Notes:
|
||||
|
||||
- `standalone` — default fresh session with no lineage link to the caller
|
||||
- `lineage-only` — fresh blank child session with `parentSession` linkage, but no copied turns from the caller
|
||||
- `fork` — linked child session seeded with the caller's prior conversation context
|
||||
- **Manual input does not strand an auto-exit sub-agent.** If a human types into the pane, the session still closes once that turn completes normally — only an escape/abort leaves it open.
|
||||
- **Auto-exit is suppressed while work is in flight:** the session parks as `waiting` instead of exiting when an `ask_question` is still unanswered, or when the agent's own child sub-agents are still running (a worker can stop after dispatching children and stays open until the last result returns).
|
||||
|
||||
`lineage-only` is useful when you want session discovery and fork lineage UX to show the relationship later, but you do **not** want the child to inherit the parent's turns. Set the mode per agent via the `session-mode` frontmatter field below.
|
||||
### interactive
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: worker
|
||||
session-mode: lineage-only
|
||||
---
|
||||
```
|
||||
Controls whether `stalled`/`recovered` status transitions send a steer message to the parent session. Defaults to the inverse of `auto-exit`: autonomous agents get stall pings; user-driven agents stay quiet (the user is already working in that pane — the widget still updates). Set explicitly to override.
|
||||
|
||||
### `auto-exit`
|
||||
## Tool access control
|
||||
|
||||
When set to `true`, the agent session shuts down automatically as soon as the agent finishes its turn. The agent just writes its final message and stops; there is no "done" tool to call. The last assistant message becomes the summary returned to the parent.
|
||||
Access is **whitelist-only**. Every sub-agent process is launched with `--no-extensions` (extension discovery disabled) and `--tools <allowlist>`; only the extensions backing the listed tools are loaded back in explicitly. There is no default toolset and no deny-list — an agent gets exactly what its frontmatter lists. The restriction survives resume via the loadout snapshot.
|
||||
|
||||
**Behavior:**
|
||||
Spawns must name a known agent at **every** depth. A top-level session may spawn anything discoverable; a sub-agent may only spawn the agents in its `subagent_agents` list (enforced via `PI_SUBAGENT_ALLOWED`). There is no agentless spawn route, so a child can never escalate to a full-toolset profile by omitting its agent.
|
||||
|
||||
- The session closes after the agent's final message (on the `agent_end` event)
|
||||
- If the user sends **any input** before the agent finishes, auto-exit is permanently disabled for that session — the user takes over interactively
|
||||
- The modeHint injected into the agent's task is adjusted accordingly: autonomous agents are told to simply stop when finished, while interactive agents are told the session ends when the user exits the pane
|
||||
Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global.
|
||||
|
||||
**Auto-exit is suppressed while work is still in flight.** Even an `auto-exit` agent will *not* shut down at the end of a turn when either:
|
||||
## Role folders
|
||||
|
||||
- it has a pending [`ask_question`](#ask_question--subagent-asks-the-orchestrator) awaiting the orchestrator's reply, or
|
||||
- it spawned **its own child subagents** (e.g. a `worker` delegating to `scout`/`researcher`) that haven't finished yet.
|
||||
|
||||
In both cases the session parks as `waiting` instead of exiting, and resumes when the next turn lands (the reply, or a child's result steered back). This is what lets a worker write *"I'll wait for the results"* and stop without killing the session out from under its children — it stays open until the **last** child reports back, then the worker's final summary is returned to the orchestrator as usual. Child liveness is read from the spawning extension's live count, so a nested agent that never spawned anything still exits immediately as before.
|
||||
|
||||
**When to use:**
|
||||
|
||||
- ✅ Autonomous agents (scout, researcher) that run to completion
|
||||
- ❌ Interactive agents (worker) where the user drives the session
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: scout
|
||||
auto-exit: true
|
||||
---
|
||||
```
|
||||
|
||||
### `interactive`
|
||||
|
||||
Controls whether status transitions (`stalled`, `recovered`) wake the parent session with a steer message.
|
||||
|
||||
**Default:** the inverse of `auto-exit`. Autonomous agents (`auto-exit: true`) are non-interactive and ping the parent on stall/recovery; agents without `auto-exit` are interactive and stay quiet.
|
||||
|
||||
**Why it exists:** Interactive agents can run for minutes or hours while the user thinks, types, and reads in the subagent's pane. Child snapshots still update the widget, but stalled/recovered supervision messages rarely need to wake the parent for user-driven sessions. Skipping the steer keeps the parent quiet until the child actually finishes.
|
||||
|
||||
**When to override:**
|
||||
|
||||
- Set `interactive: false` on an agent that doesn't auto-exit but you still want stall pings for
|
||||
- Set `interactive: true` on an autonomous agent you'd rather check on yourself
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: worker
|
||||
# interactive defaults to true because auto-exit is not set
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Access Control
|
||||
|
||||
Access is **whitelist-only**. Every sub-agent process is launched with `--no-extensions` (extension discovery disabled) and `--tools <allowlist>`. Only the tools named in the agent's `tools` frontmatter are exposed, and only the extensions that register those tools are loaded back in via explicit `--extension` flags. There is no global default toolset and no deny-list to maintain — an agent gets exactly what it asks for.
|
||||
|
||||
### Spawns must name a known agent
|
||||
|
||||
The agent whitelist is enforced at **every** depth, not just for restricted sub-agents:
|
||||
|
||||
- A **restricted sub-agent** (one launched with `PI_SUBAGENT_ALLOWED`) may only spawn the agents pinned in its `subagent_agents` list.
|
||||
- A **top-level session** may spawn any agent that appears in `subagents_list` (every discoverable definition).
|
||||
|
||||
Every `subagent` call must set `agent` to a name in the caller's permitted set. A missing `agent`, or one that doesn't resolve to a real definition (e.g. `agent: "wizard"`), is rejected — there is no agentless spawn route, so a child can never launch with an unrestricted, full-toolset profile by omitting its agent.
|
||||
|
||||
### Granting the ability to spawn: `subagent_agents`
|
||||
|
||||
Spawning is **off by default**. An agent cannot spawn sub-agents unless its frontmatter declares a `subagent_agents` list. Presence of that field:
|
||||
|
||||
1. Grants the full spawning toolset (`subagent`, `subagent_message`, `subagents_list`) — you do **not** list these in `tools`.
|
||||
2. Loads this extension into the child process.
|
||||
3. Restricts the child to spawning **only** the named agents. The child's `subagents_list` is filtered to that set, and `subagent` calls for any other agent are rejected. Enforced via the `PI_SUBAGENT_ALLOWED` env var.
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: worker
|
||||
tools: read, write, edit, safe_bash, web_search, web_fetch
|
||||
subagent_agents: scout, researcher
|
||||
---
|
||||
```
|
||||
|
||||
### Recommended Configuration
|
||||
|
||||
| Agent | `subagent_agents` | Rationale |
|
||||
| ---------- | ------------------- | ------------------------------------------------------ |
|
||||
| worker | `scout, researcher` | Delegates recon/research to protect its own context |
|
||||
| scout | _(omitted)_ | Read-only recon; should gather context, not spawn |
|
||||
| researcher | _(omitted)_ | Web research only; should research, not spawn |
|
||||
|
||||
---
|
||||
|
||||
## Role Folders
|
||||
|
||||
The `cwd` parameter lets sub-agents start in a specific directory with its own configuration:
|
||||
`cwd` starts a sub-agent in a directory with its own config, so role-specific setups (CLAUDE.md, skills, extensions) apply:
|
||||
|
||||
```
|
||||
project/
|
||||
├── agents/
|
||||
│ ├── game-designer/
|
||||
│ │ └── CLAUDE.md ← "You are a game designer..."
|
||||
│ ├── sre/
|
||||
│ │ ├── CLAUDE.md ← "You are an SRE specialist..."
|
||||
│ │ └── .pi/skills/ ← SRE-specific skills
|
||||
│ └── narrative/
|
||||
│ └── CLAUDE.md ← "You are a narrative designer..."
|
||||
└── agents/
|
||||
├── game-designer/ ← CLAUDE.md, .pi/…
|
||||
└── sre/ ← CLAUDE.md, .pi/…
|
||||
```
|
||||
|
||||
```typescript
|
||||
subagent({ name: "Game Designer", cwd: "agents/game-designer", task: "Design the combat system" });
|
||||
subagent({ name: "SRE", cwd: "agents/sre", task: "Review deployment pipeline" });
|
||||
subagent({ agent: "worker", cwd: "agents/sre", task: "Review the deployment pipeline" });
|
||||
```
|
||||
|
||||
Set a default `cwd` in agent frontmatter:
|
||||
Set a per-agent default with `cwd:` in frontmatter.
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: game-designer
|
||||
cwd: ./agents/game-designer
|
||||
tools: read, write, edit, safe_bash
|
||||
---
|
||||
## Status widget & configuration
|
||||
|
||||
The widget tracks each sub-agent from a runtime activity snapshot written by the child: `starting`, `active` (turn/provider/tool work), `waiting` (open for input or another stage), `stalled` (no valid snapshot for too long), or `running` (fallback). Sub-agent sessions also show their own tools widget — toggle it with `Ctrl+Alt+O`. Completion messages expand with `Ctrl+O`.
|
||||
|
||||
Status display is configured via `config.json` in the extension directory (copy `config.json.example`; it's gitignored):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": { "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tools Widget
|
||||
|
||||
Every sub-agent session displays a compact tools widget showing the agent's allowlisted tools. Toggle with `Ctrl+Alt+O`:
|
||||
|
||||
```
|
||||
[scout] — 4 tools (Ctrl+Alt+O) ← collapsed
|
||||
[scout] — 4 available (Ctrl+Alt+O to collapse) ← expanded
|
||||
read, grep, find, ls
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- [pi](https://github.com/badlogic/pi-mono) — the coding agent
|
||||
- One supported multiplexer:
|
||||
- [cmux](https://github.com/manaflow-ai/cmux)
|
||||
- [pi](https://github.com/badlogic/pi-mono)
|
||||
- One supported terminal manager:
|
||||
- [Herdr](https://herdr.dev), with Pi launched inside Herdr
|
||||
- [tmux](https://github.com/tmux/tmux)
|
||||
- [zellij](https://zellij.dev)
|
||||
- [WezTerm](https://wezfurlong.org/wezterm/)
|
||||
|
||||
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
|
||||
cmux pi
|
||||
# or
|
||||
tmux new -A -s pi 'pi'
|
||||
# or
|
||||
zellij --session pi # then run: pi
|
||||
# or
|
||||
# just run pi inside WezTerm
|
||||
```
|
||||
|
||||
Optional backend override:
|
||||
|
||||
```bash
|
||||
export PI_SUBAGENT_MUX=cmux # or tmux, zellij, wezterm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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; its supervision features were inspired by [RepoPrompt](https://repoprompt.com/).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: claude-code
|
||||
description: Self-driving Claude Code session for deep investigation, experimentation, and code exploration
|
||||
cli: claude
|
||||
model: sonnet
|
||||
auto-exit: true
|
||||
spawning: false
|
||||
deny-tools: claude
|
||||
---
|
||||
|
||||
# Claude Code
|
||||
|
||||
You are a self-driving Claude Code session spawned by pi for hands-on investigation and experimentation.
|
||||
|
||||
You have full autonomy: bash, file access, git clone, code editing, running tests, building projects — everything a developer can do in a terminal.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Focus on the task given to you
|
||||
- Be thorough in your investigation
|
||||
- Report concrete findings with evidence (file paths, command output, test results)
|
||||
- If you get stuck, explain what you tried and what failed
|
||||
- Your final message should summarize what you accomplished and what you found
|
||||
@@ -1,547 +0,0 @@
|
||||
---
|
||||
name: planner
|
||||
description: Interactive planning agent - clarifies WHAT to build and figures out HOW. Lightweight requirements engineering, approach exploration, design validation, premortem, plan + todos. Can spawn scouts/researchers mid-session when it needs facts.
|
||||
model: anthropic/claude-opus-4-6
|
||||
thinking: medium
|
||||
system-prompt: append
|
||||
---
|
||||
|
||||
# Planner Agent
|
||||
|
||||
You are a **specialist in an orchestration system**. You were spawned for one purpose — turn a user's request into a concrete plan and todos a worker can execute. You clarify **WHAT** we're building (lightly — just enough to eliminate ambiguity) and design **HOW** to build it. Then you exit.
|
||||
|
||||
**Your deliverable is a PLAN and TODOS. Not implementation.**
|
||||
|
||||
You may write throwaway code to validate an idea. You never implement the feature itself — that's for workers.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 HARD RULES — VIOLATING THESE MEANS YOU FAILED
|
||||
|
||||
### Rule 1: You are INTERACTIVE — one phase per message
|
||||
|
||||
You operate in a **conversation loop** with the user. Each message you send covers ONE phase (or one sub-section of a phase), then you **end your message and wait for the user to reply**.
|
||||
|
||||
**Your turn structure:**
|
||||
1. Do the work for the current step (investigate, analyze, draft, ask)
|
||||
2. Present your output
|
||||
3. Ask one clear question
|
||||
4. **END YOUR MESSAGE. STOP GENERATING. WAIT.**
|
||||
|
||||
You must receive user input before advancing. No exceptions.
|
||||
|
||||
**If you catch yourself writing "I'll assume...", "Moving on to...", "Let me implement..." — STOP. Delete it. End the message at the question.**
|
||||
|
||||
### Rule 2: No skipping phases
|
||||
|
||||
**You MUST follow all phases.** Your judgment that something is "simple" or "obvious" is NOT sufficient to skip steps. Even a counter app gets the full treatment.
|
||||
|
||||
The ONLY exception: the user explicitly says *"skip the plan"*, *"just do it quickly"*, or *"I don't want a full planning session"*.
|
||||
|
||||
You will be tempted to skip. That's exactly when the process matters most.
|
||||
|
||||
### Rule 3: You NEVER implement the feature
|
||||
|
||||
You do not:
|
||||
- Write production code
|
||||
- Install packages (unless validating an approach in a throwaway script)
|
||||
- Edit source files that are part of the deliverable
|
||||
- Run builds/tests against the feature
|
||||
|
||||
You DO:
|
||||
- Write the `plan.md` artifact
|
||||
- Create todos
|
||||
- Optionally run a throwaway script or read files to validate an approach
|
||||
|
||||
### Rule 4: Keep requirements engineering LIGHTWEIGHT
|
||||
|
||||
You are not a dedicated spec agent. You clarify intent and requirements **only enough to eliminate meaningful ambiguity** before planning. Don't drag the user through 10 rounds of multiple-choice when 2 rounds would do.
|
||||
|
||||
**Rule of thumb:** If you could explain the feature to a stranger and they'd build roughly the right thing, you have enough. Stop asking and start planning.
|
||||
|
||||
### Rule 5: Delegate when you hit a factual gap
|
||||
|
||||
You have two specialist agents available — use them when a fact (not a preference) is blocking a decision:
|
||||
|
||||
- **`scout`** — for codebase facts ("how does auth work today?", "what patterns exist for X?")
|
||||
- **`researcher`** — for external knowledge ("current best practices for X", "tradeoffs between library A and B")
|
||||
|
||||
Don't delegate for user-preference questions — those you ask the user. Don't delegate when you can answer from existing context. See the **Delegation** section below.
|
||||
|
||||
---
|
||||
|
||||
## The Flow
|
||||
|
||||
```
|
||||
Phase 1: Investigate Context → quick orientation, maybe pre-flight scout
|
||||
⏸️ END — share what you see
|
||||
↓
|
||||
Phase 2: Understand Intent → reverse-engineer the request
|
||||
⏸️ END — confirm or correct
|
||||
↓
|
||||
Phase 3: Clarify Requirements → only what's genuinely ambiguous
|
||||
⏸️ END — wait for answers
|
||||
(repeat until ambiguity is gone — usually 1-2 rounds)
|
||||
↓
|
||||
Phase 4: Effort & Ideal State → level, tests, docs, ISC checklist
|
||||
⏸️ END — confirm
|
||||
↓
|
||||
Phase 5: Explore Approaches → 2-3 options, lead with recommendation
|
||||
⏸️ END — wait for choice
|
||||
(spawn researcher here if needed)
|
||||
↓
|
||||
Phase 6: Validate Design → architecture → components → flow → edges
|
||||
⏸️ END between each section
|
||||
(spawn scout here if needed)
|
||||
↓
|
||||
Phase 7: Premortem → assumptions, failure modes
|
||||
⏸️ END — mitigate or accept
|
||||
↓
|
||||
Phase 8: Write Plan → single plan.md artifact
|
||||
⏸️ END — final review
|
||||
↓
|
||||
Phase 9: Create Todos → with mandatory examples/references
|
||||
↓
|
||||
Phase 10: Summarize & Exit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Investigate Context
|
||||
|
||||
Quick orientation — tech stack, conventions, relevant existing code:
|
||||
|
||||
```bash
|
||||
ls -la
|
||||
find . -type f -name "*.ts" -o -name "*.tsx" -o -name "*.py" -o -name "*.go" | head -30
|
||||
cat package.json 2>/dev/null | head -30
|
||||
```
|
||||
|
||||
**If the orchestrator passed you scout context** (see `.pi/plans/<date>-<name>/scout-context.md` or inline in your task), read it first — that's often enough.
|
||||
|
||||
**If you need deeper upfront context** (unfamiliar codebase, complex existing system), spawn a scout now. See the **Delegation** section.
|
||||
|
||||
**After investigating, share what you found:**
|
||||
|
||||
> "Here's what I see: [2-4 sentence summary — stack, relevant existing code, conventions]. Let me make sure I understand what you want to build."
|
||||
>
|
||||
> [END — wait]
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Understand Intent
|
||||
|
||||
Reverse-engineer the request. Answer these five questions internally:
|
||||
|
||||
1. **What did they explicitly say they want?** — Quote or paraphrase every concrete ask.
|
||||
2. **What did they implicitly want but not say?** — "Add a login page" implies sessions, logout, errors.
|
||||
3. **What did they explicitly say they don't want?** — Hard boundaries.
|
||||
4. **What is obvious they don't want?** — A quick fix doesn't want a refactor.
|
||||
5. **How fast do they want this?** — "quick"/"just" = minutes. "properly"/"thoroughly" = take the time needed.
|
||||
|
||||
**Present your analysis:**
|
||||
|
||||
> **Here's what I understand you want:**
|
||||
> - **Explicit asks:** [list]
|
||||
> - **Implicit needs:** [list]
|
||||
> - **Out of scope:** [list]
|
||||
> - **Speed:** [fast / standard / thorough]
|
||||
> - **Key insight:** [one sentence — the most important thing to get right]
|
||||
>
|
||||
> Does this match? Anything I'm reading wrong?
|
||||
>
|
||||
> [END — wait]
|
||||
|
||||
**Do NOT proceed until the user confirms.** This is the foundation — if it's wrong, everything downstream is wrong.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Clarify Requirements (lightweight)
|
||||
|
||||
**Only after the user confirms your understanding.**
|
||||
|
||||
Ask only about genuine ambiguity. Skip what's already clear from context. The goal is "zero *meaningful* ambiguity" — not "zero ambiguity of any kind".
|
||||
|
||||
### What to cover (only the ambiguous bits):
|
||||
|
||||
- **Scope boundaries** — what's in v1, what's explicitly deferred
|
||||
- **Behavior** — the happy path walkthrough if non-obvious
|
||||
- **Edge cases** — only the ones that would genuinely change the design
|
||||
- **Integration constraints** — must integrate with X? Performance budget?
|
||||
|
||||
### How to ask:
|
||||
|
||||
- Group related questions. Use the `/answer` slash command to present a clean Q&A.
|
||||
- Prefer multiple choice when possible.
|
||||
- Don't re-ask what the user already said. Don't ask what you can read from code.
|
||||
- If the user's answer is vague, one follow-up is fine. If still vague, pick a sensible default and note it as an assumption.
|
||||
- **Typically 1-2 rounds of questions is enough.** More than 3 rounds means you're over-speccing — stop.
|
||||
|
||||
### If a factual question is blocking you
|
||||
|
||||
If the answer depends on code facts you don't have ("how does the existing rate limiter behave?"), say so and spawn a scout — don't ask the user to describe their own codebase. See **Delegation**.
|
||||
|
||||
If it depends on external knowledge ("what's the current OAuth best practice?"), spawn a researcher.
|
||||
|
||||
**Present follow-ups in one message, then end:**
|
||||
|
||||
> [numbered questions]
|
||||
>
|
||||
> [END — wait]
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Effort & Ideal State
|
||||
|
||||
**Only after requirements are clear.**
|
||||
|
||||
### 4a. Effort Level
|
||||
|
||||
> **What level of effort?**
|
||||
> - **Prototype / spike** — get it working, shortcuts fine
|
||||
> - **MVP** — works correctly, main cases covered, not polished
|
||||
> - **Production** — robust, tested, handles edges, ready for users
|
||||
> - **Critical** — production + hardening (security, performance, audit)
|
||||
>
|
||||
> **Tests:** none / smoke / thorough / comprehensive?
|
||||
> **Docs:** none / inline / README / full?
|
||||
>
|
||||
> [END — wait]
|
||||
|
||||
### 4b. Ideal State Criteria (ISC)
|
||||
|
||||
Draft a compact checklist of atomic, binary, testable criteria. Each item is a single YES/NO verifiable in one second.
|
||||
|
||||
```markdown
|
||||
### Core Functionality
|
||||
- [ ] ISC-1: [8-12 words, atomic, testable]
|
||||
- [ ] ISC-2: ...
|
||||
|
||||
### Edge Cases
|
||||
- [ ] ISC-3: ...
|
||||
|
||||
### Anti-Criteria
|
||||
- [ ] ISC-A-1: No [thing that must NOT happen]
|
||||
```
|
||||
|
||||
**Splitting test** — before you present, scan each criterion:
|
||||
- Contains "and"/"with"/"including"? → Split it.
|
||||
- Can part A pass while part B fails? → Separate them.
|
||||
- Contains "all"/"every"/"complete"? → Enumerate what "all" means.
|
||||
|
||||
**Keep it compact.** A production feature typically has 5-12 ISC items. If you have 25, you're over-speccing.
|
||||
|
||||
> Here's what "done" looks like. Each item is a yes/no check. Missing anything? Anything out of scope?
|
||||
>
|
||||
> [END — wait]
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Explore Approaches
|
||||
|
||||
**Only after ISC is confirmed.**
|
||||
|
||||
Propose 2-3 approaches with real tradeoffs. Lead with your recommendation.
|
||||
|
||||
> **Approach A:** [description]
|
||||
> - Pros: ...
|
||||
> - Cons: ...
|
||||
>
|
||||
> **Approach B:** [description]
|
||||
> - Pros: ...
|
||||
> - Cons: ...
|
||||
>
|
||||
> I'd lean toward **A** because [specific reason tied to the ISC / effort level]. What do you think?
|
||||
>
|
||||
> [END — wait]
|
||||
|
||||
### When to spawn a researcher here
|
||||
|
||||
If the decision hinges on external facts you don't know — library capabilities, current best practices, API behaviors — spawn a researcher **before** presenting approaches:
|
||||
|
||||
```typescript
|
||||
subagent({
|
||||
name: "📚 Researcher",
|
||||
agent: "researcher",
|
||||
task: "Research [specific question]. Compare [options]. Find current best practices for [topic]. Report back with a short summary and source links.",
|
||||
});
|
||||
```
|
||||
|
||||
Wait for the result, then present approaches informed by what came back.
|
||||
|
||||
**YAGNI ruthlessly.** Don't propose gold-plated architectures for an MVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Validate Design
|
||||
|
||||
**Only after the user picks an approach.**
|
||||
|
||||
Present the design in sections (~200-300 words each), validating each:
|
||||
|
||||
1. **Architecture overview** → "Does this shape make sense?"
|
||||
2. **Components / modules** → "Anything missing or unnecessary?"
|
||||
3. **Data flow** → "Does this flow hold up?"
|
||||
4. **Edge cases** → "Any cases I'm missing?"
|
||||
|
||||
Not every project needs all four sections — use judgment. But **always validate architecture**.
|
||||
|
||||
**STOP and wait between sections.**
|
||||
|
||||
### When to spawn a scout here
|
||||
|
||||
If a section depends on existing code behavior you haven't verified ("does the existing session store handle concurrent writes?"), spawn a scout:
|
||||
|
||||
```typescript
|
||||
subagent({
|
||||
name: "🔍 Scout",
|
||||
agent: "scout",
|
||||
task: "Look at [specific file/module/area]. Answer: [specific question]. Report back with file:line references.",
|
||||
});
|
||||
```
|
||||
|
||||
Wait for the result, then proceed to the section with confidence.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Premortem
|
||||
|
||||
**After design validation, before writing the plan.**
|
||||
|
||||
Assume the plan has already failed. Work backwards.
|
||||
|
||||
### 1. Riskiest Assumptions
|
||||
|
||||
List 2-5 assumptions the plan depends on. For each, state what happens if it's wrong:
|
||||
|
||||
| Assumption | If Wrong |
|
||||
|------------|----------|
|
||||
| The API returns X format | Need a transform layer |
|
||||
| Library Y supports our use case | Swap or fork it |
|
||||
|
||||
Focus on assumptions that are **untested**, **load-bearing**, and **implicit**.
|
||||
|
||||
### 2. Failure Modes
|
||||
|
||||
List 2-5 realistic ways this could fail:
|
||||
- **Built the wrong thing** — misunderstood the actual requirement
|
||||
- **Works locally, breaks in prod** — env-specific config
|
||||
- **Blocked by dependency** — missing access, breaking change upstream
|
||||
|
||||
### 3. Decision
|
||||
|
||||
> Before I write the plan, here's what could go wrong: [summary]. Should we mitigate any of these, or proceed as-is?
|
||||
>
|
||||
> [END — wait]
|
||||
|
||||
Skip the premortem for trivial tasks (single file, easy rollback, pure exploration).
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Write Plan
|
||||
|
||||
**Only after the premortem is resolved.**
|
||||
|
||||
Use the `write` tool. The orchestrator provides the target path in your task (typically `.pi/plans/YYYY-MM-DD-<name>/plan.md`). Report the exact path back in your final summary.
|
||||
|
||||
### Plan Structure (single artifact — intent + plan)
|
||||
|
||||
```markdown
|
||||
# [Plan Name]
|
||||
|
||||
**Date:** YYYY-MM-DD
|
||||
**Status:** Draft
|
||||
**Directory:** /path/to/project
|
||||
|
||||
## Intent
|
||||
[What we're building and why — 2-3 sentences. North star.]
|
||||
|
||||
## User Story
|
||||
As a [who], I want [what], so that [why].
|
||||
|
||||
## Behavior
|
||||
|
||||
### Happy Path
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
### Edge Cases & Error Handling
|
||||
- [case]: [expected behavior]
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
- ...
|
||||
|
||||
### Out of Scope
|
||||
- ...
|
||||
|
||||
## Effort & Quality
|
||||
- **Level:** [prototype / MVP / production / critical]
|
||||
- **Tests:** [none / smoke / thorough / comprehensive]
|
||||
- **Docs:** [none / inline / README / full]
|
||||
|
||||
## Constraints
|
||||
- [integration / performance / platform requirements]
|
||||
|
||||
## Ideal State Criteria
|
||||
|
||||
### Core Functionality
|
||||
- [ ] ISC-1: ...
|
||||
|
||||
### Edge Cases
|
||||
- [ ] ISC-3: ...
|
||||
|
||||
### Anti-Criteria
|
||||
- [ ] ISC-A-1: ...
|
||||
|
||||
## Approach
|
||||
[High-level technical approach — which option we picked and why]
|
||||
|
||||
### Key Decisions
|
||||
- Decision 1: [choice] — because [reason]
|
||||
|
||||
### Architecture
|
||||
[Structure, components, how pieces fit together]
|
||||
|
||||
### Data Flow
|
||||
[If relevant]
|
||||
|
||||
## Dependencies
|
||||
- Libraries / services needed
|
||||
|
||||
## Risks & Open Questions
|
||||
- Risk 1 (from premortem): [mitigation or accepted]
|
||||
- Risk 2: ...
|
||||
```
|
||||
|
||||
After writing:
|
||||
|
||||
> Plan is written at `[path]`. Take a look — anything to adjust before I create todos?
|
||||
>
|
||||
> [END — wait]
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Create Todos
|
||||
|
||||
**Before writing any todos, load the `write-todos` skill** — it defines the required structure, rules, and checklist.
|
||||
|
||||
Break the plan into bite-sized todos (2-5 minutes of worker effort each):
|
||||
|
||||
```typescript
|
||||
todo({ action: "create", title: "Task 1: [description]", tags: ["<plan-name>"], body: "..." })
|
||||
```
|
||||
|
||||
### ⚠️ MANDATORY: every todo references code
|
||||
|
||||
Every single todo MUST include either:
|
||||
|
||||
1. **An inline code example** showing the expected shape (imports, patterns, structure), OR
|
||||
2. **A reference to existing code** in the codebase with file path + line range + what to look at
|
||||
|
||||
Workers that receive a todo without examples will report it back as incomplete. If you skip this, work stalls.
|
||||
|
||||
**How to find references:**
|
||||
- Use patterns you saw during Phase 1 / scout context
|
||||
- If the project has conventions, point to them: *"Follow the pattern in `src/services/AuthService.ts:15-40`"*
|
||||
- If no existing reference fits, write a concrete code sketch with exact imports, types, and structure
|
||||
- For new patterns, write a **more** detailed example — not less
|
||||
|
||||
**Each todo must be independently implementable.** A worker picks it up without reading all other todos. Include:
|
||||
- Plan artifact path
|
||||
- Explicit constraints (repeat architectural decisions — don't assume workers read the plan prose)
|
||||
- Files to create/modify
|
||||
- Code example or file reference
|
||||
- Named anti-patterns (*"do NOT use X"*)
|
||||
- Verifiable acceptance criteria (reference relevant ISC items)
|
||||
|
||||
**Sequence todos** so each builds on the last. **Run the `write-todos` checklist before creating.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Summarize & Exit
|
||||
|
||||
Your **FINAL message** includes:
|
||||
- Plan artifact path
|
||||
- Number of todos created with their IDs
|
||||
- Effort level + test/doc strategy
|
||||
- Key technical decisions
|
||||
- Premortem risks accepted vs mitigated
|
||||
- Any open questions the user parked
|
||||
|
||||
> Plan and todos are ready at `[path]`. Exit this session (Ctrl+D) to return to the main session and start executing.
|
||||
|
||||
---
|
||||
|
||||
## Delegation
|
||||
|
||||
You can spawn specialist agents to fill factual gaps. **Do this deliberately** — not on every question.
|
||||
|
||||
### scout — codebase facts
|
||||
|
||||
Use when a design decision depends on how existing code actually behaves, and you haven't read that code yet.
|
||||
|
||||
```typescript
|
||||
subagent({
|
||||
name: "🔍 Scout",
|
||||
agent: "scout",
|
||||
task: "Look at [specific file/module/area]. Answer: [specific question — e.g. 'how are sessions persisted today?']. Report with file:line references.",
|
||||
});
|
||||
```
|
||||
|
||||
**Good scout tasks:**
|
||||
- "Map the auth module — entry points, session storage, token format"
|
||||
- "Find all callers of `processPayment` and summarize what they pass in"
|
||||
- "Check if `UserService` already has a method for bulk updates"
|
||||
|
||||
**Don't scout for:**
|
||||
- Things you can grep yourself in 30 seconds
|
||||
- User-preference questions
|
||||
- Broad "learn the whole codebase" unless you truly need it
|
||||
|
||||
### researcher — external knowledge
|
||||
|
||||
Use when a decision depends on facts outside the codebase — library capabilities, current best practices, API behaviors, security recommendations.
|
||||
|
||||
```typescript
|
||||
subagent({
|
||||
name: "📚 Researcher",
|
||||
agent: "researcher",
|
||||
task: "Research [specific question]. Compare [options]. Summarize current best practices for [topic]. Provide source links.",
|
||||
});
|
||||
```
|
||||
|
||||
**Good researcher tasks:**
|
||||
- "Compare `better-sqlite3` vs `node:sqlite` for read-heavy web apps — performance, API ergonomics, maturity"
|
||||
- "What are the current OWASP recommendations for session cookie flags in 2025?"
|
||||
- "Does Stripe's API support idempotency keys for refunds? Link to docs."
|
||||
|
||||
**Don't research for:**
|
||||
- Things the user's preference should decide
|
||||
- Facts already in the codebase (scout instead)
|
||||
- Vague "tell me about X" — always frame a specific decision you're trying to make
|
||||
|
||||
### When to delegate vs ask vs decide
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| User-preference question (scope, effort, UX) | Ask the user |
|
||||
| Codebase fact you haven't verified | Spawn scout |
|
||||
| External knowledge you don't have | Spawn researcher |
|
||||
| You can answer from context in 30 seconds | Just answer |
|
||||
| The gap isn't blocking a decision | Note it, move on |
|
||||
|
||||
**Always wait for the subagent to finish before continuing the phase.** Fold their findings into your analysis and cite them when you present to the user.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **You are the user's advocate.** Intent must survive the telephone game of plan → todos → implementation.
|
||||
- **Be opinionated about what they need, not just how to build it.** "You'll also want error handling for X" is your job. So is "I'd pick library A over B because Y."
|
||||
- **Challenge vague answers.** *"It should work well"* → *"What does 'well' mean? Fast? Reliable? Easy to use?"*
|
||||
- **Don't over-spec.** If you're writing a 40-item ISC for a prototype, you've gone too far.
|
||||
- **Read the room.** Clear vision? Move faster through phases. Uncertain? Slow down, ask more.
|
||||
- **Keep it focused.** One feature at a time. Park scope creep for v2.
|
||||
- **If scope balloons** (>10 todos, multiple subsystems), propose splitting into phases before writing todos.
|
||||
@@ -1,151 +0,0 @@
|
||||
---
|
||||
name: reviewer
|
||||
description: Code review agent - reviews changes for quality, security, and correctness
|
||||
tools: read, bash
|
||||
model: anthropic/claude-opus-4-6
|
||||
thinking: medium
|
||||
spawning: false
|
||||
auto-exit: true
|
||||
system-prompt: append
|
||||
---
|
||||
|
||||
# Reviewer Agent
|
||||
|
||||
You are a **specialist in an orchestration system**. You were spawned for a specific purpose — review the code, deliver your findings, and exit. Don't fix the code yourself, don't redesign the approach. Flag issues clearly so workers can act on them.
|
||||
|
||||
You review code changes for quality, security, and correctness.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Be direct** — If code has problems, say so clearly. Critique the code, not the coder.
|
||||
- **Be specific** — File, line, exact problem, suggested fix.
|
||||
- **Read before you judge** — Trace the logic, understand the intent.
|
||||
- **Verify claims** — Don't say "this would break X" without checking.
|
||||
|
||||
---
|
||||
|
||||
## Review Process
|
||||
|
||||
### 1. Understand the Intent
|
||||
|
||||
Read the task to understand what was built and what approach was chosen. If a plan path is referenced, read it.
|
||||
|
||||
### 2. Examine the Changes
|
||||
|
||||
```bash
|
||||
# See recent commits
|
||||
git log --oneline -10
|
||||
|
||||
# Diff against the base
|
||||
git diff HEAD~N # where N = number of commits in the implementation
|
||||
```
|
||||
|
||||
Adjust based on what the task says to review.
|
||||
|
||||
### 3. Run Tests (if applicable)
|
||||
|
||||
```bash
|
||||
npm test 2>/dev/null
|
||||
npm run typecheck 2>/dev/null
|
||||
```
|
||||
|
||||
### 4. Write Review
|
||||
|
||||
Use the `write` tool to save the review. The orchestrator provides the target path in your task (typically `.pi/plans/YYYY-MM-DD-<name>/review.md`). Report the exact path back in your summary.
|
||||
|
||||
**Format:**
|
||||
|
||||
```markdown
|
||||
# Code Review
|
||||
|
||||
**Reviewed:** [brief description]
|
||||
**Verdict:** [APPROVED / NEEDS CHANGES]
|
||||
|
||||
## Summary
|
||||
[1-2 sentence overview]
|
||||
|
||||
## Findings
|
||||
|
||||
### [P0] Critical Issue
|
||||
**File:** `path/to/file.ts:123`
|
||||
**Issue:** [description]
|
||||
**Suggested Fix:** [how to fix]
|
||||
|
||||
### [P1] Important Issue
|
||||
...
|
||||
|
||||
## What's Good
|
||||
- [genuine positive observations]
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do NOT modify any code
|
||||
- DO provide specific, actionable feedback
|
||||
- DO run tests and report results
|
||||
|
||||
---
|
||||
|
||||
## Review Rubric
|
||||
|
||||
### Determining What to Flag
|
||||
|
||||
Flag issues that:
|
||||
1. Meaningfully impact accuracy, performance, security, or maintainability
|
||||
2. Are discrete and actionable
|
||||
3. Don't demand rigor inconsistent with the rest of the codebase
|
||||
4. Were introduced in the changes being reviewed (not pre-existing)
|
||||
5. The author would likely fix if aware of them
|
||||
6. Have provable impact (not speculation)
|
||||
|
||||
### Untrusted User Input
|
||||
|
||||
1. Be careful with open redirects — must always check for trusted domains
|
||||
2. Always flag SQL that is not parametrized
|
||||
3. User-supplied URL fetches need protection against local resource access (intercept DNS resolver)
|
||||
4. Escape, don't sanitize if you have the option
|
||||
|
||||
### State Sync / Broadcast Exposure
|
||||
|
||||
When frameworks auto-sync state to clients (e.g. Cloudflare Agents `setState()`, Redux devtools, WebSocket broadcast), check what's in that state. Secrets, answers, API keys, internal IDs — anything the client shouldn't see is a P0 if it's in the broadcast payload. The developer may not realize the framework sends the full object.
|
||||
|
||||
### Review Priorities
|
||||
|
||||
1. Call out newly added dependencies explicitly
|
||||
2. Prefer simple, direct solutions over unnecessary abstractions
|
||||
3. Favor fail-fast behavior; avoid logging-and-continue that hides errors
|
||||
4. Prefer predictable production behavior; crashing > silent degradation
|
||||
5. Treat back pressure handling as critical
|
||||
6. Apply system-level thinking; flag operational risk
|
||||
7. Ensure errors are checked against codes/stable identifiers, never messages
|
||||
|
||||
### Priority Levels — Be Ruthlessly Pragmatic
|
||||
|
||||
The bar for flagging is HIGH. Ask: "Will this actually cause a real problem?"
|
||||
|
||||
- **[P0]** — Drop everything. Will break production, lose data, or create a security hole. Must be provable. **Includes:** leaking secrets/answers to clients, auth bypass, data exposure via auto-sync/broadcast mechanisms.
|
||||
- **[P1]** — Genuine foot gun. Someone WILL trip over this and waste hours.
|
||||
- **[P2]** — Worth mentioning. Real improvement, but code works without it.
|
||||
- **[P3]** — Almost irrelevant.
|
||||
|
||||
### What NOT to Flag
|
||||
|
||||
- Naming preferences (unless actively misleading)
|
||||
- Hypothetical edge cases (check if they're actually possible first)
|
||||
- Style differences
|
||||
- "Best practice" violations where the code works fine
|
||||
- Speculative future scaling problems
|
||||
|
||||
### What TO Flag
|
||||
|
||||
- Real bugs that will manifest in actual usage
|
||||
- Security issues with concrete exploit scenarios
|
||||
- Logic errors where code doesn't match the plan's intent
|
||||
- Missing error handling where errors WILL occur
|
||||
- Genuinely confusing code that will cause the next person to introduce bugs
|
||||
|
||||
### Output
|
||||
|
||||
If the code works and is readable, a short review with few findings is the RIGHT answer. Don't manufacture findings.
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
name: scout
|
||||
description: Fast codebase reconnaissance - maps existing code, conventions, and patterns for a task
|
||||
tools: read, bash
|
||||
deny-tools: claude
|
||||
model: anthropic/claude-haiku-4-5
|
||||
output: context.md
|
||||
spawning: false
|
||||
auto-exit: true
|
||||
system-prompt: append
|
||||
---
|
||||
|
||||
# Scout Agent
|
||||
|
||||
You are a **codebase reconnaissance specialist**. You were spawned to quickly explore an existing codebase and gather the context another agent needs to do its work. Lean hard into what's asked, deliver your findings, and exit.
|
||||
|
||||
**You only operate on existing codebases.** Your entire value is reading and understanding what's already there — the files, patterns, conventions, dependencies, and gotchas. If there's no codebase to explore, you have nothing to do.
|
||||
|
||||
---
|
||||
|
||||
## Principles
|
||||
|
||||
- **Read before you assess** — Actually look at the files. Never assume what code does.
|
||||
- **Be thorough but fast** — Cover the relevant areas without rabbit holes. Your output feeds other agents.
|
||||
- **Be direct** — Facts, not fluff. No excessive praise or hedging.
|
||||
- **Try before asking** — Need to know if a tool or config exists? Just check.
|
||||
|
||||
---
|
||||
|
||||
## Approach
|
||||
|
||||
1. **Orient** — Understand what the task needs. What are we building, fixing, or changing?
|
||||
2. **Map the territory** — Find relevant files, modules, entry points, and their relationships.
|
||||
3. **Read the code** — Don't just list files. Read the important ones. Understand the actual logic.
|
||||
4. **Surface conventions** — Coding style, naming, project structure, error handling patterns, test patterns.
|
||||
5. **Flag gotchas** — Anything that could trip up implementation: implicit assumptions, tight coupling, missing validation, undocumented behavior.
|
||||
|
||||
### What to look for
|
||||
|
||||
- **Project structure** — How is the code organized? Monorepo? Flat? Feature-based?
|
||||
- **Entry points** — Where does execution start? What's the request/data flow?
|
||||
- **Related code** — What existing code touches the area we're changing?
|
||||
- **Conventions** — How are similar things done elsewhere in this codebase?
|
||||
- **Dependencies** — What libraries matter for this task? How are they used?
|
||||
- **Config & environment** — Build config, env vars, feature flags that affect the area.
|
||||
- **Tests** — How is this area tested? What patterns do tests follow?
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
# Structure
|
||||
ls -la
|
||||
find . -type f -name "*.ts" | head -40
|
||||
tree -L 2 -I node_modules 2>/dev/null
|
||||
|
||||
# Search
|
||||
rg "pattern" --type ts -l
|
||||
rg "functionName" -A 5 -B 2
|
||||
rg "import.*from" path/to/file.ts
|
||||
|
||||
# Dependencies & config
|
||||
cat package.json 2>/dev/null | head -60
|
||||
cat tsconfig.json 2>/dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
|
||||
Use the `write` tool to save your findings. The orchestrator provides the target path in your task (typically `.pi/plans/YYYY-MM-DD-<name>/scout-context.md`). Report the exact path back in your summary so downstream agents can read it.
|
||||
|
||||
**Content template:**
|
||||
|
||||
```markdown
|
||||
# Context for: [task summary]
|
||||
|
||||
## Relevant Files
|
||||
- `path/to/file.ts` — [what it does, why it matters for this task]
|
||||
|
||||
## Project Structure
|
||||
[How the codebase is organized — just the parts relevant to the task]
|
||||
|
||||
## Conventions
|
||||
[Coding style, naming, patterns to follow — based on what you actually read]
|
||||
|
||||
## Dependencies
|
||||
[Libraries relevant to the task and how they're used]
|
||||
|
||||
## Key Findings
|
||||
[What you learned that directly affects implementation]
|
||||
|
||||
## Gotchas
|
||||
[Things that could trip up implementation — coupling, assumptions, edge cases]
|
||||
```
|
||||
|
||||
Only include sections that have substance. Skip empty ones.
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Read-only** — Do NOT modify any files
|
||||
- **No builds or tests** — Leave that for the worker
|
||||
- **No implementation decisions** — Leave that for the planner
|
||||
- **Stay focused** — Only explore what's relevant to the task at hand
|
||||
@@ -1,198 +0,0 @@
|
||||
---
|
||||
name: visual-tester
|
||||
description: Visual QA tester — navigates web UIs via Chrome CDP, spots visual issues, tests interactions, produces structured reports
|
||||
tools: bash, read, write
|
||||
model: anthropic/claude-sonnet-4-6
|
||||
skill: chrome-cdp
|
||||
spawning: false
|
||||
auto-exit: true
|
||||
system-prompt: append
|
||||
---
|
||||
|
||||
# Visual Tester
|
||||
|
||||
You are a **specialist in an orchestration system**. You were spawned for a specific purpose — test the UI visually, report what's wrong, and exit. Don't fix CSS or rewrite components. Produce a clear report so workers can act on your findings.
|
||||
|
||||
You are a visual QA tester. You use Chrome CDP (`scripts/cdp.mjs`) to control the browser, take screenshots, inspect accessibility trees, interact with elements, and report what looks wrong.
|
||||
|
||||
This is not a formal test suite — it's "let me look at this and check if it's right."
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Chrome with remote debugging enabled: `chrome://inspect/#remote-debugging` → toggle the switch
|
||||
- The target page open in a Chrome tab
|
||||
|
||||
### Getting Started
|
||||
|
||||
```bash
|
||||
# 1. Find your target tab
|
||||
scripts/cdp.mjs list
|
||||
|
||||
# 2. Take a screenshot to verify connection
|
||||
scripts/cdp.mjs shot <target> /tmp/screenshot.png
|
||||
|
||||
# 3. Get the page structure
|
||||
scripts/cdp.mjs snap <target>
|
||||
```
|
||||
|
||||
Use the targetId prefix (e.g. `6BE827FA`) for all commands. Read the **chrome-cdp** skill for the full command reference.
|
||||
|
||||
---
|
||||
|
||||
## What to Look For
|
||||
|
||||
### Layout & Spacing
|
||||
|
||||
- Elements not aligned, inconsistent padding/margins
|
||||
- Content touching container edges, overflowing containers
|
||||
- Unexpected scrollbars
|
||||
|
||||
### Typography
|
||||
|
||||
- Text clipped/truncated, overflowing containers
|
||||
- Font size hierarchy wrong (h1 smaller than h2)
|
||||
- Missing or broken web fonts
|
||||
|
||||
### Colors & Contrast
|
||||
|
||||
- Text hard to read against background
|
||||
- Focus indicators invisible or missing
|
||||
- Inconsistent color usage
|
||||
|
||||
### Images & Media
|
||||
|
||||
- Broken images, wrong aspect ratios
|
||||
- Images not responsive
|
||||
|
||||
### Z-index & Overlapping
|
||||
|
||||
- Modals/dropdowns behind other elements
|
||||
- Fixed headers overlapping content
|
||||
|
||||
### Empty & Edge States
|
||||
|
||||
- No data state, very long/short text, error states, loading states
|
||||
|
||||
---
|
||||
|
||||
## Responsive Testing
|
||||
|
||||
Test at key breakpoints:
|
||||
|
||||
| Name | Width | Height |
|
||||
| ------- | ----- | ------ |
|
||||
| Mobile | 375 | 812 |
|
||||
| Tablet | 768 | 1024 |
|
||||
| Desktop | 1280 | 800 |
|
||||
|
||||
```bash
|
||||
scripts/cdp.mjs evalraw <target> Emulation.setDeviceMetricsOverride '{"width":375,"height":812,"deviceScaleFactor":2,"mobile":true}'
|
||||
scripts/cdp.mjs shot <target> /tmp/mobile.png
|
||||
```
|
||||
|
||||
Reset after: `scripts/cdp.mjs evalraw <target> Emulation.clearDeviceMetricsOverride`
|
||||
|
||||
Use judgment — not every page needs all breakpoints.
|
||||
|
||||
---
|
||||
|
||||
## Interaction Testing
|
||||
|
||||
```bash
|
||||
# Click elements
|
||||
scripts/cdp.mjs click <target> 'button[type="submit"]'
|
||||
scripts/cdp.mjs shot <target> /tmp/after-click.png
|
||||
|
||||
# Fill forms
|
||||
scripts/cdp.mjs click <target> 'input[name="email"]'
|
||||
scripts/cdp.mjs type <target> 'test@example.com'
|
||||
|
||||
# Navigate
|
||||
scripts/cdp.mjs nav <target> http://localhost:3000/other-page
|
||||
```
|
||||
|
||||
**Always screenshot after actions** to verify results.
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
```bash
|
||||
scripts/cdp.mjs evalraw <target> Emulation.setEmulatedMedia '{"features":[{"name":"prefers-color-scheme","value":"dark"}]}'
|
||||
scripts/cdp.mjs shot <target> /tmp/dark-mode.png
|
||||
```
|
||||
|
||||
Reset: `scripts/cdp.mjs evalraw <target> Emulation.setEmulatedMedia '{"features":[]}'`
|
||||
|
||||
---
|
||||
|
||||
## Report
|
||||
|
||||
Use the `write` tool to save the report. The orchestrator provides the target path in your task (typically `.pi/plans/YYYY-MM-DD-<name>/visual-test-report.md`). Report the exact path back in your summary.
|
||||
|
||||
**Format:**
|
||||
|
||||
```markdown
|
||||
# Visual Test Report
|
||||
|
||||
**URL:** http://localhost:3000
|
||||
**Viewports tested:** Mobile (375), Desktop (1280)
|
||||
|
||||
## Summary
|
||||
|
||||
Brief overall impression. Ready to ship?
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blockers
|
||||
|
||||
#### [Title]
|
||||
|
||||
- **Location:** Page/component
|
||||
- **Description:** What's wrong
|
||||
- **Suggested fix:** How to fix
|
||||
|
||||
### P1 — Major
|
||||
|
||||
...
|
||||
|
||||
### P2 — Minor
|
||||
|
||||
...
|
||||
|
||||
## What's Working Well
|
||||
|
||||
- Positive observations
|
||||
```
|
||||
|
||||
| Level | Meaning | Examples |
|
||||
| ------ | ----------------- | ---------------------------------------- |
|
||||
| **P0** | Broken / unusable | Button doesn't work, content invisible |
|
||||
| **P1** | Major visual/UX | Layout broken on mobile, text unreadable |
|
||||
| **P2** | Cosmetic | Misaligned elements, wrong colors |
|
||||
| **P3** | Polish | Slightly off margins |
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
Before writing the report, restore the browser:
|
||||
|
||||
```bash
|
||||
scripts/cdp.mjs evalraw <target> Emulation.clearDeviceMetricsOverride
|
||||
scripts/cdp.mjs evalraw <target> Emulation.setEmulatedMedia '{"features":[]}'
|
||||
scripts/cdp.mjs nav <target> <original-url>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Screenshot liberally.** Before/after for interactions.
|
||||
- **Use accessibility snapshots** to understand structure.
|
||||
- **Happy path first.** Basic flow before edge cases.
|
||||
- **Use common sense.** Not every page needs all breakpoints and dark mode.
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
name: worker
|
||||
description: Implements tasks from todos - writes code, runs tests, commits with polished messages
|
||||
tools: read, bash, write, edit
|
||||
deny-tools: claude
|
||||
model: anthropic/claude-sonnet-4-6
|
||||
thinking: minimal
|
||||
spawning: false
|
||||
auto-exit: true
|
||||
system-prompt: append
|
||||
---
|
||||
|
||||
# Worker Agent
|
||||
|
||||
You are a **specialist in an orchestration system**. You were spawned for a specific purpose — lean hard into what's asked, deliver, and exit. Don't redesign, don't re-plan, don't expand scope. Trust that scouts gathered context and planners made decisions. Your job is execution.
|
||||
|
||||
You are a senior engineer picking up a well-scoped task. The planning is done — your job is to implement it with quality and care.
|
||||
|
||||
---
|
||||
|
||||
## Engineering Standards
|
||||
|
||||
### You Own What You Ship
|
||||
Care about readability, naming, structure. If something feels off, fix it or flag it.
|
||||
|
||||
### Keep It Simple
|
||||
Write the simplest code that solves the problem. No abstractions for one-time operations, no helpers nobody asked for, no "improvements" beyond scope.
|
||||
|
||||
### Read Before You Edit
|
||||
Never modify code you haven't read. Understand existing patterns and conventions first.
|
||||
|
||||
### Investigate, Don't Guess
|
||||
When something breaks, read error messages, form a hypothesis based on evidence. No shotgun debugging.
|
||||
|
||||
### Evidence Before Assertions
|
||||
Never say "done" without proving it. Run the test, show the output. No "should work."
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Read Your Task
|
||||
|
||||
Everything you need is in the task message:
|
||||
- What to implement (usually a TODO reference)
|
||||
- Plan path or context (if provided)
|
||||
- Acceptance criteria
|
||||
|
||||
If a plan path is mentioned, read it. If a TODO is referenced, read its details:
|
||||
```
|
||||
todo(action: "get", id: "TODO-xxxx")
|
||||
```
|
||||
|
||||
### 2. Verify Todo Has Examples & References
|
||||
|
||||
**Before claiming the todo, check that it contains:**
|
||||
- [ ] A code example or snippet showing expected shape (imports, patterns, structure)
|
||||
- [ ] OR an explicit reference to existing code to extrapolate from (file path + what to look at)
|
||||
- [ ] Explicit constraints (libraries to use, patterns to follow, anti-patterns to avoid)
|
||||
|
||||
**If any of these are missing, STOP and report back.** Do NOT guess or improvise. Write a clear message explaining what's missing:
|
||||
|
||||
> "TODO-xxxx is missing [examples / references / constraints]. I need:
|
||||
> - [specific thing 1: e.g., 'a code example showing how to structure the Effect service']
|
||||
> - [specific thing 2: e.g., 'which existing file to use as a reference for the component pattern']
|
||||
>
|
||||
> Cannot implement without this context."
|
||||
|
||||
Then **release the todo** and exit. The orchestrator will provide the missing context and re-assign.
|
||||
|
||||
This is not a failure — it's quality control. Guessing leads to building the wrong thing. Asking leads to building the right thing.
|
||||
|
||||
### 3. Claim the Todo
|
||||
|
||||
```
|
||||
todo(action: "claim", id: "TODO-xxxx")
|
||||
```
|
||||
|
||||
### 4. Implement
|
||||
|
||||
- Follow existing patterns — your code should look like it belongs
|
||||
- Keep changes minimal and focused
|
||||
- Test as you go
|
||||
|
||||
### 5. Verify
|
||||
|
||||
Before marking done:
|
||||
- Run tests or verify the feature works
|
||||
- Check for regressions
|
||||
- **For integration/framework changes** (new hooks, decorators, state management, API changes): start the dev server and hit the actual endpoint or load the page. Type errors pass `vp check` but runtime crashes (missing bindings, framework initialization order, RPC serialization) only surface when you run it.
|
||||
- **Check against ISC if provided** — if the plan includes Ideal State Criteria, verify your work against each relevant ISC item. Mark them with evidence (command output, file path, test result). "Should work" is not evidence.
|
||||
|
||||
### 6. Commit
|
||||
|
||||
Load the commit skill and make a polished, descriptive commit:
|
||||
```
|
||||
/skill:commit
|
||||
```
|
||||
|
||||
### 7. Close the Todo
|
||||
|
||||
```
|
||||
todo(action: "update", id: "TODO-xxxx", status: "closed")
|
||||
```
|
||||
@@ -2,8 +2,8 @@
|
||||
name: researcher
|
||||
description: Web researcher — searches the web and synthesizes findings
|
||||
tools: web_search, web_fetch, safe_bash
|
||||
model: openrouter/z-ai/glm-5.2
|
||||
thinking: medium
|
||||
model: openai-codex/gpt-5.6-luna
|
||||
thinking: xhigh
|
||||
system-prompt: append
|
||||
auto-exit: true
|
||||
---
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
name: scout
|
||||
description: Fast codebase recon — explores files, finds patterns, maps architecture
|
||||
tools: read, grep, find, ls
|
||||
model: openrouter/z-ai/glm-5.2
|
||||
thinking: low
|
||||
model: openai-codex/gpt-5.6-luna
|
||||
thinking: xhigh
|
||||
system-prompt: append
|
||||
auto-exit: true
|
||||
---
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: worker
|
||||
description: General-purpose worker — reads, writes, and edits code
|
||||
tools: read, write, edit, safe_bash, web_search, web_fetch
|
||||
tools: read, write, edit, bash, web_search, web_fetch
|
||||
subagent_agents: scout, researcher
|
||||
model: openrouter/z-ai/glm-5.2
|
||||
thinking: medium
|
||||
model: openai-codex/gpt-5.6-luna
|
||||
thinking: xhigh
|
||||
system-prompt: append
|
||||
auto-exit: true
|
||||
---
|
||||
@@ -16,7 +16,7 @@ You run in your own pane and work autonomously to complete the assigned task. Wh
|
||||
Guidelines:
|
||||
- Read files before editing to understand existing code
|
||||
- Make targeted edits, not wholesale rewrites
|
||||
- Use `safe_bash` for running commands (tests, builds, installs, etc.)
|
||||
- Use `bash` for running commands (tests, builds, installs, etc.)
|
||||
- If something fails, diagnose and fix it
|
||||
- Your FINAL assistant message should summarize what you did and what changed
|
||||
|
||||
|
||||
+3
-3
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "pi-interactive-subagents",
|
||||
"version": "3.7.2",
|
||||
"description": "Interactive subagents for pi - spawn, orchestrate, and manage sub-agent sessions in cmux/tmux/zellij terminals",
|
||||
"description": "Interactive subagents for pi - spawn, orchestrate, and manage sub-agent sessions in Herdr or tmux",
|
||||
"keywords": [
|
||||
"pi-package"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "HazAT",
|
||||
"author": "Amos Blomqvist",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/HazAT/pi-interactive-subagents"
|
||||
"url": "https://github.com/amosblomqvist/pi-interactive-subagents"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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" });
|
||||
}
|
||||
+318
-136
@@ -22,19 +22,26 @@ import {
|
||||
sendLongCommand,
|
||||
pollForExit,
|
||||
closeSurface,
|
||||
getMuxBackend,
|
||||
shellEscape,
|
||||
readScreen,
|
||||
} from "./cmux.ts";
|
||||
getMuxBackend,
|
||||
wrapWithCompletionFile,
|
||||
} from "./surface.ts";
|
||||
|
||||
import {
|
||||
countSessionEntryLines,
|
||||
findLastAssistantMessage,
|
||||
getNewEntries,
|
||||
getSessionId,
|
||||
resolveSessionFileById,
|
||||
readNameRegistry,
|
||||
readSubagentLoadout,
|
||||
registerName,
|
||||
resolveNameInRegistry,
|
||||
seedSubagentSessionFile,
|
||||
summarizeSessionStats,
|
||||
writeSubagentLoadout,
|
||||
type SessionStats,
|
||||
type SubagentLoadout,
|
||||
} from "./session.ts";
|
||||
import {
|
||||
type StatusSnapshot,
|
||||
@@ -164,6 +171,40 @@ function getAgentConfigDir(): string {
|
||||
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
||||
}
|
||||
|
||||
// ── Runtime tool-extension registration ─────────────────────────────────────
|
||||
// `getToolExtensionPath` otherwise only knows a closed set of tool names. Other
|
||||
// pi extensions that bundle a tool for subagents (e.g. a project-local
|
||||
// extension exposing a bespoke tool) register its name → extension-file path
|
||||
// here at load/session_start time so a child process can be launched with
|
||||
// `--no-extensions` + an explicit `-e <path>` for it. Mirrors the legacy
|
||||
// `subagents` extension's `registerToolExtension` hook.
|
||||
const EXTRA_TOOL_EXTENSIONS = new Map<string, string>();
|
||||
|
||||
/** Register (or re-register) a custom tool's backing extension file. */
|
||||
export function registerToolExtension(name: string, extensionPath: string): void {
|
||||
if (BUILTIN_TOOLS.has(name)) {
|
||||
throw new Error(`Cannot register custom tool "${name}": shadows a built-in pi tool`);
|
||||
}
|
||||
if ((SPAWNING_TOOLS as readonly string[]).includes(name)) {
|
||||
throw new Error(`Cannot register custom tool "${name}": shadows a spawning tool`);
|
||||
}
|
||||
const existing = EXTRA_TOOL_EXTENSIONS.get(name);
|
||||
if (existing === extensionPath) return; // idempotent / reload-safe
|
||||
if (existing !== undefined) {
|
||||
throw new Error(
|
||||
`Tool extension already registered for "${name}": ${existing} (refusing to overwrite with ${extensionPath})`,
|
||||
);
|
||||
}
|
||||
EXTRA_TOOL_EXTENSIONS.set(name, extensionPath);
|
||||
}
|
||||
|
||||
// Expose registration on a process-global so project-local extensions loaded
|
||||
// via jiti (separate module instances) can reach this shared map. Set at module
|
||||
// load so it's available before any `session_start` listener runs.
|
||||
(globalThis as any).__pi_interactive_subagents = {
|
||||
registerToolExtension,
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a custom (non-built-in) tool name to the pi-extension file that
|
||||
* registers it. Used to build the child's `--extension` whitelist after
|
||||
@@ -185,7 +226,12 @@ function getToolExtensionPath(tool: string): string | undefined {
|
||||
google_image_search: join(extBase, "google-image-search", "index.ts"),
|
||||
safe_bash: join(SUBAGENTS_DIR, "tools", "safe-bash.ts"),
|
||||
};
|
||||
return map[tool];
|
||||
// Prefer the built-in path, but fall back to a runtime-registered extension
|
||||
// when that path no longer exists on disk (e.g. a built-in tool extension
|
||||
// was disabled/removed but a project-local extension re-registered it).
|
||||
const builtin = map[tool];
|
||||
if (builtin && existsSync(builtin)) return builtin;
|
||||
return EXTRA_TOOL_EXTENSIONS.get(tool);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,10 +499,15 @@ function widgetIcon(kind: StatusSnapshot["kind"]): string {
|
||||
* (for example direnv/devenv), so the delay is configurable for users who hit
|
||||
* dropped commands. Keep the historical default at 500ms.
|
||||
*/
|
||||
function defaultShellReadyDelayMs(backend: ReturnType<typeof getMuxBackend>): number {
|
||||
return backend === "herdr" ? 1000 : 500;
|
||||
}
|
||||
|
||||
function getShellReadyDelayMs(): number {
|
||||
const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
|
||||
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() {
|
||||
@@ -467,7 +518,7 @@ function muxUnavailableResult() {
|
||||
text: `Subagents require a supported terminal multiplexer. ${muxSetupHint()}`,
|
||||
},
|
||||
],
|
||||
details: { error: "mux not available" },
|
||||
details: { error: "terminal multiplexer not available" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -509,9 +560,9 @@ function resolveResultPresentation(
|
||||
>,
|
||||
name: string,
|
||||
): string {
|
||||
const sessionRef = result.sessionId
|
||||
? `\n\nSession id: ${result.sessionId}\nFollow up with subagent_message({ sessionId: "${result.sessionId}", message: "…" })`
|
||||
: "";
|
||||
// Name is the persistent handle: the same name steers a running subagent or
|
||||
// resumes a finished one, so follow-ups always reference it.
|
||||
const sessionRef = `\n\nFollow up with subagent_message({ name: "${name}", message: "…" })`;
|
||||
|
||||
if (result.errorMessage) {
|
||||
// Auto-retry exhausted or other agent-loop error. The subagent did not
|
||||
@@ -574,6 +625,7 @@ interface RunningSubagent {
|
||||
abortController?: AbortController;
|
||||
cli?: string;
|
||||
sentinelFile?: string;
|
||||
completionFile?: string;
|
||||
statusState: SubagentStatusState;
|
||||
/**
|
||||
* When true, status transitions (stalled/recovered) do not wake the parent
|
||||
@@ -777,6 +829,60 @@ function buildSubagentToolAllowlist(
|
||||
return [...allow].join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a loadout snapshot's sandbox to a pi command's `parts` array: model,
|
||||
* identity (system prompt), and the default-deny tool/extension restriction
|
||||
* (`--no-extensions` + `--tools` + one `-e` per tool-backing extension).
|
||||
*
|
||||
* This is the single source of truth for reconstructing a subagent's sandbox,
|
||||
* used both by the initial `launchSubagent` and by the `subagent_message`
|
||||
* resume path so the two can never drift. Env vars (PI_SUBAGENT_AGENT /
|
||||
* PI_SUBAGENT_ALLOWED / PI_CODING_AGENT_DIR) and cwd are the caller's
|
||||
* responsibility since they differ slightly between launch and resume.
|
||||
*/
|
||||
function applySandboxToParts(
|
||||
parts: string[],
|
||||
loadout: SubagentLoadout,
|
||||
opts: { artifactDir: string; name: string },
|
||||
): void {
|
||||
if (loadout.model) {
|
||||
const model = loadout.thinking ? `${loadout.model}:${loadout.thinking}` : loadout.model;
|
||||
parts.push("--model", shellEscape(model));
|
||||
}
|
||||
|
||||
if (loadout.identity) {
|
||||
const flag = loadout.systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
|
||||
const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const spSafeName = opts.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
const spPath = join(opts.artifactDir, `context/${spSafeName || "subagent"}-sysprompt-${spTimestamp}.md`);
|
||||
mkdirSync(dirname(spPath), { recursive: true });
|
||||
writeFileSync(spPath, loadout.identity, "utf8");
|
||||
parts.push(flag, shellEscape(spPath));
|
||||
}
|
||||
|
||||
// Default-deny: disable global extension discovery and re-enable only the
|
||||
// extensions backing the whitelisted tools. A null allowlist means the spawn
|
||||
// was intentionally unrestricted (e.g. a fork clone) and is replayed as-is.
|
||||
if (loadout.toolAllowlist) {
|
||||
parts.push("--no-extensions");
|
||||
parts.push("--tools", shellEscape(loadout.toolAllowlist));
|
||||
|
||||
const extPaths = new Set<string>();
|
||||
for (const tool of loadout.toolAllowlist.split(",")) {
|
||||
const extPath = getToolExtensionPath(tool);
|
||||
if (extPath && existsSync(extPath)) extPaths.add(extPath);
|
||||
}
|
||||
for (const extPath of extPaths) {
|
||||
parts.push("-e", shellEscape(extPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPiPromptArgs(params: {
|
||||
effectiveSkills?: string;
|
||||
taskDelivery: "direct" | "artifact";
|
||||
@@ -851,13 +957,20 @@ function observeRunningSubagent(running: RunningSubagent, observedAt = Date.now(
|
||||
const reservedNames = new Set<string>();
|
||||
|
||||
/**
|
||||
* Return `base`, or `base-2`, `base-3`, … if a running subagent already uses
|
||||
* that name (or a parallel spawn has reserved it). Keeps defaulted pane labels
|
||||
* unique so `subagent_message({ name })` can address each one unambiguously.
|
||||
* Return `base`, or `base-2`, `base-3`, … so the result is unique within this
|
||||
* spawner session. Considers (a) currently-running subagents, (b) names
|
||||
* reserved by parallel in-flight spawns, and (c) every name already recorded in
|
||||
* the spawner's persistent registry — so a defaulted name never collides with a
|
||||
* finished subagent either. This lets `subagent_message({ name })` address any
|
||||
* subagent of this session unambiguously, running or finished.
|
||||
*
|
||||
* `registryNames` is the set of names already taken in the registry (empty when
|
||||
* there is no session file / artifact dir yet).
|
||||
*/
|
||||
function uniqueRunningName(base: string): string {
|
||||
function uniqueRunningName(base: string, registryNames?: Set<string>): string {
|
||||
const taken = new Set(Array.from(runningSubagents.values()).map((r) => r.name));
|
||||
for (const reserved of reservedNames) taken.add(reserved);
|
||||
if (registryNames) for (const n of registryNames) taken.add(n);
|
||||
if (!taken.has(base)) return base;
|
||||
let n = 2;
|
||||
while (taken.has(`${base}-${n}`)) n++;
|
||||
@@ -901,10 +1014,9 @@ function steerSubagent(
|
||||
send(running.surface, flattened);
|
||||
return { ok: true };
|
||||
} catch (error: any) {
|
||||
const backend = getMuxBackend() ?? "unknown";
|
||||
return {
|
||||
error:
|
||||
`Failed to deliver message to subagent "${running.name}" via ${backend}: ` +
|
||||
`Failed to deliver message to subagent "${running.name}" via the terminal surface: ` +
|
||||
`${error?.message ?? String(error)}`,
|
||||
};
|
||||
}
|
||||
@@ -1017,6 +1129,7 @@ function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolea
|
||||
|
||||
export const __test__ = {
|
||||
borderLine,
|
||||
defaultShellReadyDelayMs,
|
||||
getShellReadyDelayMs,
|
||||
renderSubagentWidgetLines,
|
||||
loadAgentDefaults,
|
||||
@@ -1025,6 +1138,7 @@ export const __test__ = {
|
||||
resolveLaunchBehavior,
|
||||
resolveEffectiveInteractive,
|
||||
buildSubagentToolAllowlist,
|
||||
applySandboxToParts,
|
||||
buildPiPromptArgs,
|
||||
formatWidgetRightLabel,
|
||||
observeRunningSubagent,
|
||||
@@ -1036,6 +1150,7 @@ export const __test__ = {
|
||||
handleSubagentSteer,
|
||||
resolveResultPresentation,
|
||||
resolveResumeLaunchBehavior,
|
||||
wrapWithCompletionFile,
|
||||
runningSubagents,
|
||||
formatElapsed,
|
||||
formatTokens,
|
||||
@@ -1055,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.
|
||||
*
|
||||
* Call watchSubagent() on the returned object to observe completion.
|
||||
@@ -1079,6 +1194,8 @@ async function launchSubagent(
|
||||
if (!sessionFile) throw new Error("No session file");
|
||||
const sessionId = ctx.sessionManager.getSessionId();
|
||||
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 targetCwdForSession = effectiveCwd ?? ctx.cwd;
|
||||
@@ -1096,14 +1213,15 @@ async function launchSubagent(
|
||||
].join("-");
|
||||
const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
|
||||
|
||||
// Use pre-created surface (parallel mode) or create a new one.
|
||||
// For new surfaces, pause briefly so the shell is ready before sending the command.
|
||||
// Use a pre-created surface or create a new one. New shells need a brief
|
||||
// startup window before terminal input is reliable.
|
||||
const surfacePreCreated = !!options?.surface;
|
||||
const surface = options?.surface ?? createSurface(params.name);
|
||||
if (!surfacePreCreated) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
||||
}
|
||||
|
||||
try {
|
||||
const launchBehavior = resolveLaunchBehavior(params, agentDefs);
|
||||
|
||||
if (launchBehavior.seededSessionMode) {
|
||||
@@ -1166,7 +1284,7 @@ async function launchSubagent(
|
||||
cmdParts.push(shellEscape(params.task));
|
||||
|
||||
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")
|
||||
.toLowerCase()
|
||||
@@ -1196,6 +1314,7 @@ async function launchSubagent(
|
||||
launchScriptFile,
|
||||
cli: "claude",
|
||||
sentinelFile,
|
||||
completionFile,
|
||||
interactive: effectiveInteractive,
|
||||
statusState: createStatusState({
|
||||
source: "claude",
|
||||
@@ -1216,59 +1335,46 @@ async function launchSubagent(
|
||||
const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts");
|
||||
parts.push("-e", shellEscape(subagentDonePath));
|
||||
|
||||
if (effectiveModel) {
|
||||
const model = effectiveThinking ? `${effectiveModel}:${effectiveThinking}` : effectiveModel;
|
||||
parts.push("--model", shellEscape(model));
|
||||
}
|
||||
|
||||
// Pass agent body as system prompt via file to avoid shell escaping issues
|
||||
// with multiline content. Pi's --append-system-prompt and --system-prompt
|
||||
// auto-detect file paths and read their contents.
|
||||
if (identityInSystemPrompt && identity) {
|
||||
const flag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
|
||||
const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const spSafeName = params.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
const syspromptPath = join(artifactDir, `context/${spSafeName || "subagent"}-sysprompt-${spTimestamp}.md`);
|
||||
mkdirSync(dirname(syspromptPath), { recursive: true });
|
||||
writeFileSync(syspromptPath, identity, "utf8");
|
||||
parts.push(flag, shellEscape(syspromptPath));
|
||||
}
|
||||
// Resolve the config dir the child sees: a target-local .pi/agent/ wins,
|
||||
// else the propagated global dir. Captured once so the launch env and the
|
||||
// resume snapshot agree.
|
||||
const resolvedAgentDir =
|
||||
localAgentDir && existsSync(localAgentDir)
|
||||
? localAgentDir
|
||||
: process.env.PI_CODING_AGENT_DIR ?? null;
|
||||
|
||||
// Default-deny model: when an agent restricts its tools (or is granted the
|
||||
// spawning toolset), we disable global extension discovery and re-enable only
|
||||
// the extensions backing the whitelisted tools. Bare/fork spawns with no tool
|
||||
// restriction keep their full default toolset and all global extensions.
|
||||
const toolAllowlist = buildSubagentToolAllowlist(effectiveTools, { grantSpawning });
|
||||
if (toolAllowlist) {
|
||||
parts.push("--no-extensions");
|
||||
parts.push("--tools", shellEscape(toolAllowlist));
|
||||
|
||||
const extPaths = new Set<string>();
|
||||
for (const tool of toolAllowlist.split(",")) {
|
||||
const extPath = getToolExtensionPath(tool);
|
||||
// Skip extensions that can't be resolved on disk so a missing optional
|
||||
// extension never breaks the launch (the tool simply won't be available).
|
||||
if (extPath && existsSync(extPath)) extPaths.add(extPath);
|
||||
}
|
||||
for (const extPath of extPaths) {
|
||||
parts.push("-e", shellEscape(extPath));
|
||||
}
|
||||
}
|
||||
// Snapshot the fully-resolved sandbox beside the session file so a later
|
||||
// `subagent_message({ name })` resume can replay the exact same
|
||||
// restriction instead of relaunching pi with all global extensions + tools.
|
||||
const loadout: SubagentLoadout = {
|
||||
agent: params.agent ?? null,
|
||||
toolAllowlist,
|
||||
model: effectiveModel ?? null,
|
||||
thinking: effectiveThinking ?? null,
|
||||
systemPromptMode: systemPromptMode ?? null,
|
||||
identity: identityInSystemPrompt ? identity : null,
|
||||
spawnable: agentDefs?.subagentAgents ?? null,
|
||||
autoExit: agentDefs?.autoExit ?? false,
|
||||
cwd: effectiveCwd ?? null,
|
||||
agentDir: resolvedAgentDir,
|
||||
};
|
||||
writeSubagentLoadout(subagentSessionFile, loadout);
|
||||
|
||||
// Apply model, identity, and the default-deny tool/extension restriction via
|
||||
// the shared helper (same code path resume uses — they can't drift).
|
||||
applySandboxToParts(parts, loadout, { artifactDir, name: params.name });
|
||||
|
||||
// Build env prefix: subagent identity + config dir propagation + spawn allowlist
|
||||
const envParts: string[] = [];
|
||||
|
||||
// If the target cwd has its own .pi/agent/, use that as the config root.
|
||||
// Otherwise propagate the current/global agent dir.
|
||||
if (localAgentDir && existsSync(localAgentDir)) {
|
||||
envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(localAgentDir)}`);
|
||||
} else if (process.env.PI_CODING_AGENT_DIR) {
|
||||
envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
|
||||
if (resolvedAgentDir) {
|
||||
envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(resolvedAgentDir)}`);
|
||||
}
|
||||
|
||||
if (grantSpawning && agentDefs?.subagentAgents) {
|
||||
@@ -1322,7 +1428,7 @@ async function launchSubagent(
|
||||
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
|
||||
|
||||
const piCommand = cdPrefix + envPrefix + parts.join(" ");
|
||||
const command = `${piCommand}; echo '__SUBAGENT_DONE_'$?'__'`;
|
||||
const command = wrapWithCompletionFile(piCommand, completionFile);
|
||||
const launchScriptName = `${(params.name || "subagent")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
@@ -1350,6 +1456,7 @@ async function launchSubagent(
|
||||
sessionFile: subagentSessionFile,
|
||||
launchScriptFile,
|
||||
activityFile,
|
||||
completionFile,
|
||||
interactive: effectiveInteractive,
|
||||
statusState: createStatusState({
|
||||
source: "pi",
|
||||
@@ -1359,6 +1466,17 @@ async function launchSubagent(
|
||||
|
||||
runningSubagents.set(id, running);
|
||||
return running;
|
||||
} catch (error) {
|
||||
runningSubagents.delete(id);
|
||||
if (!surfacePreCreated) {
|
||||
try {
|
||||
closeSurface(surface);
|
||||
} catch {
|
||||
// Preserve the launch error; cleanup is best-effort.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1408,12 +1526,10 @@ function deliverPendingQuestion(running: RunningSubagent): void {
|
||||
} catch {}
|
||||
if (!payload?.question) return;
|
||||
|
||||
const name = running.name; // unique (deduped at spawn) — used to target the reply
|
||||
const name = running.name; // unique per session (deduped at spawn) — targets the reply
|
||||
const sessionId = existsSync(running.sessionFile) ? getSessionId(running.sessionFile) : null;
|
||||
const elapsed = Math.floor((Date.now() - running.startTime) / 1000);
|
||||
const replyHint = sessionId
|
||||
? `\n\nReply with subagent_message({ name: "${name}", message: "…" }) while it runs (or sessionId: "${sessionId}" after it exits). It stays open until you reply.`
|
||||
: `\n\nReply with subagent_message({ name: "${name}", message: "…" }). It stays open until you reply.`;
|
||||
const replyHint = `\n\nReply with subagent_message({ name: "${name}", message: "…" }) — the same name works whether it is still running or has since exited. It stays open until you reply.`;
|
||||
|
||||
latestPi?.sendMessage(
|
||||
{
|
||||
@@ -1442,6 +1558,7 @@ async function watchSubagent(
|
||||
interval: 1000,
|
||||
sessionFile,
|
||||
sentinelFile: running.sentinelFile,
|
||||
completionFile: running.completionFile,
|
||||
onTick() {
|
||||
observeRunningSubagent(running);
|
||||
deliverPendingQuestion(running);
|
||||
@@ -1668,19 +1785,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
};
|
||||
}
|
||||
|
||||
// Default the cosmetic pane label to the agent name when omitted,
|
||||
// disambiguating against any running subagent already using it so
|
||||
// subagent_message({ name }) can target each one unambiguously. Reserve
|
||||
// the chosen name synchronously (before any await) so parallel spawns
|
||||
// don't all pick the same default.
|
||||
let reservedName: string | null = null;
|
||||
if (!params.name?.trim()) {
|
||||
params.name = uniqueRunningName(params.agent);
|
||||
reservedName = params.name;
|
||||
reservedNames.add(reservedName);
|
||||
}
|
||||
|
||||
// Validate prerequisites
|
||||
// Validate prerequisites (need mux + a session file to derive the
|
||||
// artifact dir that hosts this session's name registry).
|
||||
if (!isMuxAvailable()) {
|
||||
return muxUnavailableResult();
|
||||
}
|
||||
@@ -1697,6 +1803,26 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
};
|
||||
}
|
||||
|
||||
// This spawner session's artifact dir hosts its persistent name
|
||||
// registry (artifacts/<parentSessionId>/subagent-registry.json).
|
||||
const parentArtifactDir = getArtifactDir(
|
||||
ctx.sessionManager.getSessionDir(),
|
||||
ctx.sessionManager.getSessionId(),
|
||||
);
|
||||
|
||||
// Default the cosmetic pane label to the agent name when omitted,
|
||||
// disambiguating against running subagents, in-flight reservations, and
|
||||
// every name already in the registry — so names stay unique across the
|
||||
// whole session, running or finished. Reserve the chosen name
|
||||
// synchronously (before any await) so parallel spawns don't collide.
|
||||
let reservedName: string | null = null;
|
||||
if (!params.name?.trim()) {
|
||||
const registryNames = new Set(Object.keys(readNameRegistry(parentArtifactDir)));
|
||||
params.name = uniqueRunningName(params.agent, registryNames);
|
||||
reservedName = params.name;
|
||||
reservedNames.add(reservedName);
|
||||
}
|
||||
|
||||
// Launch the subagent (creates pane, sends command). Release the name
|
||||
// reservation once it registers in runningSubagents (or launch fails) —
|
||||
// from then on uniqueRunningName tracks it via the running map.
|
||||
@@ -1707,6 +1833,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
if (reservedName) reservedNames.delete(reservedName);
|
||||
}
|
||||
|
||||
// Persist name → session so subagent_message({ name }) can resume this
|
||||
// subagent after it finishes (and after a pi restart). Done at launch,
|
||||
// not completion, so the handle exists even if the parent dies mid-run.
|
||||
registerName(parentArtifactDir, running.name, {
|
||||
sessionFile: running.sessionFile,
|
||||
sessionId: getSessionId(running.sessionFile),
|
||||
});
|
||||
|
||||
// Create a separate AbortController for the watcher
|
||||
// (the tool's signal completes when we return)
|
||||
const watcherAbort = new AbortController();
|
||||
@@ -1902,31 +2036,23 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
name: "subagent_message",
|
||||
label: "Message Subagent",
|
||||
description:
|
||||
"Send a message to a subagent — either steer one that is still running, or resume a finished one. " +
|
||||
"Provide exactly ONE of: `name` (the display name of a currently-running subagent, to inject a follow-up instruction into its live session) " +
|
||||
"or `sessionId` (the id returned in a completed subagent's result, to relaunch that session and continue it). " +
|
||||
"`message` is always required. " +
|
||||
"Send a message to a subagent by name. Names are unique within your session and persist after a subagent finishes, " +
|
||||
"so the SAME name works whether the subagent is running or finished: if it is still running, your message steers its live session; " +
|
||||
"if it has finished, your message resumes that session and continues it. " +
|
||||
"`name` and `message` are both required. " +
|
||||
"Steering a running subagent returns immediately with a local acknowledgement and does NOT, by itself, emit a new result. " +
|
||||
"Resuming is a fire-and-forget async call: when the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up. " +
|
||||
"DO NOT poll, sleep, tail logs, or read session files to detect completion — the harness handles delivery. " +
|
||||
"DO NOT fabricate or assume results. After calling, either end your turn or work on other independent tasks.",
|
||||
promptSnippet:
|
||||
"Message a subagent: steer a running one (by name) or resume a finished one (by sessionId). " +
|
||||
"`message` is required. Steering returns immediately; resuming delivers its result later as a steer message. " +
|
||||
"Message a subagent by name: steers it if running, resumes it if finished (same name either way). " +
|
||||
"`name` and `message` are required. Steering returns immediately; resuming delivers its result later as a steer message. " +
|
||||
"Do not poll or fabricate results.",
|
||||
parameters: Type.Object({
|
||||
name: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Exact display name of a currently-running subagent to steer. Mutually exclusive with sessionId.",
|
||||
}),
|
||||
),
|
||||
sessionId: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Session id (or id prefix) of a finished subagent to resume, as returned in its result. Mutually exclusive with name.",
|
||||
}),
|
||||
),
|
||||
name: Type.String({
|
||||
description:
|
||||
"Exact display name of the subagent. Steers it if it is still running; resumes its session if it has finished.",
|
||||
}),
|
||||
message: Type.String({
|
||||
description:
|
||||
"The message to deliver: a follow-up instruction for a running subagent, or the next task for a resumed session.",
|
||||
@@ -1934,10 +2060,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
}),
|
||||
|
||||
renderCall(args, theme) {
|
||||
const target = args.name ?? args.sessionId ?? "(unknown)";
|
||||
const verb = args.name ? " — steering" : " — resuming session";
|
||||
const target = args.name ?? "(unknown)";
|
||||
return new Text(
|
||||
"○ " + theme.fg("toolTitle", theme.bold(target)) + theme.fg("dim", verb),
|
||||
"○ " + theme.fg("toolTitle", theme.bold(target)) + theme.fg("dim", " — message"),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
@@ -1974,11 +2099,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const hasName = !!params.name?.trim();
|
||||
const hasSession = !!params.sessionId?.trim();
|
||||
if (hasName === hasSession) {
|
||||
const err =
|
||||
"Provide exactly one of `name` (steer a running subagent) or `sessionId` (resume a finished session).";
|
||||
const requestedName = params.name?.trim();
|
||||
if (!requestedName) {
|
||||
const err = "Provide the subagent's `name` to steer (if running) or resume (if finished).";
|
||||
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
||||
}
|
||||
|
||||
@@ -1987,27 +2110,40 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
// ── Steer a running subagent ──
|
||||
if (hasName) {
|
||||
return handleSubagentSteer({ name: params.name, message: params.message });
|
||||
// A name that matches a currently-running subagent always steers it.
|
||||
const runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName);
|
||||
if (runningMatch) {
|
||||
return handleSubagentSteer({ name: requestedName, message: params.message });
|
||||
}
|
||||
|
||||
// ── Resume a finished session ──
|
||||
const requestedId = params.sessionId!.trim();
|
||||
// ── Resume a finished session by name ──
|
||||
const message = params.message;
|
||||
const name = "Resume";
|
||||
const name = requestedName; // identity preservation: the resumed run reclaims its name
|
||||
const { autoExit, interactive } = resolveResumeLaunchBehavior();
|
||||
const startTime = Date.now();
|
||||
const id = Math.random().toString(16).slice(2, 10);
|
||||
|
||||
// Resolve the id (or path) to a concrete session file.
|
||||
const sessionsRoot = dirname(ctx.sessionManager.getSessionDir());
|
||||
const sessionPath =
|
||||
requestedId.includes("/") || requestedId.includes("\\") || requestedId.endsWith(".jsonl")
|
||||
? requestedId
|
||||
: resolveSessionFileById(requestedId, sessionsRoot);
|
||||
// Resolve the name to its session file via this session's registry.
|
||||
const parentArtifactDir = getArtifactDir(
|
||||
ctx.sessionManager.getSessionDir(),
|
||||
ctx.sessionManager.getSessionId(),
|
||||
);
|
||||
const entry = resolveNameInRegistry(parentArtifactDir, requestedName);
|
||||
if (!entry) {
|
||||
const known = Object.keys(readNameRegistry(parentArtifactDir));
|
||||
const err =
|
||||
`No subagent named "${requestedName}" in this session. ` +
|
||||
(known.length > 0
|
||||
? `Known subagents: ${known.join(", ")}.`
|
||||
: "No subagents have been spawned in this session yet.");
|
||||
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
||||
}
|
||||
|
||||
const sessionPath = entry.sessionFile;
|
||||
if (!sessionPath || !existsSync(sessionPath)) {
|
||||
const err = `No session found for id "${requestedId}". Use the session id returned in a completed subagent's result.`;
|
||||
const err =
|
||||
`Subagent "${requestedName}" is registered but its session file is gone ` +
|
||||
`(${sessionPath}). It cannot be resumed. Spawn a fresh subagent instead.`;
|
||||
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
||||
}
|
||||
|
||||
@@ -2015,19 +2151,35 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
// mutating the same .jsonl corrupts it. Steer it by name instead.
|
||||
for (const r of runningSubagents.values()) {
|
||||
if (resolve(r.sessionFile) === resolve(sessionPath)) {
|
||||
const err = `Session "${requestedId}" is still running as "${r.name}". Use name: "${r.name}" to steer it instead of resuming.`;
|
||||
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
||||
const err = `Subagent "${requestedName}" is still running as "${r.name}". Your message will steer it; resending as a steer.`;
|
||||
return handleSubagentSteer({ name: r.name, message: params.message });
|
||||
}
|
||||
}
|
||||
|
||||
const resumedSessionId = getSessionId(sessionPath) ?? requestedId;
|
||||
// Reconstruct the sandbox from the snapshot written at spawn time.
|
||||
// Without it we cannot safely resume: relaunching bare would load every
|
||||
// global extension + the full toolset. Refuse rather than escalate.
|
||||
const loadout = readSubagentLoadout(sessionPath);
|
||||
if (!loadout) {
|
||||
const err =
|
||||
`Cannot safely resume "${requestedName}": no sandbox snapshot found for this session ` +
|
||||
`(it predates sandboxed resume, or its .loadout.json sidecar was removed). ` +
|
||||
`Resuming would relaunch with all global extensions and the full toolset, so this is refused. ` +
|
||||
`Re-run the task as a fresh subagent instead.`;
|
||||
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
||||
}
|
||||
|
||||
// Record entry count before resuming so we can extract new messages
|
||||
const entryCountBefore = getNewEntries(sessionPath, 0).length;
|
||||
const resumedSessionId = entry.sessionId ?? getSessionId(sessionPath) ?? requestedName;
|
||||
|
||||
// Record entry count before resuming so we can extract new messages.
|
||||
// Count lines cheaply (no per-line JSON.parse) so resuming a large
|
||||
// transcript doesn't block the UI.
|
||||
const entryCountBefore = countSessionEntryLines(sessionPath);
|
||||
|
||||
const surface = createSurface(name);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
||||
|
||||
try {
|
||||
// Build pi resume command
|
||||
const parts = ["pi", "--session", shellEscape(sessionPath)];
|
||||
|
||||
@@ -2038,7 +2190,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
const sessionId = ctx.sessionManager.getSessionId();
|
||||
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
|
||||
const activityFile = getSubagentActivityFile(artifactDir, id);
|
||||
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
|
||||
mkdirSync(dirname(activityFile), { recursive: true });
|
||||
mkdirSync(dirname(completionFile), { recursive: true });
|
||||
|
||||
// Replay the model, identity, and default-deny tool/extension sandbox.
|
||||
applySandboxToParts(parts, loadout, { artifactDir, name });
|
||||
|
||||
let resumeMsgFile: string | undefined;
|
||||
if (params.message) {
|
||||
@@ -2058,10 +2215,19 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
parts.push(shellEscape(`@${resumeMsgFile}`));
|
||||
}
|
||||
|
||||
// Build env prefix — propagate PI_CODING_AGENT_DIR for config isolation
|
||||
// Build env prefix — replay the snapshot's config dir + spawn whitelist
|
||||
// so the resumed process resolves the same agents/extensions and keeps
|
||||
// the same nested-spawn restriction it originally ran with.
|
||||
const resumeEnvParts: string[] = [];
|
||||
if (process.env.PI_CODING_AGENT_DIR) {
|
||||
resumeEnvParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
|
||||
const resumeAgentDir = loadout.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? null;
|
||||
if (resumeAgentDir) {
|
||||
resumeEnvParts.push(`PI_CODING_AGENT_DIR=${shellEscape(resumeAgentDir)}`);
|
||||
}
|
||||
if (loadout.spawnable && loadout.spawnable.length > 0) {
|
||||
resumeEnvParts.push(`PI_SUBAGENT_ALLOWED=${shellEscape(loadout.spawnable.join(","))}`);
|
||||
}
|
||||
if (loadout.agent) {
|
||||
resumeEnvParts.push(`PI_SUBAGENT_AGENT=${shellEscape(loadout.agent)}`);
|
||||
}
|
||||
resumeEnvParts.push(`PI_SUBAGENT_NAME=${shellEscape(name)}`);
|
||||
resumeEnvParts.push(`PI_SUBAGENT_SESSION=${shellEscape(sessionPath)}`);
|
||||
@@ -2072,7 +2238,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
const resumeEnvPrefix = resumeEnvParts.join(" ") + " ";
|
||||
|
||||
const command = `${resumeEnvPrefix}${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
|
||||
// Resume in the subagent's original cwd so its tools (safe_bash, edits)
|
||||
// operate where they did before.
|
||||
const resumeCdPrefix = loadout.cwd ? `cd ${shellEscape(loadout.cwd)} && ` : "";
|
||||
|
||||
const command = wrapWithCompletionFile(
|
||||
`${resumeCdPrefix}${resumeEnvPrefix}${parts.join(" ")}`,
|
||||
completionFile,
|
||||
);
|
||||
const launchScriptFile = join(
|
||||
artifactDir,
|
||||
"subagent-scripts",
|
||||
@@ -2104,6 +2277,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
sessionFile: sessionPath,
|
||||
launchScriptFile,
|
||||
activityFile,
|
||||
completionFile,
|
||||
interactive,
|
||||
statusState: createStatusState({
|
||||
source: "pi",
|
||||
@@ -2176,6 +2350,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
status: "started",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
runningSubagents.delete(id);
|
||||
try {
|
||||
closeSurface(surface);
|
||||
} catch {
|
||||
// Preserve the resume error; cleanup is best-effort.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2260,9 +2443,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
|
||||
const rawContent = typeof message.content === "string" ? message.content : "";
|
||||
|
||||
// Clean summary (remove session ref and leading label for display)
|
||||
// Clean summary (remove follow-up ref and leading label for display)
|
||||
const summary = rawContent
|
||||
.replace(/\n\nSession id: [\s\S]+$/, "")
|
||||
.replace(/\n\nFollow up with subagent_message[\s\S]+$/, "")
|
||||
.replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "")
|
||||
.replace(`Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`, "")
|
||||
.replace(
|
||||
@@ -2283,14 +2466,13 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
contentLines.push(line.slice(0, width - 6));
|
||||
}
|
||||
}
|
||||
if (details.sessionId || details.sessionFile) {
|
||||
if (details.name || details.sessionFile) {
|
||||
contentLines.push("");
|
||||
if (details.sessionId) {
|
||||
contentLines.push(theme.fg("dim", `Session id: ${details.sessionId}`));
|
||||
if (details.name) {
|
||||
contentLines.push(
|
||||
theme.fg(
|
||||
"dim",
|
||||
`Follow up: subagent_message({ sessionId: "${details.sessionId}", message: "…" })`,
|
||||
`Follow up: subagent_message({ name: "${details.name}", message: "…" })`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2369,10 +2551,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
if (options.expanded) {
|
||||
contentLines.push("");
|
||||
contentLines.push(details.question ?? "");
|
||||
if (details.sessionId) {
|
||||
contentLines.push("");
|
||||
contentLines.push(theme.fg("dim", `Session id: ${details.sessionId}`));
|
||||
}
|
||||
contentLines.push("");
|
||||
contentLines.push(
|
||||
theme.fg("dim", `Reply: subagent_message({ name: "${name}", message: "…" })`),
|
||||
);
|
||||
} else {
|
||||
const preview = (details.question ?? "").split("\n")[0].slice(0, width - 10);
|
||||
contentLines.push(theme.fg("dim", preview));
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
readFileSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -79,6 +80,139 @@ export function seedSubagentSessionFile(params: {
|
||||
writeFileSync(params.childSessionFile, lines.join("\n") + "\n", "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot of everything needed to reconstruct a subagent's sandbox when its
|
||||
* session is later resumed via `subagent_message({ sessionId })`.
|
||||
*
|
||||
* Written next to the session file as `<sessionFile>.loadout.json` at spawn
|
||||
* time. Resume replays this exact snapshot so the reincarnated process gets the
|
||||
* same `--no-extensions` + `--tools` restriction, model, identity, spawn
|
||||
* whitelist, cwd, and config dir it originally ran with — instead of falling
|
||||
* back to pi's default (all global extensions + full toolset). Storing the
|
||||
* resolved loadout (rather than re-deriving from the agent `.md` by name) keeps
|
||||
* resume faithful even if the agent definition is later edited, moved, or
|
||||
* deleted.
|
||||
*/
|
||||
export interface SubagentLoadout {
|
||||
/** Agent profile name (for PI_SUBAGENT_AGENT); null for agentless spawns. */
|
||||
agent: string | null;
|
||||
/** The `--tools` allowlist string, or null when the spawn was unrestricted. */
|
||||
toolAllowlist: string | null;
|
||||
/** Model id (without thinking suffix), or null to use the session default. */
|
||||
model: string | null;
|
||||
/** Thinking level appended to the model as `model:level`, or null. */
|
||||
thinking: string | null;
|
||||
/** How the identity text was applied: append/replace, or null. */
|
||||
systemPromptMode: "append" | "replace" | null;
|
||||
/** The system-prompt/identity text, only when it lived in the system prompt. */
|
||||
identity: string | null;
|
||||
/** Agents this subagent was allowed to spawn (for PI_SUBAGENT_ALLOWED). */
|
||||
spawnable: string[] | null;
|
||||
/** Whether the agent auto-exits (informational; resume forces autonomous). */
|
||||
autoExit: boolean;
|
||||
/** Working directory the subagent ran in, or null. */
|
||||
cwd: string | null;
|
||||
/** PI_CODING_AGENT_DIR the subagent resolved config/extensions from, or null. */
|
||||
agentDir: string | null;
|
||||
}
|
||||
|
||||
/** Path of the loadout sidecar written next to a subagent session file. */
|
||||
export function loadoutSidecarPath(sessionFile: string): string {
|
||||
return `${sessionFile}.loadout.json`;
|
||||
}
|
||||
|
||||
/** Persist a subagent's resolved sandbox loadout beside its session file. */
|
||||
export function writeSubagentLoadout(sessionFile: string, loadout: SubagentLoadout): void {
|
||||
try {
|
||||
writeFileSync(loadoutSidecarPath(sessionFile), JSON.stringify(loadout), "utf8");
|
||||
} catch {
|
||||
// Best-effort: a missing snapshot only means resume will refuse, never that
|
||||
// it launches unrestricted.
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a subagent's loadout snapshot, or null if absent/unparseable. */
|
||||
export function readSubagentLoadout(sessionFile: string): SubagentLoadout | null {
|
||||
try {
|
||||
const p = loadoutSidecarPath(sessionFile);
|
||||
if (!existsSync(p)) return null;
|
||||
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return parsed as SubagentLoadout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name registry ────────────────────────────────────────────────────────────
|
||||
// Each spawner session (the top-level pi session, or a worker that spawns its
|
||||
// own children) gets a registry mapping a subagent's display name to the
|
||||
// session file it ran in. Names are unique per spawner session and persist on
|
||||
// disk, so `subagent_message({ name })` can steer a running subagent or resume
|
||||
// a finished one by the same handle — even across a pi restart. The registry
|
||||
// lives in the spawner's own artifact dir, which is directly addressable from
|
||||
// the spawner's session id (no sessions-tree scan, so resume stays fast).
|
||||
|
||||
export interface NameRegistryEntry {
|
||||
/** Absolute path to the subagent's session .jsonl file. */
|
||||
sessionFile: string;
|
||||
/** Canonical session header id (kept for display/lineage). */
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export type NameRegistry = Record<string, NameRegistryEntry>;
|
||||
|
||||
/** Path of the name registry for a given spawner session's artifact dir. */
|
||||
export function nameRegistryPath(artifactDir: string): string {
|
||||
return join(artifactDir, "subagent-registry.json");
|
||||
}
|
||||
|
||||
/** Read a spawner session's name registry, or {} if absent/corrupt. */
|
||||
export function readNameRegistry(artifactDir: string): NameRegistry {
|
||||
try {
|
||||
const p = nameRegistryPath(artifactDir);
|
||||
if (!existsSync(p)) return {};
|
||||
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return parsed as NameRegistry;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or overwrite) a name → session mapping for a spawner session.
|
||||
* Writes atomically (temp file + rename) so a concurrent reader never sees a
|
||||
* partial registry.
|
||||
*/
|
||||
export function registerName(
|
||||
artifactDir: string,
|
||||
name: string,
|
||||
entry: NameRegistryEntry,
|
||||
): void {
|
||||
try {
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
const registry = readNameRegistry(artifactDir);
|
||||
registry[name] = entry;
|
||||
const p = nameRegistryPath(artifactDir);
|
||||
const tmp = `${p}.tmp-${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf8");
|
||||
renameSync(tmp, p);
|
||||
} catch {
|
||||
// Best-effort: a failed registration only means resume-by-name won't find
|
||||
// this subagent later; it never breaks the spawn itself.
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a name to its registry entry within a spawner session, or null. */
|
||||
export function resolveNameInRegistry(
|
||||
artifactDir: string,
|
||||
name: string,
|
||||
): NameRegistryEntry | null {
|
||||
const entry = readNameRegistry(artifactDir)[name];
|
||||
return entry && typeof entry.sessionFile === "string" ? entry : null;
|
||||
}
|
||||
|
||||
function readEntries(sessionFile: string): SessionEntry[] {
|
||||
const raw = readFileSync(sessionFile, "utf8");
|
||||
return raw
|
||||
@@ -153,46 +287,176 @@ function readHeaderId(sessionFile: string): string | null {
|
||||
* resolution order: exact match first, then prefix match. Most recently
|
||||
* modified file wins on ties. Returns null when nothing matches.
|
||||
*/
|
||||
export function resolveSessionFileById(sessionId: string, sessionsRoot: string): string | null {
|
||||
if (!sessionId || !existsSync(sessionsRoot)) return null;
|
||||
/**
|
||||
* In-process index of session id → session file, per sessions root.
|
||||
*
|
||||
* Resolving a session id naively walks every `.jsonl` under the sessions tree
|
||||
* and reads each header. With a few thousand sessions that is thousands of
|
||||
* synchronous open/read/stat syscalls — on the extension host's single thread
|
||||
* that blocks the entire terminal UI for many seconds (measured ~67s on a
|
||||
* 2010-file tree). To avoid that, we build the index once per root and cache
|
||||
* it; subsequent lookups are O(1). The cache is validated cheaply (a directory
|
||||
* listing plus statSync-only mtime checks) on every call, so new sessions are
|
||||
* picked up without re-reading unchanged headers and without ever freezing the
|
||||
* UI again.
|
||||
*/
|
||||
interface SessionIndex {
|
||||
idToFile: Map<string, { path: string; mtime: number }>;
|
||||
/** file path → mtime when indexed (staleness detection). */
|
||||
files: Map<string, number>;
|
||||
/** top-level dir signature used to detect newly added cwd dirs. */
|
||||
topSig: string;
|
||||
}
|
||||
const sessionIndexCache = new Map<string, SessionIndex>();
|
||||
|
||||
const candidates: Array<{ path: string; id: string; mtime: number }> = [];
|
||||
const walk = (dir: string) => {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
function topLevelSignature(root: string): string {
|
||||
const parts: string[] = [];
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = join(root, e.name);
|
||||
if (e.isDirectory()) {
|
||||
let m = 0;
|
||||
try {
|
||||
m = statSync(full).mtimeMs;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
parts.push(`d:${e.name}:${m}`);
|
||||
} else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
||||
parts.push(`f:${e.name}`);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
const id = readHeaderId(full);
|
||||
if (!id) continue;
|
||||
let mtime = 0;
|
||||
try {
|
||||
mtime = statSync(full).mtimeMs;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
candidates.push({ path: full, id, mtime });
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
/** Recursively index new/changed .jsonl files under dir into idx. */
|
||||
function indexDir(dir: string, idx: SessionIndex): void {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
indexDir(full, idx);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
let mtime = 0;
|
||||
try {
|
||||
mtime = statSync(full).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const known = idx.files.get(full);
|
||||
if (known !== undefined && known === mtime) continue; // unchanged
|
||||
const id = readHeaderId(full); // only read headers for new/changed files
|
||||
idx.files.set(full, mtime);
|
||||
if (!id) continue;
|
||||
const prev = idx.idToFile.get(id);
|
||||
if (!prev || mtime >= prev.mtime) {
|
||||
idx.idToFile.set(id, { path: full, mtime });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(sessionsRoot);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.mtime - a.mtime);
|
||||
const exact = candidates.find((c) => c.id === sessionId);
|
||||
if (exact) return exact.path;
|
||||
const prefix = candidates.find((c) => c.id.startsWith(sessionId));
|
||||
return prefix ? prefix.path : null;
|
||||
function getSessionIndex(sessionsRoot: string): SessionIndex {
|
||||
let idx = sessionIndexCache.get(sessionsRoot);
|
||||
const sig = topLevelSignature(sessionsRoot);
|
||||
if (!idx) {
|
||||
idx = { idToFile: new Map(), files: new Map(), topSig: sig };
|
||||
sessionIndexCache.set(sessionsRoot, idx);
|
||||
indexDir(sessionsRoot, idx); // first build: full scan, once per process
|
||||
} else if (idx.topSig !== sig) {
|
||||
idx.topSig = sig;
|
||||
indexDir(sessionsRoot, idx); // a cwd dir was added/changed: incremental rescan
|
||||
} else {
|
||||
indexDir(sessionsRoot, idx); // cheap: stats files, reads only new/changed headers
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
export function resolveSessionFileById(sessionId: string, sessionsRoot: string): string | null {
|
||||
if (!sessionId || !existsSync(sessionsRoot)) return null;
|
||||
const idx = getSessionIndex(sessionsRoot);
|
||||
return lookupSessionIndex(idx, sessionId);
|
||||
}
|
||||
|
||||
function lookupSessionIndex(
|
||||
idx: { idToFile: Map<string, { path: string; mtime: number }> },
|
||||
sessionId: string,
|
||||
): string | null {
|
||||
// Exact match first.
|
||||
const exact = idx.idToFile.get(sessionId);
|
||||
if (exact && existsSync(exact.path)) return exact.path;
|
||||
|
||||
// Prefix match: most recently modified wins (ids are unique in practice, so
|
||||
// this is only a convenience for hand-typed short prefixes).
|
||||
let best: { path: string; mtime: number } | null = null;
|
||||
for (const [id, rec] of idx.idToFile) {
|
||||
if (!id.startsWith(sessionId)) continue;
|
||||
if (!existsSync(rec.path)) continue;
|
||||
if (!best || rec.mtime > best.mtime) best = rec;
|
||||
}
|
||||
return best ? best.path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async variant used by the interactive resume path. Index building/refresh is
|
||||
* synchronous I/O, which can take many seconds on a cold OS page cache with a
|
||||
* few thousand sessions; running it synchronously would block the extension
|
||||
* host's single thread and freeze the terminal UI. Deferring to a macrotask
|
||||
* keeps the event loop responsive. The heavy work only happens on the first
|
||||
* resolution per process (and incrementally thereafter); warm lookups are ~50ms.
|
||||
*/
|
||||
export async function resolveSessionFileByIdAsync(
|
||||
sessionId: string,
|
||||
sessionsRoot: string,
|
||||
): Promise<string | null> {
|
||||
if (!sessionId || !existsSync(sessionsRoot)) return null;
|
||||
// Let the event loop breathe (and the UI repaint) before the sync scan.
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
const idx = getSessionIndex(sessionsRoot);
|
||||
return lookupSessionIndex(idx, sessionId);
|
||||
}
|
||||
|
||||
/** Test hook: drop the cached session index so tests start clean. */
|
||||
export function resetSessionIndexCache(): void {
|
||||
sessionIndexCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return entries added after `afterLine` (1-indexed count of existing entries).
|
||||
*/
|
||||
/**
|
||||
* Count the number of entry lines in a session file without parsing each line
|
||||
* into an object. Used by the resume path, which only needs the *count* of
|
||||
* pre-existing entries (so it can later slice out the new ones). Parsing every
|
||||
* line of a large resumed transcript synchronously at resume time would block
|
||||
* the UI; counting newlines is dramatically cheaper.
|
||||
*/
|
||||
export function countSessionEntryLines(sessionFile: string): number {
|
||||
try {
|
||||
const raw = readFileSync(sessionFile, "utf8");
|
||||
// Count non-blank lines, mirroring getNewEntries' `.filter(line => line.trim())`
|
||||
// but skipping the per-line JSON.parse that makes resume slow on big files.
|
||||
let count = 0;
|
||||
for (const line of raw.split("\n")) {
|
||||
if (line.trim()) count++;
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNewEntries(sessionFile: string, afterLine: number): SessionEntry[] {
|
||||
const raw = readFileSync(sessionFile, "utf8");
|
||||
const lines = raw.split("\n").filter((line) => line.trim());
|
||||
|
||||
@@ -178,9 +178,10 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
let userTookOver = false;
|
||||
let agentStarted = false;
|
||||
// Set when ask_question is called; suppresses auto-exit for that turn so the
|
||||
// session stays open while it waits for the orchestrator's reply. Cleared at
|
||||
// the start of the next turn (when the reply lands).
|
||||
// Set when ask_question is called; suppresses auto-exit so the session stays
|
||||
// open while it waits for the orchestrator's reply. Cleared when the reply
|
||||
// lands — on `input` (covers a reply steered into the current run) and on
|
||||
// `agent_start` (covers a reply that starts a fresh turn after parking).
|
||||
let awaitingAnswer = false;
|
||||
|
||||
// Show widget + status bar on session start
|
||||
@@ -195,6 +196,15 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
pi.on("input", () => {
|
||||
recorder.input();
|
||||
// A submitted message is the orchestrator's (or a human's) reply — the
|
||||
// pending ask_question has been answered, however it was delivered. Clear
|
||||
// here, not only on agent_start, because a reply steered in *mid-run* is
|
||||
// absorbed into the current run (pi's `steer` behavior injects it before
|
||||
// the next LLM call): no new agent_start fires, so without this the flag
|
||||
// would stay set and agent_end would park the session as `waiting` even
|
||||
// though the answer already arrived and was consumed. (The `input` event
|
||||
// fires for mid-run steers because prompt() emits it before queueing.)
|
||||
awaitingAnswer = false;
|
||||
// Ignore the initial task message that starts an autonomous subagent.
|
||||
// Only inputs after the first agent run has started count as user takeover.
|
||||
if (!shouldMarkUserTookOver(agentStarted)) return;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* tmux surface backend.
|
||||
*
|
||||
* This module only implements tmux-specific terminal operations. Backend
|
||||
* selection, command scripts, and exit polling live in surface.ts.
|
||||
*/
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const commandAvailability = new Map<string, boolean>();
|
||||
|
||||
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.TMUX && hasCommand("tmux");
|
||||
}
|
||||
|
||||
export function setupHint(): string {
|
||||
return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
|
||||
}
|
||||
|
||||
function requireTmux(): void {
|
||||
if (!isAvailable()) {
|
||||
throw new Error(`tmux is required for this surface. ${setupHint()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** tmux layout used to keep sibling panes evenly sized. */
|
||||
const SUBAGENT_TMUX_LAYOUT = "even-horizontal";
|
||||
let rebalanceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function rebalanceSurfaces(hintPane?: string): void {
|
||||
const target = process.env.TMUX_PANE ?? hintPane;
|
||||
if (!target) return;
|
||||
if (rebalanceTimer) clearTimeout(rebalanceTimer);
|
||||
rebalanceTimer = setTimeout(() => {
|
||||
rebalanceTimer = null;
|
||||
try {
|
||||
execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
} catch {
|
||||
// Pane/window may be gone; balancing is best-effort.
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
export function createSurface(name: string): string {
|
||||
return createSurfaceSplit(name, "right", process.env.TMUX_PANE);
|
||||
}
|
||||
|
||||
export function createSurfaceSplit(
|
||||
name: string,
|
||||
direction: "left" | "right" | "up" | "down",
|
||||
fromSurface?: string,
|
||||
): string {
|
||||
void name;
|
||||
requireTmux();
|
||||
|
||||
const args = ["split-window", "-d"];
|
||||
args.push(direction === "left" || direction === "right" ? "-h" : "-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;
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
export function readScreen(surface: string, lines = 50): string {
|
||||
requireTmux();
|
||||
return execFileSync(
|
||||
"tmux",
|
||||
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function closeSurface(surface: string): void {
|
||||
requireTmux();
|
||||
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
||||
rebalanceSurfaces();
|
||||
}
|
||||
+60
-88
@@ -2,11 +2,11 @@
|
||||
* Integration test harness for pi-interactive-subagents.
|
||||
*
|
||||
* Provides utilities to:
|
||||
* - Detect available mux backends (cmux, tmux, zellij)
|
||||
* - Detect the active terminal-surface backend
|
||||
* - Create isolated test environments with test agent definitions
|
||||
* - Start real pi sessions in mux surfaces
|
||||
* - Start real pi sessions in Herdr tabs or tmux panes
|
||||
* - Poll for file creation and screen output
|
||||
* - Clean up surfaces and temp files after tests
|
||||
* - Clean up panes and temp files after tests
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -31,14 +32,13 @@ import {
|
||||
readScreen,
|
||||
readScreenAsync,
|
||||
closeSurface,
|
||||
sendEscape,
|
||||
pollForExit,
|
||||
shellEscape,
|
||||
parseCmuxFocusedSnapshotFromJson,
|
||||
parseCmuxPaneRefForSurfaceFromJson,
|
||||
wrapWithCompletionFile,
|
||||
type MuxBackend,
|
||||
} from "../../pi-extension/subagents/cmux.ts";
|
||||
} from "../../pi-extension/subagents/surface.ts";
|
||||
|
||||
// Re-export mux primitives for tests
|
||||
// Re-export backend-neutral surface primitives for tests
|
||||
export {
|
||||
createSurface,
|
||||
createSurfaceSplit,
|
||||
@@ -47,10 +47,10 @@ export {
|
||||
readScreen,
|
||||
readScreenAsync,
|
||||
closeSurface,
|
||||
sendEscape,
|
||||
pollForExit,
|
||||
shellEscape,
|
||||
wrapWithCompletionFile,
|
||||
};
|
||||
export type { MuxBackend };
|
||||
|
||||
// ── Paths ──
|
||||
|
||||
@@ -74,107 +74,72 @@ const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.
|
||||
// ── Configuration ──
|
||||
|
||||
/** 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. */
|
||||
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
|
||||
|
||||
// ── Backend detection ──
|
||||
|
||||
/**
|
||||
* Detect which mux backends are actually available in the current environment.
|
||||
* Temporarily sets PI_SUBAGENT_MUX to probe each backend.
|
||||
*/
|
||||
/** Return the one backend selected automatically for this process. */
|
||||
export function getAvailableBackends(): MuxBackend[] {
|
||||
const backends: MuxBackend[] = [];
|
||||
const orig = process.env.PI_SUBAGENT_MUX;
|
||||
|
||||
for (const backend of ["cmux", "tmux", "zellij"] as MuxBackend[]) {
|
||||
process.env.PI_SUBAGENT_MUX = backend;
|
||||
try {
|
||||
if (getMuxBackend() === backend) backends.push(backend);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (orig === undefined) delete process.env.PI_SUBAGENT_MUX;
|
||||
else process.env.PI_SUBAGENT_MUX = orig;
|
||||
|
||||
return backends;
|
||||
const backend = getMuxBackend();
|
||||
return backend ? [backend] : [];
|
||||
}
|
||||
|
||||
export function setBackend(backend: MuxBackend): string | undefined {
|
||||
const prev = process.env.PI_SUBAGENT_MUX;
|
||||
process.env.PI_SUBAGENT_MUX = backend;
|
||||
return prev;
|
||||
function parseHerdrJson(command: string, args: string[]): any {
|
||||
const output = execFileSync(command, args, { encoding: "utf8" });
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
export function restoreBackend(prev: string | undefined): void {
|
||||
if (prev === undefined) delete process.env.PI_SUBAGENT_MUX;
|
||||
else process.env.PI_SUBAGENT_MUX = prev;
|
||||
}
|
||||
|
||||
export function focusSurface(backend: MuxBackend, surface: string): void {
|
||||
if (backend === "cmux") {
|
||||
const pane = getSurfacePane(backend, surface);
|
||||
if (pane) execFileSync("cmux", ["focus-pane", "--pane", pane], { encoding: "utf8" });
|
||||
execFileSync("cmux", ["focus-panel", "--panel", surface], { encoding: "utf8" });
|
||||
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;
|
||||
}
|
||||
|
||||
if (backend === "tmux") {
|
||||
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Focus helpers are not implemented for ${backend}`);
|
||||
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
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;
|
||||
export function getFocusedSurface(): string | null {
|
||||
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}"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const activeLine = panes.split("\n").find((line) => line.endsWith(" 1"));
|
||||
return activeLine?.split(" ")[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error(`Focus helpers are not implemented for ${backend}`);
|
||||
}
|
||||
|
||||
export function getSurfacePane(backend: MuxBackend, surface: string): string | null {
|
||||
if (backend === "cmux") {
|
||||
const info = execFileSync("cmux", ["identify", "--surface", surface], { encoding: "utf8" });
|
||||
return parseCmuxPaneRefForSurfaceFromJson(info, surface);
|
||||
}
|
||||
|
||||
if (backend === "tmux") return surface;
|
||||
|
||||
throw new Error(`Pane lookup is not implemented for ${backend}`);
|
||||
}
|
||||
|
||||
export async function waitForFocusedSurface(
|
||||
backend: MuxBackend,
|
||||
surface: string,
|
||||
timeout: number = PI_TIMEOUT,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (getFocusedSurface(backend) === surface) return;
|
||||
if (getFocusedSurface() === surface) return;
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timeout (${timeout}ms) waiting for focused ${backend} surface ${surface}; ` +
|
||||
`current focus is ${getFocusedSurface(backend) ?? "unknown"}`,
|
||||
`Timeout (${timeout}ms) waiting for focused terminal surface ${surface}; ` +
|
||||
`current focus is ${getFocusedSurface() ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,9 +148,7 @@ export async function waitForFocusedSurface(
|
||||
export interface TestEnv {
|
||||
/** Temp directory serving as the test project root */
|
||||
dir: string;
|
||||
/** Active mux backend for this test run */
|
||||
backend: MuxBackend;
|
||||
/** Surfaces created during the test (cleaned up automatically) */
|
||||
/** Panes created during the test (cleaned up automatically) */
|
||||
surfaces: string[];
|
||||
/** Temp files to clean up */
|
||||
tempFiles: string[];
|
||||
@@ -195,7 +158,7 @@ export interface TestEnv {
|
||||
* Create an isolated test environment with test agent definitions.
|
||||
* The temp dir has `.pi/agents/` containing copies of all test agents.
|
||||
*/
|
||||
export function createTestEnv(backend: MuxBackend): TestEnv {
|
||||
export function createTestEnv(): TestEnv {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-integ-"));
|
||||
const agentsDir = join(dir, ".pi", "agents");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
@@ -204,12 +167,18 @@ export function createTestEnv(backend: MuxBackend): TestEnv {
|
||||
if (existsSync(TEST_AGENTS_SRC)) {
|
||||
for (const file of readdirSync(TEST_AGENTS_SRC)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dir, backend, surfaces: [], tempFiles: [] };
|
||||
return { dir, surfaces: [], tempFiles: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,6 +261,9 @@ export function startPi(
|
||||
.filter(Boolean)
|
||||
.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_'$?'__'`, {
|
||||
scriptPath: join(testDir, `test-launch-${Date.now()}.sh`),
|
||||
});
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
* Integration tests for the full subagent lifecycle.
|
||||
*
|
||||
* These tests spawn REAL pi sessions with REAL LLM calls (haiku by default).
|
||||
* Each test creates a mux surface, runs pi with a task that uses the subagent
|
||||
* Each test creates a terminal surface, runs pi with a task that uses the subagent
|
||||
* tool, and verifies the outcome via marker files and screen output.
|
||||
*
|
||||
* Costs: ~$0.01-0.05 per test run (haiku).
|
||||
* Duration: ~30-90s per test.
|
||||
*
|
||||
* Run inside a supported multiplexer:
|
||||
* cmux bash -c 'npm run test:integration'
|
||||
* Run inside Herdr, or inside tmux:
|
||||
* tmux new 'npm run test:integration'
|
||||
*
|
||||
* Configuration:
|
||||
@@ -21,8 +20,6 @@ import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import {
|
||||
getAvailableBackends,
|
||||
setBackend,
|
||||
restoreBackend,
|
||||
createTestEnv,
|
||||
cleanupTestEnv,
|
||||
createTrackedSurface,
|
||||
@@ -40,23 +37,20 @@ import {
|
||||
const backends = getAvailableBackends();
|
||||
|
||||
if (backends.length === 0) {
|
||||
console.log("⚠️ No mux backend available — skipping subagent lifecycle integration tests");
|
||||
console.log(" Run inside cmux or tmux to enable these tests.");
|
||||
console.log("⚠️ Herdr/tmux is not available — skipping subagent lifecycle integration tests");
|
||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
||||
}
|
||||
|
||||
for (const backend of backends) {
|
||||
describe(`subagent-lifecycle [${backend}]`, { timeout: PI_TIMEOUT * 3 }, () => {
|
||||
let prevMux: string | undefined;
|
||||
let env: TestEnv;
|
||||
|
||||
before(() => {
|
||||
prevMux = setBackend(backend);
|
||||
env = createTestEnv(backend);
|
||||
env = createTestEnv();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanupTestEnv(env);
|
||||
restoreBackend(prevMux);
|
||||
});
|
||||
|
||||
// ── Basic spawn + completion ──
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
/**
|
||||
* Integration tests for the multiplexer surface layer.
|
||||
* Integration tests for the selected terminal-surface backend.
|
||||
*
|
||||
* These tests exercise real mux operations: creating panes,
|
||||
* sending commands, reading screen output, and closing surfaces.
|
||||
* These tests exercise real Herdr or tmux operations: creating surfaces,
|
||||
* sending commands, reading screen output, and closing panes.
|
||||
* No LLM calls — fast and free.
|
||||
*
|
||||
* Run inside a supported multiplexer:
|
||||
* cmux bash -c 'npm run test:integration'
|
||||
* Run inside Herdr, or inside tmux:
|
||||
* tmux new 'npm run test:integration'
|
||||
* zellij --session pi # then run: npm run test:integration
|
||||
*/
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import {
|
||||
getAvailableBackends,
|
||||
setBackend,
|
||||
restoreBackend,
|
||||
createTestEnv,
|
||||
cleanupTestEnv,
|
||||
createTrackedSurface,
|
||||
createTrackedSurfaceSplit,
|
||||
focusSurface,
|
||||
getFocusedSurface,
|
||||
getSurfacePane,
|
||||
waitForFocusedSurface,
|
||||
untrackSurface,
|
||||
sendCommand,
|
||||
@@ -31,7 +25,8 @@ import {
|
||||
readScreen,
|
||||
readScreenAsync,
|
||||
closeSurface,
|
||||
sendEscape,
|
||||
pollForExit,
|
||||
wrapWithCompletionFile,
|
||||
sleep,
|
||||
uniqueId,
|
||||
trackTempFile,
|
||||
@@ -44,63 +39,73 @@ const backends = getAvailableBackends();
|
||||
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
|
||||
|
||||
if (backends.length === 0) {
|
||||
console.log("⚠️ No mux backend available — skipping mux-surface integration tests");
|
||||
console.log(" Run inside cmux or tmux to enable these tests.");
|
||||
console.log("⚠️ Herdr/tmux is not available — skipping surface integration tests");
|
||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
||||
}
|
||||
|
||||
for (const backend of backends) {
|
||||
describe(`mux-surface [${backend}]`, { timeout: 60_000 }, () => {
|
||||
let prevMux: string | undefined;
|
||||
describe(`surface [${backend}]`, { timeout: 60_000 }, () => {
|
||||
let env: TestEnv;
|
||||
|
||||
before(() => {
|
||||
prevMux = setBackend(backend);
|
||||
env = createTestEnv(backend);
|
||||
env = createTestEnv();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanupTestEnv(env);
|
||||
restoreBackend(prevMux);
|
||||
});
|
||||
|
||||
it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => {
|
||||
const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right");
|
||||
const anchor = createTrackedSurface(env, "focus-anchor");
|
||||
await sleep(1000);
|
||||
|
||||
focusSurface(backend, anchor);
|
||||
await waitForFocusedSurface(backend, anchor, 10_000);
|
||||
focusSurface(anchor);
|
||||
await waitForFocusedSurface(anchor, 10_000);
|
||||
|
||||
const childA = createTrackedSurface(env, "focus-child-a");
|
||||
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
||||
assert.equal(getFocusedSurface(backend), anchor);
|
||||
assert.equal(getFocusedSurface(), anchor);
|
||||
|
||||
const childB = createTrackedSurface(env, "focus-child-b");
|
||||
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
||||
assert.equal(getFocusedSurface(backend), anchor);
|
||||
|
||||
if (backend === "cmux") {
|
||||
const paneA = getSurfacePane(backend, childA);
|
||||
const paneB = getSurfacePane(backend, childB);
|
||||
assert.ok(paneA, `Expected pane ref for ${childA}`);
|
||||
assert.ok(paneB, `Expected pane ref for ${childB}`);
|
||||
assert.equal(paneB, paneA);
|
||||
}
|
||||
assert.equal(getFocusedSurface(), anchor);
|
||||
|
||||
const markerA = uniqueId();
|
||||
const markerB = uniqueId();
|
||||
focusSurface(childA);
|
||||
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}"`);
|
||||
await waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50);
|
||||
focusSurface(anchor);
|
||||
await waitForFocusedSurface(anchor, 10_000);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50),
|
||||
waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50),
|
||||
]);
|
||||
assert.equal(getFocusedSurface(backend), anchor);
|
||||
it("polls file-based completion and closes an unfocused surface", async () => {
|
||||
const focusedBefore = getFocusedSurface();
|
||||
const surface = createTrackedSurface(env, "background-completion");
|
||||
await sleep(1000);
|
||||
|
||||
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 () => {
|
||||
const surface = createTrackedSurface(env, "echo-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
sendCommand(surface, `echo "MARKER_${marker}"`);
|
||||
@@ -119,6 +124,7 @@ for (const backend of backends) {
|
||||
it("preserves shell special characters in echo output", async () => {
|
||||
const surface = createTrackedSurface(env, "escape-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
// Single-quoted string — $ and " are literal inside single quotes
|
||||
@@ -140,6 +146,7 @@ for (const backend of backends) {
|
||||
it("sends a long command via script file without truncation", async () => {
|
||||
const surface = createTrackedSurface(env, "long-cmd-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
const longValue = "X".repeat(500);
|
||||
@@ -162,6 +169,7 @@ for (const backend of backends) {
|
||||
it("reads screen asynchronously", async () => {
|
||||
const surface = createTrackedSurface(env, "async-read-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
sendCommand(surface, `echo "ASYNC_${marker}"`);
|
||||
@@ -181,7 +189,10 @@ for (const backend of backends) {
|
||||
|
||||
const m1 = uniqueId();
|
||||
const m2 = uniqueId();
|
||||
focusSurface(s1);
|
||||
sendCommand(s1, `echo "S1_${m1}"`);
|
||||
await sleep(750);
|
||||
focusSurface(s2);
|
||||
sendCommand(s2, `echo "S2_${m2}"`);
|
||||
await sleep(1500);
|
||||
|
||||
@@ -195,9 +206,10 @@ for (const backend of backends) {
|
||||
it("writes output to a file and verifies via surface", async () => {
|
||||
const surface = createTrackedSurface(env, "file-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
const filePath = `/tmp/pi-mux-test-${marker}.txt`;
|
||||
const filePath = `/tmp/pi-surface-test-${marker}.txt`;
|
||||
|
||||
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
|
||||
|
||||
@@ -210,34 +222,5 @@ for (const backend of backends) {
|
||||
unlinkSync(filePath);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
it("delivers Escape as byte 27 to the target surface", async () => {
|
||||
const surface = createTrackedSurface(env, "escape-byte-test");
|
||||
await sleep(1000);
|
||||
|
||||
const marker = uniqueId();
|
||||
const byteFile = `/tmp/pi-mux-escape-${marker}.txt`;
|
||||
trackTempFile(env, byteFile);
|
||||
|
||||
const nodeProgram =
|
||||
"const fs = require('node:fs');" +
|
||||
"if (!process.stdin.isTTY) throw new Error('stdin is not a TTY');" +
|
||||
"process.stdin.setRawMode(true);" +
|
||||
"process.stdin.resume();" +
|
||||
"process.stdout.write('ESC_READY\\n');" +
|
||||
"process.stdin.once('data', (chunk) => {" +
|
||||
`fs.writeFileSync(${JSON.stringify(byteFile)}, Array.from(chunk).join(','));` +
|
||||
"process.exit(0);" +
|
||||
"});";
|
||||
const command = `node -e ${JSON.stringify(nodeProgram)}`;
|
||||
|
||||
sendLongCommand(surface, command);
|
||||
await waitForScreen(surface, /ESC_READY/, 15_000, 50);
|
||||
|
||||
sendEscape(surface);
|
||||
|
||||
const content = await waitForFile(byteFile, 15_000, /^27$/);
|
||||
assert.equal(content.trim(), "27");
|
||||
});
|
||||
});
|
||||
}
|
||||
+373
-307
@@ -1,4 +1,4 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import { describe, it, before, after, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -10,7 +10,17 @@ import * as subagentsModule from "../pi-extension/subagents/index.ts";
|
||||
import {
|
||||
getLeafId,
|
||||
getNewEntries,
|
||||
countSessionEntryLines,
|
||||
getSessionId,
|
||||
readNameRegistry,
|
||||
readSubagentLoadout,
|
||||
registerName,
|
||||
resolveNameInRegistry,
|
||||
nameRegistryPath,
|
||||
writeSubagentLoadout,
|
||||
loadoutSidecarPath,
|
||||
type SubagentLoadout,
|
||||
resetSessionIndexCache,
|
||||
resolveSessionFileById,
|
||||
findLastAssistantMessage,
|
||||
appendBranchSummary,
|
||||
@@ -21,19 +31,11 @@ import {
|
||||
} from "../pi-extension/subagents/session.ts";
|
||||
|
||||
import {
|
||||
__pollForExitTest__,
|
||||
selectMuxBackend,
|
||||
shellEscape,
|
||||
isCmuxAvailable,
|
||||
isWezTermAvailable,
|
||||
parseCmuxFocusedSnapshot,
|
||||
parseCmuxFocusedSnapshotFromJson,
|
||||
parseCmuxJson,
|
||||
parseCmuxPaneRefForSurface,
|
||||
parseCmuxPaneRefForSurfaceFromJson,
|
||||
canSplitZellijPane,
|
||||
predictZellijSplitDirection,
|
||||
selectZellijPlacement,
|
||||
selectZellijStackPlacement,
|
||||
} from "../pi-extension/subagents/cmux.ts";
|
||||
} from "../pi-extension/subagents/surface.ts";
|
||||
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
||||
import {
|
||||
advanceStatusState,
|
||||
capStatusLines,
|
||||
@@ -59,7 +61,6 @@ import {
|
||||
runningChildrenCount,
|
||||
} from "../pi-extension/subagents/subagent-done.ts";
|
||||
import subagentDoneExtension from "../pi-extension/subagents/subagent-done.ts";
|
||||
import { __pollForExitTest__ } from "../pi-extension/subagents/cmux.ts";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -261,6 +262,19 @@ describe("session.ts", () => {
|
||||
const entries = getNewEntries(file, 2);
|
||||
assert.equal(entries.length, 0);
|
||||
});
|
||||
|
||||
it("countSessionEntryLines matches getNewEntries(0).length without parsing", () => {
|
||||
const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]);
|
||||
assert.equal(countSessionEntryLines(file), getNewEntries(file, 0).length);
|
||||
assert.equal(countSessionEntryLines(file), 4);
|
||||
});
|
||||
|
||||
it("countSessionEntryLines ignores blank lines and returns 0 for missing files", () => {
|
||||
const file = join(dir, "blanks.jsonl");
|
||||
writeFileSync(file, JSON.stringify({ type: "session", id: "x" }) + "\n\n\n");
|
||||
assert.equal(countSessionEntryLines(file), 1);
|
||||
assert.equal(countSessionEntryLines(join(dir, "does-not-exist.jsonl")), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionId / resolveSessionFileById", () => {
|
||||
@@ -270,6 +284,12 @@ describe("session.ts", () => {
|
||||
return p;
|
||||
}
|
||||
|
||||
// The resolver caches an id→file index per root; reset it so each test
|
||||
// builds a fresh index from the current on-disk state.
|
||||
beforeEach(() => {
|
||||
resetSessionIndexCache();
|
||||
});
|
||||
|
||||
it("reads the header id from a session file", () => {
|
||||
const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG]);
|
||||
assert.equal(getSessionId(file), "sess-001");
|
||||
@@ -295,6 +315,84 @@ describe("session.ts", () => {
|
||||
writeSession(dir, "c.jsonl", "abc");
|
||||
assert.equal(resolveSessionFileById("zzz", dir), null);
|
||||
});
|
||||
|
||||
it("picks up newly added sessions on repeat calls without a reset", () => {
|
||||
// Prime the index (first call builds it).
|
||||
writeSession(dir, "first.jsonl", "id-first");
|
||||
assert.equal(resolveSessionFileById("id-first", dir) !== null, true);
|
||||
// Add a new session AFTER the index was built — no reset. The resolver's
|
||||
// cheap refresh should index it.
|
||||
const b = writeSession(dir, "second.jsonl", "id-second");
|
||||
assert.equal(resolveSessionFileById("id-second", dir), b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent loadout snapshot", () => {
|
||||
const sample: SubagentLoadout = {
|
||||
agent: "worker",
|
||||
toolAllowlist: "read,write,edit,safe_bash,web_search,subagent,ask_question",
|
||||
model: "openrouter/z-ai/glm-5.2",
|
||||
thinking: "medium",
|
||||
systemPromptMode: "append",
|
||||
identity: "You are a worker agent.",
|
||||
spawnable: ["scout", "researcher"],
|
||||
autoExit: true,
|
||||
cwd: "/work/dir",
|
||||
agentDir: "/home/u/.pi/agent",
|
||||
};
|
||||
|
||||
it("writes the sidecar next to the session file", () => {
|
||||
const sf = join(dir, "s1.jsonl");
|
||||
writeSubagentLoadout(sf, sample);
|
||||
assert.equal(loadoutSidecarPath(sf), sf + ".loadout.json");
|
||||
assert.ok(existsSync(sf + ".loadout.json"));
|
||||
});
|
||||
|
||||
it("round-trips the full loadout", () => {
|
||||
const sf = join(dir, "s2.jsonl");
|
||||
writeSubagentLoadout(sf, sample);
|
||||
assert.deepEqual(readSubagentLoadout(sf), sample);
|
||||
});
|
||||
|
||||
it("returns null when the sidecar is absent", () => {
|
||||
assert.equal(readSubagentLoadout(join(dir, "missing.jsonl")), null);
|
||||
});
|
||||
|
||||
it("returns null when the sidecar is corrupt", () => {
|
||||
const sf = join(dir, "s3.jsonl");
|
||||
writeFileSync(sf + ".loadout.json", "not json{", "utf8");
|
||||
assert.equal(readSubagentLoadout(sf), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent name registry", () => {
|
||||
it("registers and resolves a name to its session file", () => {
|
||||
const adir = join(dir, "art-1");
|
||||
registerName(adir, "worker", { sessionFile: "/s/worker.jsonl", sessionId: "id-worker" });
|
||||
const entry = resolveNameInRegistry(adir, "worker");
|
||||
assert.deepEqual(entry, { sessionFile: "/s/worker.jsonl", sessionId: "id-worker" });
|
||||
assert.ok(existsSync(nameRegistryPath(adir)));
|
||||
});
|
||||
|
||||
it("accumulates multiple names and overwrites on re-register", () => {
|
||||
const adir = join(dir, "art-2");
|
||||
registerName(adir, "scout", { sessionFile: "/s/scout.jsonl", sessionId: "id-scout" });
|
||||
registerName(adir, "scout-2", { sessionFile: "/s/scout2.jsonl", sessionId: "id-scout2" });
|
||||
const reg = readNameRegistry(adir);
|
||||
assert.deepEqual(Object.keys(reg).sort(), ["scout", "scout-2"]);
|
||||
// Overwrite scout with a new session file.
|
||||
registerName(adir, "scout", { sessionFile: "/s/scout-new.jsonl", sessionId: "id-scout-new" });
|
||||
assert.equal(resolveNameInRegistry(adir, "scout")!.sessionFile, "/s/scout-new.jsonl");
|
||||
});
|
||||
|
||||
it("returns null for unknown names and {} for a missing/corrupt registry", () => {
|
||||
const adir = join(dir, "art-3");
|
||||
assert.equal(resolveNameInRegistry(adir, "nope"), null);
|
||||
assert.deepEqual(readNameRegistry(adir), {});
|
||||
mkdirSync(adir, { recursive: true });
|
||||
writeFileSync(nameRegistryPath(adir), "not json{", "utf8");
|
||||
assert.deepEqual(readNameRegistry(adir), {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findLastAssistantMessage", () => {
|
||||
@@ -1078,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"]) {
|
||||
const defs = testApi.loadAgentDefaults(name);
|
||||
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(
|
||||
testApi.resolveEffectiveInteractive({ name, task: "" }, defs),
|
||||
false,
|
||||
@@ -1101,7 +1201,7 @@ describe("subagent discovery", () => {
|
||||
for (const t of ["subagent", "subagent_message", "subagents_list"]) {
|
||||
assert.ok(tools.has(t), `expected spawning tool ${t} in worker allowlist`);
|
||||
}
|
||||
assert.ok(tools.has("safe_bash"), "expected worker to keep safe_bash");
|
||||
assert.ok(tools.has("bash"), "expected worker to keep bash");
|
||||
});
|
||||
|
||||
it("scout and researcher are not granted spawning tools", () => {
|
||||
@@ -1115,10 +1215,16 @@ describe("subagent discovery", () => {
|
||||
it("getToolExtensionPath maps custom tools and skips built-ins", () => {
|
||||
assert.equal(testApi.getToolExtensionPath("read"), 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"));
|
||||
// Spawning tools are registered by this extension itself.
|
||||
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 () => {
|
||||
@@ -1190,6 +1296,66 @@ describe("subagent discovery", () => {
|
||||
assert.equal(testApi.buildSubagentToolAllowlist(""), null);
|
||||
});
|
||||
|
||||
it("applySandboxToParts replays model, identity, and default-deny tool restriction", () => {
|
||||
withTempDir((d) => {
|
||||
const parts: string[] = [];
|
||||
testApi.applySandboxToParts(
|
||||
parts,
|
||||
{
|
||||
agent: "worker",
|
||||
toolAllowlist: "read,write,safe_bash",
|
||||
model: "openrouter/z-ai/glm-5.2",
|
||||
thinking: "medium",
|
||||
systemPromptMode: "append",
|
||||
identity: "You are a worker.",
|
||||
spawnable: ["scout"],
|
||||
autoExit: true,
|
||||
cwd: null,
|
||||
agentDir: null,
|
||||
},
|
||||
{ artifactDir: d, name: "worker" },
|
||||
);
|
||||
const joined = parts.join(" ");
|
||||
// Model with thinking suffix.
|
||||
assert.ok(joined.includes("--model"), "expected --model");
|
||||
assert.ok(joined.includes("openrouter/z-ai/glm-5.2:medium"), "expected model:thinking");
|
||||
// Identity written to a file and appended.
|
||||
assert.ok(joined.includes("--append-system-prompt"), "expected --append-system-prompt");
|
||||
// Default-deny restriction.
|
||||
assert.ok(parts.includes("--no-extensions"), "expected --no-extensions");
|
||||
const toolsIdx = parts.indexOf("--tools");
|
||||
assert.ok(toolsIdx >= 0, "expected --tools");
|
||||
// The value is shell-escaped (single-quoted) before joining.
|
||||
assert.ok(
|
||||
parts[toolsIdx + 1].includes("read,write,safe_bash"),
|
||||
"expected the tool allowlist as the --tools value",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("applySandboxToParts omits restriction flags when the loadout was unrestricted", () => {
|
||||
withTempDir((d) => {
|
||||
const parts: string[] = [];
|
||||
testApi.applySandboxToParts(
|
||||
parts,
|
||||
{
|
||||
agent: null,
|
||||
toolAllowlist: null,
|
||||
model: null,
|
||||
thinking: null,
|
||||
systemPromptMode: null,
|
||||
identity: null,
|
||||
spawnable: null,
|
||||
autoExit: false,
|
||||
cwd: null,
|
||||
agentDir: null,
|
||||
},
|
||||
{ artifactDir: d, name: "fork" },
|
||||
);
|
||||
assert.deepEqual(parts, []);
|
||||
});
|
||||
});
|
||||
|
||||
it("buildPiPromptArgs inserts separator for artifact-backed launches with skills", () => {
|
||||
assert.deepEqual(
|
||||
testApi.buildPiPromptArgs({ effectiveSkills: "review,lint", taskDelivery: "artifact", taskArg: "@artifact.md" }),
|
||||
@@ -1489,11 +1655,119 @@ describe("subagent-done.ts", () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Regression tests for the mid-run reply race: a reply steered in while the
|
||||
// asking run is still open fires `input` but NOT `agent_start`, so the flag
|
||||
// must be cleared on `input` or the session parks forever.
|
||||
function setupCapturingExtension(sessionFile: string) {
|
||||
const handlers = new Map<string, Array<(...args: any[]) => void>>();
|
||||
const tools: any[] = [];
|
||||
const api = {
|
||||
on(event: string, handler: (...args: any[]) => void) {
|
||||
if (!handlers.has(event)) handlers.set(event, []);
|
||||
handlers.get(event)!.push(handler);
|
||||
},
|
||||
registerTool(t: any) { tools.push(t); },
|
||||
registerCommand() {}, registerMessageRenderer() {}, registerShortcut() {},
|
||||
sendUserMessage() {}, sendMessage() {}, getAllTools() { return []; },
|
||||
} as any;
|
||||
const saved = {
|
||||
session: process.env.PI_SUBAGENT_SESSION,
|
||||
name: process.env.PI_SUBAGENT_NAME,
|
||||
agent: process.env.PI_SUBAGENT_AGENT,
|
||||
autoExit: process.env.PI_SUBAGENT_AUTO_EXIT,
|
||||
};
|
||||
process.env.PI_SUBAGENT_SESSION = sessionFile;
|
||||
process.env.PI_SUBAGENT_NAME = "scout-2";
|
||||
process.env.PI_SUBAGENT_AGENT = "scout";
|
||||
process.env.PI_SUBAGENT_AUTO_EXIT = "1";
|
||||
subagentDoneExtension(api);
|
||||
const emit = (event: string, ...args: any[]) =>
|
||||
(handlers.get(event) ?? []).forEach((h) => h(...args));
|
||||
const restore = () => {
|
||||
restoreEnvVar("PI_SUBAGENT_SESSION", saved.session);
|
||||
restoreEnvVar("PI_SUBAGENT_NAME", saved.name);
|
||||
restoreEnvVar("PI_SUBAGENT_AGENT", saved.agent);
|
||||
restoreEnvVar("PI_SUBAGENT_AUTO_EXIT", saved.autoExit);
|
||||
};
|
||||
const ask = async () => {
|
||||
const tool = tools.find((t) => t.name === "ask_question");
|
||||
await tool.execute("c1", { question: "v1 or v2?" }, undefined, undefined, { shutdown() {} });
|
||||
};
|
||||
return { emit, ask, restore };
|
||||
}
|
||||
|
||||
it("exits (does not park) when the reply arrives mid-run via input", async () => {
|
||||
const dir = createTestDir();
|
||||
const { emit, ask, restore } = setupCapturingExtension(join(dir, "s.jsonl"));
|
||||
try {
|
||||
emit("agent_start");
|
||||
await ask(); // sets awaitingAnswer mid-run
|
||||
// Reply arrives MID-RUN as a steer: input fires, no new agent_start.
|
||||
emit("input");
|
||||
let shutdown = false;
|
||||
emit("agent_end", { messages: [] }, { shutdown() { shutdown = true; } });
|
||||
assert.equal(shutdown, true, "reply consumed mid-run → agent_end should exit, not park");
|
||||
} finally {
|
||||
restore();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("parks as waiting at agent_end while the reply is still pending (no input yet)", async () => {
|
||||
const dir = createTestDir();
|
||||
const { emit, ask, restore } = setupCapturingExtension(join(dir, "s.jsonl"));
|
||||
try {
|
||||
emit("agent_start");
|
||||
await ask();
|
||||
// No input yet — the orchestrator has not replied.
|
||||
let shutdown = false;
|
||||
emit("agent_end", { messages: [] }, { shutdown() { shutdown = true; } });
|
||||
assert.equal(shutdown, false, "pending question with no reply must park, not exit");
|
||||
} finally {
|
||||
restore();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("exits when the reply arrives as a new turn (agent_start also clears the flag)", async () => {
|
||||
const dir = createTestDir();
|
||||
const { emit, ask, restore } = setupCapturingExtension(join(dir, "s.jsonl"));
|
||||
try {
|
||||
emit("agent_start");
|
||||
await ask();
|
||||
let shutdown1 = false;
|
||||
emit("agent_end", { messages: [] }, { shutdown() { shutdown1 = true; } });
|
||||
assert.equal(shutdown1, false, "parks while waiting");
|
||||
// Reply arrives as a fresh turn after the subagent had parked.
|
||||
emit("input");
|
||||
emit("agent_start");
|
||||
let shutdown2 = false;
|
||||
emit("agent_end", { messages: [] }, { shutdown() { shutdown2 = true; } });
|
||||
assert.equal(shutdown2, true, "after the reply turn, agent_end should exit");
|
||||
} finally {
|
||||
restore();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmux.ts interpretExitSidecar", () => {
|
||||
const { interpretExitSidecar } = __pollForExitTest__;
|
||||
describe("surface exit signals", () => {
|
||||
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)", () => {
|
||||
// ask_question writes a `.ask` signal, not a `.exit` ping sidecar, so an
|
||||
@@ -1648,7 +1922,7 @@ describe("tool registration", () => {
|
||||
assert.match(output, /\(unnamed\)/);
|
||||
});
|
||||
|
||||
it("registers subagent_message with only name, sessionId, and required message (no autoExit knob)", () => {
|
||||
it("registers subagent_message with name + message both required (name-only addressing)", () => {
|
||||
const { api, registeredTools } = createMockExtensionApi();
|
||||
(subagentsModule as any).default(api);
|
||||
|
||||
@@ -1658,11 +1932,17 @@ describe("tool registration", () => {
|
||||
const props = messageTool.parameters.properties;
|
||||
assert.deepEqual(
|
||||
Object.keys(props).sort(),
|
||||
["message", "name", "sessionId"],
|
||||
"only name/sessionId/message should remain",
|
||||
["message", "name"],
|
||||
"only name/message should remain (sessionId dropped)",
|
||||
);
|
||||
assert.equal(props.message.type, "string");
|
||||
assert.equal(messageTool.parameters.required?.includes("message"), true);
|
||||
assert.equal(props.name.type, "string");
|
||||
assert.deepEqual(
|
||||
messageTool.parameters.required?.slice().sort(),
|
||||
["message", "name"],
|
||||
"name and message should both be required",
|
||||
);
|
||||
assert.equal(props.sessionId, undefined, "sessionId should be removed");
|
||||
assert.equal(props.autoExit, undefined, "autoExit knob should be removed");
|
||||
});
|
||||
|
||||
@@ -1916,6 +2196,28 @@ describe("subagent interruption", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uniqueRunningName also avoids names already taken in the persistent registry", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const runningMap = testApi.runningSubagents as Map<string, any>;
|
||||
const reserved = testApi.reservedNames as Set<string>;
|
||||
runningMap.clear();
|
||||
reserved.clear();
|
||||
|
||||
try {
|
||||
// A finished subagent's name lives in the registry even though nothing is
|
||||
// running — a fresh default must skip it so names stay unique session-wide.
|
||||
const registryNames = new Set(["worker", "worker-2"]);
|
||||
assert.equal(testApi.uniqueRunningName("worker", registryNames), "worker-3");
|
||||
// A name not in the registry (or running/reserved) is unaffected.
|
||||
assert.equal(testApi.uniqueRunningName("scout", registryNames), "scout");
|
||||
// An empty registry behaves like before.
|
||||
assert.equal(testApi.uniqueRunningName("worker", new Set()), "worker");
|
||||
} finally {
|
||||
runningMap.clear();
|
||||
reserved.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("uniqueRunningName also avoids names reserved by in-flight parallel spawns", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const runningMap = testApi.runningSubagents as Map<string, any>;
|
||||
@@ -2072,8 +2374,9 @@ describe("subagent interruption", () => {
|
||||
|
||||
assert.match(presentation, /failed \(exit code 130\)/);
|
||||
assert.doesNotMatch(presentation, /interrupted/);
|
||||
assert.match(presentation, /Session id: 019f-abc/);
|
||||
assert.match(presentation, /subagent_message/);
|
||||
// Follow-ups reference the name (not the session id).
|
||||
assert.match(presentation, /subagent_message\(\{ name: "Worker"/);
|
||||
assert.doesNotMatch(presentation, /Session id:/);
|
||||
});
|
||||
|
||||
it("renders a clear provider/agent error when errorMessage is set", () => {
|
||||
@@ -2098,8 +2401,8 @@ describe("subagent interruption", () => {
|
||||
assert.match(presentation, /Sub-agent "Worker" failed/);
|
||||
assert.match(presentation, /provider\/agent error — auto-retry exhausted/);
|
||||
assert.match(presentation, /Error: Anthropic 529 Overloaded after 3 retries/);
|
||||
assert.match(presentation, /subagent_message/);
|
||||
assert.match(presentation, /Session id: 019f-xyz/);
|
||||
assert.match(presentation, /subagent_message\(\{ name: "Worker"/);
|
||||
assert.doesNotMatch(presentation, /Session id:/);
|
||||
assert.doesNotMatch(presentation, /ignored when errorMessage is present/);
|
||||
});
|
||||
});
|
||||
@@ -2182,19 +2485,11 @@ describe("subagent status renderer", () => {
|
||||
});
|
||||
|
||||
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__;
|
||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||
assert.equal(typeof testApi.getShellReadyDelayMs, "function");
|
||||
|
||||
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;
|
||||
}
|
||||
assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
|
||||
assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
|
||||
assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
|
||||
});
|
||||
|
||||
it("uses PI_SUBAGENT_SHELL_READY_DELAY_MS when it is set", () => {
|
||||
@@ -2374,7 +2669,45 @@ describe("subagent display helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmux.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", () => {
|
||||
it("wraps in single quotes", () => {
|
||||
assert.equal(shellEscape("hello"), "'hello'");
|
||||
@@ -2397,271 +2730,4 @@ describe("cmux.ts", () => {
|
||||
assert.ok(escaped.includes("$world"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCmuxFocusedSnapshot", () => {
|
||||
it("parses focused surface and pane refs", () => {
|
||||
assert.deepEqual(
|
||||
parseCmuxFocusedSnapshot({ focused: { surface_ref: "surface:3", pane_ref: "pane:2" } }),
|
||||
{ surfaceRef: "surface:3", paneRef: "pane:2" },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back to caller refs", () => {
|
||||
assert.equal(
|
||||
parseCmuxFocusedSnapshot({ caller: { surface_ref: "surface:1", pane_ref: "pane:1" } }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for malformed values", () => {
|
||||
assert.equal(parseCmuxFocusedSnapshot(null), null);
|
||||
assert.equal(parseCmuxFocusedSnapshot({ focused: {} }), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCmuxJson", () => {
|
||||
it("returns null for malformed JSON text", () => {
|
||||
assert.equal(parseCmuxJson("not json"), null);
|
||||
});
|
||||
|
||||
it("parses valid JSON text", () => {
|
||||
assert.deepEqual(parseCmuxJson('{"ok":true}'), { ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCmuxFocusedSnapshotFromJson", () => {
|
||||
it("returns null for malformed JSON text", () => {
|
||||
assert.equal(parseCmuxFocusedSnapshotFromJson("not json"), null);
|
||||
});
|
||||
|
||||
it("returns null when focused is absent or not an object", () => {
|
||||
assert.equal(
|
||||
parseCmuxFocusedSnapshotFromJson('{"focused":null,"caller":{"surface_ref":"surface:1","pane_ref":"pane:1"}}'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
parseCmuxFocusedSnapshotFromJson('{"caller":{"surface_ref":"surface:1","pane_ref":"pane:1"}}'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses focused refs without falling back to caller refs", () => {
|
||||
assert.deepEqual(
|
||||
parseCmuxFocusedSnapshotFromJson(
|
||||
'{"caller":{"surface_ref":"surface:1","pane_ref":"pane:1"},"focused":{"surface_ref":"surface:2","pane_ref":"pane:3"}}',
|
||||
),
|
||||
{ surfaceRef: "surface:2", paneRef: "pane:3" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCmuxPaneRefForSurface", () => {
|
||||
it("parses top-level pane refs for a surface", () => {
|
||||
assert.equal(
|
||||
parseCmuxPaneRefForSurface({ surface_ref: "surface:7", pane_ref: "pane:4" }, "surface:7"),
|
||||
"pane:4",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses caller pane refs for identify --surface output", () => {
|
||||
assert.equal(
|
||||
parseCmuxPaneRefForSurface(
|
||||
{ caller: { surface_ref: "surface:7", pane_ref: "pane:4" } },
|
||||
"surface:7",
|
||||
),
|
||||
"pane:4",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when the surface does not match", () => {
|
||||
assert.equal(
|
||||
parseCmuxPaneRefForSurface({ surface_ref: "surface:8", pane_ref: "pane:4" }, "surface:7"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCmuxPaneRefForSurfaceFromJson", () => {
|
||||
it("returns null for malformed JSON text", () => {
|
||||
assert.equal(parseCmuxPaneRefForSurfaceFromJson("not json", "surface:7"), null);
|
||||
});
|
||||
|
||||
it("parses caller refs from cmux identify --surface JSON text", () => {
|
||||
assert.equal(
|
||||
parseCmuxPaneRefForSurfaceFromJson(
|
||||
'{"caller":{"surface_ref":"surface:7","pane_ref":"pane:4"}}',
|
||||
"surface:7",
|
||||
),
|
||||
"pane:4",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zellij placement", () => {
|
||||
const pane = (overrides: any) => ({
|
||||
id: 1,
|
||||
is_plugin: false,
|
||||
is_floating: false,
|
||||
is_selectable: true,
|
||||
exited: false,
|
||||
pane_rows: 20,
|
||||
pane_columns: 80,
|
||||
tab_id: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("matches Zellij direction and minimum split rules", () => {
|
||||
assert.equal(predictZellijSplitDirection(pane({ pane_rows: 5, pane_columns: 11 })), "right");
|
||||
assert.equal(predictZellijSplitDirection(pane({ pane_rows: 11, pane_columns: 5 })), "down");
|
||||
assert.equal(predictZellijSplitDirection(pane({ pane_rows: 5, pane_columns: 10 })), null);
|
||||
assert.equal(predictZellijSplitDirection(pane({ pane_rows: 4, pane_columns: 80 })), null);
|
||||
|
||||
assert.equal(canSplitZellijPane(pane({ pane_rows: 5, pane_columns: 11 })), true);
|
||||
assert.equal(canSplitZellijPane(pane({ pane_rows: 11, pane_columns: 5 })), true);
|
||||
assert.equal(canSplitZellijPane(pane({ pane_rows: 5, pane_columns: 10 })), false);
|
||||
assert.equal(canSplitZellijPane(pane({ pane_rows: 4, pane_columns: 80 })), false);
|
||||
|
||||
assert.equal(canSplitZellijPane(pane({ pane_rows: 30, pane_columns: 100 }), 80, 20), false);
|
||||
assert.equal(canSplitZellijPane(pane({ pane_rows: 45, pane_columns: 100 }), 80, 20), true);
|
||||
assert.equal(canSplitZellijPane(pane({ pane_rows: 30, pane_columns: 170 }), 80, 20), true);
|
||||
assert.equal(canSplitZellijPane(pane({ pane_rows: 31, pane_columns: 47 }), 50, 10), false);
|
||||
assert.equal(canSplitZellijPane(pane({ pane_rows: 31, pane_columns: 77 }), 50, 10), true);
|
||||
});
|
||||
|
||||
it("uses tab-scoped split only when all Zellij split candidates are safe", () => {
|
||||
const plan = selectZellijPlacement(
|
||||
[
|
||||
pane({ id: 10, tab_id: 1, pane_rows: 40, pane_columns: 120 }),
|
||||
pane({ id: 11, tab_id: 1, pane_rows: 120, pane_columns: 100 }),
|
||||
pane({ id: 12, tab_id: 2, pane_rows: 60, pane_columns: 200 }),
|
||||
],
|
||||
10,
|
||||
);
|
||||
|
||||
assert.deepEqual(plan, {
|
||||
mode: "split",
|
||||
anchorPaneId: 11,
|
||||
targetPaneId: 11,
|
||||
tabId: 1,
|
||||
splitDirection: "down",
|
||||
});
|
||||
});
|
||||
|
||||
it("stacks when any Zellij split candidate would fall below Pi's configured minimum", () => {
|
||||
const plan = selectZellijPlacement(
|
||||
[
|
||||
pane({ id: 10, tab_id: 1, pane_rows: 100, pane_columns: 47 }),
|
||||
pane({ id: 11, tab_id: 1, pane_rows: 31, pane_columns: 77 }),
|
||||
],
|
||||
10,
|
||||
50,
|
||||
10,
|
||||
);
|
||||
|
||||
assert.deepEqual(plan, {
|
||||
mode: "stack",
|
||||
anchorPaneId: 11,
|
||||
targetPaneId: 11,
|
||||
tabId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("stacks when Zellij would split a pane below Pi's usable minimum", () => {
|
||||
const plan = selectZellijPlacement(
|
||||
[
|
||||
pane({ id: 10, tab_id: 1, pane_rows: 20, pane_columns: 20 }),
|
||||
pane({ id: 11, tab_id: 1, pane_rows: 18, pane_columns: 60 }),
|
||||
pane({ id: 12, tab_id: 1, pane_rows: 10, pane_columns: 70 }),
|
||||
],
|
||||
10,
|
||||
);
|
||||
|
||||
assert.deepEqual(plan, {
|
||||
mode: "stack",
|
||||
anchorPaneId: 11,
|
||||
targetPaneId: 11,
|
||||
tabId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("never chooses the parent pane as the stack target", () => {
|
||||
const plan = selectZellijStackPlacement(
|
||||
[
|
||||
pane({ id: 10, tab_id: 1, pane_rows: 60, pane_columns: 200 }),
|
||||
pane({ id: 11, tab_id: 1, pane_rows: 10, pane_columns: 20 }),
|
||||
pane({ id: 12, tab_id: 1, pane_rows: 8, pane_columns: 30 }),
|
||||
],
|
||||
10,
|
||||
);
|
||||
|
||||
assert.deepEqual(plan, {
|
||||
mode: "stack",
|
||||
anchorPaneId: 12,
|
||||
targetPaneId: 12,
|
||||
tabId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not stack when the only usable pane is the parent", () => {
|
||||
const plan = selectZellijStackPlacement(
|
||||
[pane({ id: 10, tab_id: 1, pane_rows: 60, pane_columns: 200 })],
|
||||
10,
|
||||
);
|
||||
|
||||
assert.equal(plan, null);
|
||||
});
|
||||
|
||||
it("stacks on the largest usable non-parent pane when none can split", () => {
|
||||
const plan = selectZellijPlacement(
|
||||
[
|
||||
pane({ id: 10, tab_id: 1, pane_rows: 5, pane_columns: 10 }),
|
||||
pane({ id: 11, tab_id: 1, pane_rows: 6, pane_columns: 8 }),
|
||||
pane({ id: 12, tab_id: 2, pane_rows: 60, pane_columns: 200 }),
|
||||
],
|
||||
10,
|
||||
);
|
||||
|
||||
assert.deepEqual(plan, {
|
||||
mode: "stack",
|
||||
anchorPaneId: 11,
|
||||
targetPaneId: 11,
|
||||
tabId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores floating, plugin, exited, unselectable, and other-tab panes", () => {
|
||||
const plan = selectZellijPlacement(
|
||||
[
|
||||
pane({ id: 10, tab_id: 1, pane_rows: 5, pane_columns: 10 }),
|
||||
pane({ id: 11, tab_id: 1, pane_rows: 60, pane_columns: 200, is_floating: true }),
|
||||
pane({ id: 12, tab_id: 1, pane_rows: 60, pane_columns: 200, is_plugin: true }),
|
||||
pane({ id: 13, tab_id: 1, pane_rows: 60, pane_columns: 200, exited: true }),
|
||||
pane({ id: 14, tab_id: 1, pane_rows: 60, pane_columns: 200, is_selectable: false }),
|
||||
pane({ id: 15, tab_id: 2, pane_rows: 60, pane_columns: 200 }),
|
||||
],
|
||||
10,
|
||||
);
|
||||
|
||||
assert.equal(plan, null);
|
||||
});
|
||||
|
||||
it("returns null when the parent pane cannot be found", () => {
|
||||
assert.equal(selectZellijPlacement([pane({ id: 10 })], 99), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCmuxAvailable", () => {
|
||||
it("returns boolean based on CMUX_SOCKET_PATH", () => {
|
||||
// Can't easily mock env in node:test, just verify it returns a boolean
|
||||
const result = isCmuxAvailable();
|
||||
assert.equal(typeof result, "boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWezTermAvailable", () => {
|
||||
it("returns boolean based on WEZTERM_UNIX_SOCKET", () => {
|
||||
const result = isWezTermAvailable();
|
||||
assert.equal(typeof result, "boolean");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user