Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
config.json
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 HazAT
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/30adb156-cfb4-4c47-84ca-dd4aa80cba9f
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```
|
||||||
|
╭─ Subagents ──────────────────────────── 2 running ─╮
|
||||||
|
│ 00:23 Scout: Auth (scout) active · bash 7m │
|
||||||
|
│ 00:45 Scout: DB (scout) waiting 2m │
|
||||||
|
╰────────────────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
For parallel execution, just call `subagent` multiple times — they all run concurrently:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install git:github.com/HazAT/pi-interactive-subagents
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported multiplexers:
|
||||||
|
|
||||||
|
- [cmux](https://github.com/manaflow-ai/cmux)
|
||||||
|
- [tmux](https://github.com/tmux/tmux)
|
||||||
|
- [zellij](https://zellij.dev)
|
||||||
|
- [WezTerm](https://wezfurlong.org/wezterm/) (terminal emulator with built-in multiplexing)
|
||||||
|
|
||||||
|
Start pi inside one of them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmux pi
|
||||||
|
# or
|
||||||
|
tmux new -A -s pi 'pi'
|
||||||
|
# or
|
||||||
|
zellij --session pi # then run: pi
|
||||||
|
# or
|
||||||
|
# just run pi inside WezTerm — no wrapper needed
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional: set `PI_SUBAGENT_MUX=cmux|tmux|zellij|wezterm` to force a specific backend.
|
||||||
|
|
||||||
|
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`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500
|
||||||
|
```
|
||||||
|
|
||||||
|
Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch commands target child surfaces by explicit ID, so focus and command delivery are independent. Note: the `interactive` option controls parent status notifications, not terminal focus.
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
|
||||||
|
### Extensions
|
||||||
|
|
||||||
|
**Subagents** — 4 main-session tools + 3 commands, plus 1 subagent-only tool:
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
| -------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| `subagent` | Spawn a sub-agent in a dedicated multiplexer pane (async — returns immediately) |
|
||||||
|
| `subagent_interrupt` | Interrupt a running Pi-backed subagent's current turn |
|
||||||
|
| `subagents_list` | List available agent definitions |
|
||||||
|
| `subagent_resume` | Resume a previous sub-agent session (async) |
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| -------------------------- | ------------------------------------ |
|
||||||
|
| `/plan` | Start a full planning workflow |
|
||||||
|
| `/iterate` | Fork into a subagent for quick fixes |
|
||||||
|
| `/subagent <agent> <task>` | Spawn a named agent directly |
|
||||||
|
|
||||||
|
### Bundled Agents
|
||||||
|
|
||||||
|
| Agent | Model | Role |
|
||||||
|
| ----------------- | ---------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| **planner** | Opus (medium thinking) | Brainstorming — clarifies requirements, explores approaches, writes plans, creates todos |
|
||||||
|
| **scout** | Haiku | Fast codebase reconnaissance — maps files, patterns, conventions |
|
||||||
|
| **worker** | Sonnet | Implements tasks from todos — writes code, runs tests, makes polished commits |
|
||||||
|
| **reviewer** | Opus (medium thinking) | Reviews code for bugs, security issues, correctness |
|
||||||
|
| **visual-tester** | Sonnet | Visual QA via Chrome CDP — screenshots, responsive testing, interaction testing |
|
||||||
|
|
||||||
|
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. `planner`, or any `/iterate` fork) 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, or per-spawn with `interactive: true|false` on the tool call.
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Named agent with defaults from agent definition
|
||||||
|
subagent({ name: "Scout", agent: "scout", task: "Analyze the codebase..." });
|
||||||
|
|
||||||
|
// Force a full-context fork for this spawn
|
||||||
|
subagent({ name: "Iterate", fork: true, task: "Fix the bug where..." });
|
||||||
|
|
||||||
|
// Agent defaults can choose a different session-mode via frontmatter
|
||||||
|
subagent({ name: "Planner", agent: "planner", task: "Work through the design with me" });
|
||||||
|
|
||||||
|
// Custom working directory
|
||||||
|
subagent({ name: "Designer", agent: "game-designer", cwd: "agents/game-designer", task: "..." });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
| ---------------------- | ------- | -------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| `name` | string | required | Display name (shown in widget and pane title) |
|
||||||
|
| `task` | string | required | Task prompt for the sub-agent |
|
||||||
|
| `agent` | string | — | Load defaults from agent definition |
|
||||||
|
| `fork` | boolean | `false` | Force the full-context fork mode for this spawn, overriding any agent `session-mode` frontmatter |
|
||||||
|
| `interactive` | boolean | derived | Mark this spawn as interactive (don't wake the parent on stall/recovery). Defaults to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit`. |
|
||||||
|
| `model` | string | — | Override agent's default model |
|
||||||
|
| `systemPrompt` | string | — | Append to system prompt |
|
||||||
|
| `skills` | string | — | Comma-separated skill names |
|
||||||
|
| `tools` | string | — | Comma-separated tool names |
|
||||||
|
| `cwd` | string | — | Working directory for the sub-agent (see [Role Folders](#role-folders)) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interrupting a running subagent
|
||||||
|
|
||||||
|
Use `subagent_interrupt` to cancel the active turn of a running Pi-backed subagent:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
subagent_interrupt({ id: "abcd1234" });
|
||||||
|
// or
|
||||||
|
subagent_interrupt({ name: "Scout" });
|
||||||
|
```
|
||||||
|
|
||||||
|
This sends Escape to the child pane, cancelling the in-progress model turn. The subagent session stays alive — the pane, session file, and background polling all remain intact. After the interrupt, the widget immediately moves the child back to `waiting`, and stale pre-interrupt snapshots are ignored. If the child starts work later, newer snapshots return it to `active`; completion, failure, and `caller_ping` still flow through normally.
|
||||||
|
|
||||||
|
This is a turn-level interrupt, not a method for forcibly terminating a subagent session.
|
||||||
|
|
||||||
|
> **Note:** Only Pi-backed subagents are supported. Claude-backed runs will return an error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## caller_ping — Child-to-Parent Help Request
|
||||||
|
|
||||||
|
The `caller_ping` tool lets a subagent request help from its parent agent. When called, the child session **exits** and the parent receives a notification with the help message. The parent can then **resume** the child session with a response using `subagent_resume`.
|
||||||
|
|
||||||
|
**`caller_ping` parameters:**
|
||||||
|
- `message` (required): What you need help with
|
||||||
|
|
||||||
|
**`subagent_resume` parameters:**
|
||||||
|
- `sessionPath` (required): Path to the child session `.jsonl` file
|
||||||
|
- `name` (optional): Display name for the resumed pane (defaults to `Resume`)
|
||||||
|
- `message` (optional): Follow-up prompt to send after resuming
|
||||||
|
- `autoExit` (optional): Whether the resumed session should auto-exit after its next response. Defaults to `true` for autonomous follow-up work; set `false` when resuming for an interactive handoff.
|
||||||
|
|
||||||
|
**Interaction flow:**
|
||||||
|
1. Child calls `caller_ping({ message: "Not sure which schema to use" })`
|
||||||
|
2. Child session exits (like `subagent_done`)
|
||||||
|
3. Parent receives a steer notification: *"Sub-agent Worker needs help: Not sure which schema to use"*
|
||||||
|
4. Parent resumes the child session via `subagent_resume` with the response
|
||||||
|
5. Child picks up where it left off with the parent's guidance
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
// Inside a worker subagent
|
||||||
|
await caller_ping({
|
||||||
|
message: "Found two conflicting migration files — should I use v1 or v2?"
|
||||||
|
});
|
||||||
|
// Session exits here. Parent receives the ping, then resumes this session
|
||||||
|
// with guidance like "Use v2, v1 is deprecated"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `caller_ping` is only available inside subagent contexts. Calling it from a standalone pi session returns an error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The `/plan` Workflow
|
||||||
|
|
||||||
|
The `/plan` command orchestrates a full planning-to-implementation pipeline.
|
||||||
|
|
||||||
|
```
|
||||||
|
/plan Add a dark mode toggle to the settings page
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: Investigation → Quick codebase scan
|
||||||
|
Phase 2: Planning → Interactive planner subagent (user collaborates)
|
||||||
|
Phase 3: Review Plan → Confirm todos, adjust if needed
|
||||||
|
Phase 4: Execute → Scout + sequential workers implement todos
|
||||||
|
Phase 5: Review → Reviewer subagent checks all changes
|
||||||
|
```
|
||||||
|
|
||||||
|
Tab/window titles update to show current phase:
|
||||||
|
|
||||||
|
```
|
||||||
|
🔍 Investigating: dark mode → 💬 Planning: dark mode
|
||||||
|
→ 🔨 Executing: 1/3 → 🔎 Reviewing → ✅ Done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The `/iterate` Workflow
|
||||||
|
|
||||||
|
For quick, focused work without polluting the main session's context.
|
||||||
|
|
||||||
|
```
|
||||||
|
/iterate Fix the off-by-one error in the pagination logic
|
||||||
|
```
|
||||||
|
|
||||||
|
This always forks the current session into a subagent with full conversation context. It does not inherit an agent default `session-mode`. Make the fix, verify it, and exit to return. The main session gets a summary of what was done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom Agents
|
||||||
|
|
||||||
|
Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: my-agent
|
||||||
|
description: Does something specific
|
||||||
|
model: anthropic/claude-sonnet-4-6
|
||||||
|
thinking: minimal
|
||||||
|
tools: read, bash, edit, write
|
||||||
|
session-mode: lineage-only
|
||||||
|
spawning: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# My Agent
|
||||||
|
|
||||||
|
You are a specialized agent that does X...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontmatter Reference
|
||||||
|
|
||||||
|
| 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 | Comma-separated **native pi tools only**: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` |
|
||||||
|
| `skills` | string | Comma-separated skill names to auto-load |
|
||||||
|
| `session-mode` | string | Default child-session mode: `standalone`, `lineage-only`, or `fork` |
|
||||||
|
| `spawning` | boolean | Set `false` to deny all subagent-spawning tools |
|
||||||
|
| `deny-tools` | string | Comma-separated extension tool names to deny |
|
||||||
|
| `auto-exit` | boolean | Auto-shutdown when the agent finishes its turn — no `subagent_done` call needed. If the user sends any input, auto-exit is permanently disabled and the user takes over the session. Recommended for autonomous agents (scout, worker); not for interactive ones (planner). 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", ... })`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
### `session-mode`
|
||||||
|
|
||||||
|
Choose how a subagent session starts:
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
`fork: true` on the tool call always forces the `fork` mode for that specific spawn. `/iterate` uses this explicit override on purpose.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: planner
|
||||||
|
session-mode: lineage-only
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### `auto-exit`
|
||||||
|
|
||||||
|
When set to `true`, the agent session shuts down automatically as soon as the agent finishes its turn — no explicit `subagent_done` call is needed.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- 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 see "Complete your task autonomously." rather than instructions to call `subagent_done`
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
|
||||||
|
- ✅ Autonomous agents (scout, worker, reviewer) that run to completion
|
||||||
|
- ❌ Interactive agents (planner, iterate) 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. Bare spawns with no agent defs (e.g. `/iterate` with `fork: true`) are treated as interactive.
|
||||||
|
|
||||||
|
**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: planner
|
||||||
|
# interactive defaults to true because auto-exit is not set
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Or per spawn:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
subagent({ name: "Scout", agent: "scout", interactive: true, task: "..." });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool Access Control
|
||||||
|
|
||||||
|
By default, every sub-agent can spawn further sub-agents. Control this with frontmatter:
|
||||||
|
|
||||||
|
### `spawning: false`
|
||||||
|
|
||||||
|
Denies all subagent lifecycle tools (`subagent`, `subagent_interrupt`, `subagents_list`, `subagent_resume`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: worker
|
||||||
|
spawning: false
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### `deny-tools`
|
||||||
|
|
||||||
|
Fine-grained control over individual extension tools:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: focused-agent
|
||||||
|
deny-tools: subagent
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Configuration
|
||||||
|
|
||||||
|
| Agent | `spawning` | Rationale |
|
||||||
|
| ---------- | ----------- | -------------------------------------------- |
|
||||||
|
| planner | _(default)_ | Legitimately spawns scouts for investigation |
|
||||||
|
| worker | `false` | Should implement tasks, not delegate |
|
||||||
|
| researcher | `false` | Should research, not spawn |
|
||||||
|
| reviewer | `false` | Should review, not spawn |
|
||||||
|
| scout | `false` | Should gather context, not spawn |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Role Folders
|
||||||
|
|
||||||
|
The `cwd` parameter lets sub-agents start in a specific directory with its own configuration:
|
||||||
|
|
||||||
|
```
|
||||||
|
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..."
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
subagent({ name: "Game Designer", cwd: "agents/game-designer", task: "Design the combat system" });
|
||||||
|
subagent({ name: "SRE", cwd: "agents/sre", task: "Review deployment pipeline" });
|
||||||
|
```
|
||||||
|
|
||||||
|
Set a default `cwd` in agent frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: game-designer
|
||||||
|
cwd: ./agents/game-designer
|
||||||
|
spawning: false
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools Widget
|
||||||
|
|
||||||
|
Every sub-agent session displays a compact tools widget showing available and denied tools. Toggle with `Ctrl+J`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[scout] — 12 tools · 4 denied (Ctrl+J) ← collapsed
|
||||||
|
[scout] — 12 available (Ctrl+J to collapse) ← expanded
|
||||||
|
read, bash, edit, write, todo, ...
|
||||||
|
denied: subagent, subagents_list, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- [pi](https://github.com/badlogic/pi-mono) — the coding agent
|
||||||
|
- One supported multiplexer:
|
||||||
|
- [cmux](https://github.com/manaflow-ai/cmux)
|
||||||
|
- [tmux](https://github.com/tmux/tmux)
|
||||||
|
- [zellij](https://zellij.dev)
|
||||||
|
- [WezTerm](https://wezfurlong.org/wezterm/)
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
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")
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"status": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3809
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"keywords": [
|
||||||
|
"pi-package"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "HazAT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/HazAT/pi-interactive-subagents"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/test.ts",
|
||||||
|
"test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mariozechner/pi-coding-agent": "*",
|
||||||
|
"@mariozechner/pi-tui": "*",
|
||||||
|
"@sinclair/typebox": "*"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./pi-extension/subagents/index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||||
|
"@mariozechner/pi-tui": "^0.65.0",
|
||||||
|
"@sinclair/typebox": "^0.34.49"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export type SubagentActivityPhase = "starting" | "active" | "waiting" | "done";
|
||||||
|
export type SubagentActivityScope = "agent" | "turn" | "provider" | "streaming" | "tool";
|
||||||
|
|
||||||
|
export type SubagentActivityEvent =
|
||||||
|
| "session_start"
|
||||||
|
| "input"
|
||||||
|
| "before_agent_start"
|
||||||
|
| "agent_start"
|
||||||
|
| "agent_end"
|
||||||
|
| "turn_start"
|
||||||
|
| "turn_end"
|
||||||
|
| "before_provider_request"
|
||||||
|
| "after_provider_response"
|
||||||
|
| "message_update"
|
||||||
|
| "tool_execution_start"
|
||||||
|
| "tool_call"
|
||||||
|
| "tool_execution_update"
|
||||||
|
| "tool_result"
|
||||||
|
| "tool_execution_end"
|
||||||
|
| "caller_ping"
|
||||||
|
| "subagent_done"
|
||||||
|
| "session_shutdown";
|
||||||
|
|
||||||
|
export interface SubagentActivityState {
|
||||||
|
version: 1;
|
||||||
|
runningChildId: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
sequence: number;
|
||||||
|
latestEvent: SubagentActivityEvent;
|
||||||
|
phase: SubagentActivityPhase;
|
||||||
|
agentActive: boolean;
|
||||||
|
turnActive: boolean;
|
||||||
|
providerActive: boolean;
|
||||||
|
toolActive: boolean;
|
||||||
|
activeScope?: SubagentActivityScope;
|
||||||
|
activeSince?: number;
|
||||||
|
waitingSince?: number;
|
||||||
|
turnIndex?: number;
|
||||||
|
messageEventType?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName?: string;
|
||||||
|
toolStartedAt?: number;
|
||||||
|
toolEndedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivityReadResult =
|
||||||
|
| { ok: true; activity: SubagentActivityState }
|
||||||
|
| { ok: false; reason: "missing" | "invalid" | "wrong-id"; error?: string };
|
||||||
|
|
||||||
|
export type SubagentShutdownReason = "quit" | "reload" | "new" | "resume" | "fork";
|
||||||
|
|
||||||
|
export interface SubagentActivityRecorder {
|
||||||
|
sessionStart(): void;
|
||||||
|
input(): void;
|
||||||
|
beforeAgentStart(): void;
|
||||||
|
agentStart(): void;
|
||||||
|
agentEndWaiting(): void;
|
||||||
|
agentEndDone(): void;
|
||||||
|
turnStart(turnIndex?: number): void;
|
||||||
|
turnEnd(turnIndex?: number): void;
|
||||||
|
beforeProviderRequest(): void;
|
||||||
|
afterProviderResponse(): void;
|
||||||
|
messageUpdate(messageEventType?: string): void;
|
||||||
|
toolExecutionStart(toolCallId?: string, toolName?: string): void;
|
||||||
|
toolCall(toolCallId?: string, toolName?: string): void;
|
||||||
|
toolExecutionUpdate(toolCallId?: string, toolName?: string): void;
|
||||||
|
toolResult(toolCallId?: string, toolName?: string): void;
|
||||||
|
toolExecutionEnd(toolCallId?: string, toolName?: string): void;
|
||||||
|
callerPing(): void;
|
||||||
|
subagentDone(): void;
|
||||||
|
sessionShutdown(reason: SubagentShutdownReason): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVITY_UPDATE_THROTTLE_MS = 500;
|
||||||
|
const MAX_WRITE_FAILURES = 3;
|
||||||
|
const KNOWN_PHASES = new Set<SubagentActivityPhase>(["starting", "active", "waiting", "done"]);
|
||||||
|
const KNOWN_SCOPES = new Set<SubagentActivityScope>(["agent", "turn", "provider", "streaming", "tool"]);
|
||||||
|
const KNOWN_EVENTS = new Set<SubagentActivityEvent>([
|
||||||
|
"session_start",
|
||||||
|
"input",
|
||||||
|
"before_agent_start",
|
||||||
|
"agent_start",
|
||||||
|
"agent_end",
|
||||||
|
"turn_start",
|
||||||
|
"turn_end",
|
||||||
|
"before_provider_request",
|
||||||
|
"after_provider_response",
|
||||||
|
"message_update",
|
||||||
|
"tool_execution_start",
|
||||||
|
"tool_call",
|
||||||
|
"tool_execution_update",
|
||||||
|
"tool_result",
|
||||||
|
"tool_execution_end",
|
||||||
|
"caller_ping",
|
||||||
|
"subagent_done",
|
||||||
|
"session_shutdown",
|
||||||
|
]);
|
||||||
|
const MAX_ACTIVITY_STRING_LENGTH = 200;
|
||||||
|
|
||||||
|
export function getSubagentActivityFile(artifactDir: string, runningChildId: string): string {
|
||||||
|
return join(artifactDir, "subagent-activity", `${runningChildId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireObject(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (value == null || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
|
||||||
|
return Number.isFinite(object[fieldName]) ? null : `${fieldName} must be finite`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOptionalFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
|
||||||
|
const value = object[fieldName];
|
||||||
|
return value == null || Number.isFinite(value) ? null : `${fieldName} must be finite when present`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInteger(object: Record<string, unknown>, fieldName: string): string | null {
|
||||||
|
return Number.isInteger(object[fieldName]) ? null : `${fieldName} must be an integer`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOptionalInteger(object: Record<string, unknown>, fieldName: string): string | null {
|
||||||
|
const value = object[fieldName];
|
||||||
|
return value == null || Number.isInteger(value) ? null : `${fieldName} must be an integer when present`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBoolean(object: Record<string, unknown>, fieldName: string): string | null {
|
||||||
|
return typeof object[fieldName] === "boolean" ? null : `${fieldName} must be a boolean`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOptionalActivityString(object: Record<string, unknown>, fieldName: string): string | null {
|
||||||
|
const value = object[fieldName];
|
||||||
|
if (value == null) return null;
|
||||||
|
if (typeof value !== "string") return `${fieldName} must be a string when present`;
|
||||||
|
if (/\r|\n/.test(value)) return `${fieldName} must not contain newlines`;
|
||||||
|
return value.length <= MAX_ACTIVITY_STRING_LENGTH ? null : `${fieldName} is too long`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidActivity(error: string): ActivityReadResult {
|
||||||
|
return { ok: false, reason: "invalid", error };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateActivity(value: unknown, expectedRunningChildId: string): ActivityReadResult {
|
||||||
|
const object = requireObject(value);
|
||||||
|
if (!object) return invalidActivity("activity must be an object");
|
||||||
|
if (object.version !== 1) return invalidActivity("unsupported activity version");
|
||||||
|
if (typeof object.runningChildId !== "string") return invalidActivity("runningChildId must be a string");
|
||||||
|
if (object.runningChildId !== expectedRunningChildId) return { ok: false, reason: "wrong-id" };
|
||||||
|
if (typeof object.latestEvent !== "string" || !KNOWN_EVENTS.has(object.latestEvent as SubagentActivityEvent)) {
|
||||||
|
return invalidActivity("unknown latestEvent");
|
||||||
|
}
|
||||||
|
if (typeof object.phase !== "string" || !KNOWN_PHASES.has(object.phase as SubagentActivityPhase)) {
|
||||||
|
return invalidActivity("unknown activity phase");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
object.activeScope != null &&
|
||||||
|
(typeof object.activeScope !== "string" || !KNOWN_SCOPES.has(object.activeScope as SubagentActivityScope))
|
||||||
|
) {
|
||||||
|
return invalidActivity("unknown activeScope");
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = [
|
||||||
|
validateFiniteNumber(object, "createdAt"),
|
||||||
|
validateFiniteNumber(object, "updatedAt"),
|
||||||
|
validateInteger(object, "sequence"),
|
||||||
|
validateBoolean(object, "agentActive"),
|
||||||
|
validateBoolean(object, "turnActive"),
|
||||||
|
validateBoolean(object, "providerActive"),
|
||||||
|
validateBoolean(object, "toolActive"),
|
||||||
|
validateOptionalFiniteNumber(object, "activeSince"),
|
||||||
|
validateOptionalFiniteNumber(object, "waitingSince"),
|
||||||
|
validateOptionalInteger(object, "turnIndex"),
|
||||||
|
validateOptionalFiniteNumber(object, "toolStartedAt"),
|
||||||
|
validateOptionalFiniteNumber(object, "toolEndedAt"),
|
||||||
|
validateOptionalActivityString(object, "messageEventType"),
|
||||||
|
validateOptionalActivityString(object, "toolCallId"),
|
||||||
|
validateOptionalActivityString(object, "toolName"),
|
||||||
|
].find((error) => error != null);
|
||||||
|
if (validationError) return invalidActivity(validationError);
|
||||||
|
|
||||||
|
return { ok: true, activity: object as unknown as SubagentActivityState };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSubagentActivityFile(
|
||||||
|
activityFile: string,
|
||||||
|
expectedRunningChildId: string,
|
||||||
|
): ActivityReadResult {
|
||||||
|
if (!existsSync(activityFile)) return { ok: false, reason: "missing" };
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(readFileSync(activityFile, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { ok: false, reason: "invalid", error: message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateActivity(parsed, expectedRunningChildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSubagentActivityFile(activityFile: string, activity: SubagentActivityState): void {
|
||||||
|
const dir = dirname(activityFile);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const tempFile = join(dir, `${activity.runningChildId}.json.${process.pid}.${activity.sequence}.tmp`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(tempFile, `${JSON.stringify(activity)}\n`, "utf8");
|
||||||
|
renameSync(tempFile, activityFile);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempFile);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
// Temp cleanup is best effort; preserve the original write/rename failure
|
||||||
|
void cleanupError;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNoopRecorder(): SubagentActivityRecorder {
|
||||||
|
return {
|
||||||
|
sessionStart() {},
|
||||||
|
input() {},
|
||||||
|
beforeAgentStart() {},
|
||||||
|
agentStart() {},
|
||||||
|
agentEndWaiting() {},
|
||||||
|
agentEndDone() {},
|
||||||
|
turnStart() {},
|
||||||
|
turnEnd() {},
|
||||||
|
beforeProviderRequest() {},
|
||||||
|
afterProviderResponse() {},
|
||||||
|
messageUpdate() {},
|
||||||
|
toolExecutionStart() {},
|
||||||
|
toolCall() {},
|
||||||
|
toolExecutionUpdate() {},
|
||||||
|
toolResult() {},
|
||||||
|
toolExecutionEnd() {},
|
||||||
|
callerPing() {},
|
||||||
|
subagentDone() {},
|
||||||
|
sessionShutdown() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveState(activity: SubagentActivityState): void {
|
||||||
|
activity.agentActive = false;
|
||||||
|
activity.turnActive = false;
|
||||||
|
activity.providerActive = false;
|
||||||
|
activity.toolActive = false;
|
||||||
|
delete activity.activeScope;
|
||||||
|
delete activity.activeSince;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshActiveScope(activity: SubagentActivityState): void {
|
||||||
|
if (activity.toolActive) {
|
||||||
|
activity.phase = "active";
|
||||||
|
activity.activeScope = "tool";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activity.providerActive) {
|
||||||
|
activity.phase = "active";
|
||||||
|
activity.activeScope = "provider";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activity.turnActive) {
|
||||||
|
activity.phase = "active";
|
||||||
|
activity.activeScope = "turn";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activity.agentActive) {
|
||||||
|
activity.phase = "active";
|
||||||
|
activity.activeScope = "agent";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete activity.activeScope;
|
||||||
|
delete activity.activeSince;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markActive(
|
||||||
|
activity: SubagentActivityState,
|
||||||
|
scope: SubagentActivityScope,
|
||||||
|
now: number,
|
||||||
|
resetActiveSince = false,
|
||||||
|
): void {
|
||||||
|
activity.phase = "active";
|
||||||
|
activity.activeScope = scope;
|
||||||
|
if (activity.activeSince == null || resetActiveSince) activity.activeSince = now;
|
||||||
|
delete activity.waitingSince;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubagentActivityRecorder(params: {
|
||||||
|
runningChildId?: string;
|
||||||
|
activityFile?: string;
|
||||||
|
now?: () => number;
|
||||||
|
}): SubagentActivityRecorder {
|
||||||
|
const runningChildId = params.runningChildId?.trim();
|
||||||
|
const activityFile = params.activityFile?.trim();
|
||||||
|
if (!runningChildId || !activityFile) return createNoopRecorder();
|
||||||
|
|
||||||
|
const now = params.now ?? (() => Date.now());
|
||||||
|
const createdAt = now();
|
||||||
|
const activity: SubagentActivityState = {
|
||||||
|
version: 1,
|
||||||
|
runningChildId,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
sequence: 0,
|
||||||
|
latestEvent: "session_start",
|
||||||
|
phase: "starting",
|
||||||
|
agentActive: false,
|
||||||
|
turnActive: false,
|
||||||
|
providerActive: false,
|
||||||
|
toolActive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let disabled = false;
|
||||||
|
let failureCount = 0;
|
||||||
|
let lastFlushAt = 0;
|
||||||
|
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function clearPendingFlush(): void {
|
||||||
|
if (!pendingFlush) return;
|
||||||
|
clearTimeout(pendingFlush);
|
||||||
|
pendingFlush = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable(): void {
|
||||||
|
disabled = true;
|
||||||
|
clearPendingFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushNow(): void {
|
||||||
|
if (disabled) return;
|
||||||
|
try {
|
||||||
|
writeSubagentActivityFile(activityFile, activity);
|
||||||
|
lastFlushAt = now();
|
||||||
|
failureCount = 0;
|
||||||
|
} catch {
|
||||||
|
failureCount += 1;
|
||||||
|
if (failureCount >= MAX_WRITE_FAILURES) disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush(): void {
|
||||||
|
if (disabled || pendingFlush) return;
|
||||||
|
|
||||||
|
const remainingMs = Math.max(0, ACTIVITY_UPDATE_THROTTLE_MS - (now() - lastFlushAt));
|
||||||
|
if (remainingMs === 0) {
|
||||||
|
flushNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFlush = setTimeout(() => {
|
||||||
|
pendingFlush = null;
|
||||||
|
flushNow();
|
||||||
|
}, remainingMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(
|
||||||
|
latestEvent: SubagentActivityEvent,
|
||||||
|
update: (current: SubagentActivityState, now: number) => void,
|
||||||
|
flush: "immediate" | "throttled",
|
||||||
|
): void {
|
||||||
|
if (disabled) return;
|
||||||
|
if (flush === "immediate") clearPendingFlush();
|
||||||
|
|
||||||
|
const observedAt = now();
|
||||||
|
activity.latestEvent = latestEvent;
|
||||||
|
activity.updatedAt = observedAt;
|
||||||
|
activity.sequence += 1;
|
||||||
|
update(activity, observedAt);
|
||||||
|
|
||||||
|
if (flush === "immediate") flushNow();
|
||||||
|
else scheduleFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDone(latestEvent: SubagentActivityEvent): void {
|
||||||
|
record(latestEvent, (current) => {
|
||||||
|
current.phase = "done";
|
||||||
|
clearActiveState(current);
|
||||||
|
delete current.waitingSince;
|
||||||
|
}, "immediate");
|
||||||
|
disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionStart() {
|
||||||
|
record("session_start", (current) => {
|
||||||
|
current.phase = "starting";
|
||||||
|
clearActiveState(current);
|
||||||
|
delete current.waitingSince;
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
input() {
|
||||||
|
record("input", () => {}, "immediate");
|
||||||
|
},
|
||||||
|
beforeAgentStart() {
|
||||||
|
record("before_agent_start", (current, observedAt) => {
|
||||||
|
current.agentActive = true;
|
||||||
|
markActive(current, "agent", observedAt);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
agentStart() {
|
||||||
|
record("agent_start", (current, observedAt) => {
|
||||||
|
current.agentActive = true;
|
||||||
|
markActive(current, "agent", observedAt);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
agentEndWaiting() {
|
||||||
|
record("agent_end", (current, observedAt) => {
|
||||||
|
clearActiveState(current);
|
||||||
|
current.phase = "waiting";
|
||||||
|
current.waitingSince = observedAt;
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
agentEndDone() {
|
||||||
|
markDone("agent_end");
|
||||||
|
},
|
||||||
|
turnStart(turnIndex) {
|
||||||
|
record("turn_start", (current, observedAt) => {
|
||||||
|
current.agentActive = true;
|
||||||
|
current.turnActive = true;
|
||||||
|
if (turnIndex != null) current.turnIndex = turnIndex;
|
||||||
|
markActive(current, current.toolActive || current.providerActive ? current.activeScope ?? "turn" : "turn", observedAt);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
turnEnd(turnIndex) {
|
||||||
|
record("turn_end", (current) => {
|
||||||
|
current.turnActive = false;
|
||||||
|
current.providerActive = false;
|
||||||
|
current.toolActive = false;
|
||||||
|
if (turnIndex != null) current.turnIndex = turnIndex;
|
||||||
|
refreshActiveScope(current);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
beforeProviderRequest() {
|
||||||
|
record("before_provider_request", (current, observedAt) => {
|
||||||
|
current.providerActive = true;
|
||||||
|
markActive(current, "provider", observedAt, true);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
afterProviderResponse() {
|
||||||
|
record("after_provider_response", (current) => {
|
||||||
|
current.providerActive = false;
|
||||||
|
refreshActiveScope(current);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
messageUpdate(messageEventType) {
|
||||||
|
record("message_update", (current, observedAt) => {
|
||||||
|
current.agentActive = true;
|
||||||
|
current.turnActive = true;
|
||||||
|
current.messageEventType = messageEventType;
|
||||||
|
if (!current.toolActive) markActive(current, "streaming", observedAt);
|
||||||
|
}, "throttled");
|
||||||
|
},
|
||||||
|
toolExecutionStart(toolCallId, toolName) {
|
||||||
|
record("tool_execution_start", (current, observedAt) => {
|
||||||
|
current.toolActive = true;
|
||||||
|
current.toolCallId = toolCallId;
|
||||||
|
current.toolName = toolName;
|
||||||
|
current.toolStartedAt = observedAt;
|
||||||
|
markActive(current, "tool", observedAt, true);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
toolCall(toolCallId, toolName) {
|
||||||
|
record("tool_call", (current, observedAt) => {
|
||||||
|
current.toolActive = true;
|
||||||
|
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||||
|
current.toolName = toolName ?? current.toolName;
|
||||||
|
markActive(current, "tool", observedAt);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
toolExecutionUpdate(toolCallId, toolName) {
|
||||||
|
record("tool_execution_update", (current, observedAt) => {
|
||||||
|
current.toolActive = true;
|
||||||
|
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||||
|
current.toolName = toolName ?? current.toolName;
|
||||||
|
markActive(current, "tool", observedAt);
|
||||||
|
}, "throttled");
|
||||||
|
},
|
||||||
|
toolResult(toolCallId, toolName) {
|
||||||
|
record("tool_result", (current) => {
|
||||||
|
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||||
|
current.toolName = toolName ?? current.toolName;
|
||||||
|
refreshActiveScope(current);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
toolExecutionEnd(toolCallId, toolName) {
|
||||||
|
record("tool_execution_end", (current, observedAt) => {
|
||||||
|
current.toolActive = false;
|
||||||
|
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||||
|
current.toolName = toolName ?? current.toolName;
|
||||||
|
current.toolEndedAt = observedAt;
|
||||||
|
refreshActiveScope(current);
|
||||||
|
}, "immediate");
|
||||||
|
},
|
||||||
|
callerPing() {
|
||||||
|
markDone("caller_ping");
|
||||||
|
},
|
||||||
|
subagentDone() {
|
||||||
|
markDone("subagent_done");
|
||||||
|
},
|
||||||
|
sessionShutdown(reason) {
|
||||||
|
if (reason === "quit") markDone("session_shutdown");
|
||||||
|
else disable();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
|||||||
|
---
|
||||||
|
name: plan
|
||||||
|
description: >
|
||||||
|
Planning workflow. Runs a pre-flight scout, then spawns the planner agent
|
||||||
|
which clarifies WHAT to build and figures out HOW, with the ability to
|
||||||
|
spawn its own scouts/researchers mid-session. Use when asked to "plan",
|
||||||
|
"brainstorm", "I want to build X", or "let's design". Requires the
|
||||||
|
subagents extension and a supported multiplexer (cmux/tmux/zellij).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan
|
||||||
|
|
||||||
|
A planning workflow. A scout maps the relevant codebase, then an interactive planner clarifies intent + requirements and designs the technical approach, producing a `plan.md` and todos.
|
||||||
|
|
||||||
|
**Announce at start:** "Let me take a quick look, then I'll send a scout to map the codebase before we start the planning session."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: Quick Assessment (main session — 30s orientation)
|
||||||
|
↓
|
||||||
|
Phase 2: Scout (autonomous — codebase context)
|
||||||
|
↓
|
||||||
|
Phase 3: Spawn Planner Agent (interactive — clarifies WHAT, plans HOW, creates todos)
|
||||||
|
↓
|
||||||
|
(Planner may spawn its own scouts/researchers mid-session as needed)
|
||||||
|
↓
|
||||||
|
Phase 4: Review Plan & Todos (main session)
|
||||||
|
↓
|
||||||
|
Phase 5: Execute Todos (workers — receive plan + scout context)
|
||||||
|
↓
|
||||||
|
Phase 6: Review
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Quick Assessment
|
||||||
|
|
||||||
|
Quick orientation — just enough to give the scout a focused mission:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la
|
||||||
|
find . -type f -name "*.ts" | head -20 # or relevant extension
|
||||||
|
cat package.json 2>/dev/null | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Spend ~30 seconds. Tech stack, project shape, and the area relevant to the user's request. This tells you what to ask the scout to focus on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Paths
|
||||||
|
|
||||||
|
For a planning run, pick a short `<name>` (e.g. `auth-redesign`) and use a shared directory under `.pi/plans/YYYY-MM-DD-<name>/` for every deliverable. Pass explicit paths in each subagent's task and read them back with the plain `read` tool when a subagent finishes.
|
||||||
|
|
||||||
|
Standard filenames:
|
||||||
|
|
||||||
|
- `.pi/plans/YYYY-MM-DD-<name>/scout-context.md`
|
||||||
|
- `.pi/plans/YYYY-MM-DD-<name>/plan.md`
|
||||||
|
- `.pi/plans/YYYY-MM-DD-<name>/review.md` (optional, for reviewer output)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Scout
|
||||||
|
|
||||||
|
**Always spawn a scout before the planner.** The scout's context feeds into the planning session — it lets the planner skip re-asking questions whose answers live in the code, and gives it a solid base to design from.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
subagent({
|
||||||
|
name: "🔍 Scout",
|
||||||
|
agent: "scout",
|
||||||
|
task: `Analyze the codebase for [user's request area]. Map file structure, key modules, patterns, conventions, and existing code related to [feature area]. Focus on what a planner would need to understand before designing this feature.
|
||||||
|
|
||||||
|
Save your findings to: .pi/plans/YYYY-MM-DD-<name>/scout-context.md`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wait for the scout to finish.** Read the scout's context file with the `read` tool — you'll pass it to the planner.
|
||||||
|
|
||||||
|
The planner can spawn **additional** scouts or researchers mid-session if it hits a factual gap. That's expected — don't try to pre-scout every possible area.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Spawn Planner Agent
|
||||||
|
|
||||||
|
Spawn the interactive planner with the scout's context and the user's request. The planner handles everything from here: clarifying intent, compact requirements engineering, ISC, approach exploration, design validation, premortem, plan artifact, and todos.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
subagent({
|
||||||
|
name: "💬 Planner",
|
||||||
|
agent: "planner",
|
||||||
|
interactive: true,
|
||||||
|
task: `Plan: [what the user wants to build]
|
||||||
|
|
||||||
|
Scout context:
|
||||||
|
[paste scout findings here — file structure, conventions, patterns, relevant code]
|
||||||
|
|
||||||
|
Save the final plan to: .pi/plans/YYYY-MM-DD-<name>/plan.md
|
||||||
|
Create todos tagged with: <name>`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**The user works with the planner.** It will clarify requirements lightly (1-2 rounds of questions, not a deep spec session), propose approaches, validate the design, run a premortem, write the plan, and create todos with mandatory code examples.
|
||||||
|
|
||||||
|
When done, the user presses Ctrl+D and the plan + todos are returned to the main session.
|
||||||
|
|
||||||
|
### The planner may spawn its own specialists
|
||||||
|
|
||||||
|
During the session, the planner can spawn:
|
||||||
|
- **`scout`** — when a design decision depends on existing code it hasn't read
|
||||||
|
- **`researcher`** — when a decision depends on external facts (library tradeoffs, best practices, API behaviors)
|
||||||
|
|
||||||
|
These are internal to the planning session. You'll see them in the multiplexer but don't need to intervene.
|
||||||
|
|
||||||
|
### Optional: extra scout after planning
|
||||||
|
|
||||||
|
If the planner significantly changed scope (new subsystems, areas the original scout didn't cover), spawn another scout targeting the new areas before workers start:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
subagent({
|
||||||
|
name: "🔍 Scout (updated scope)",
|
||||||
|
agent: "scout",
|
||||||
|
task: "The plan changed scope. Gather context for [new areas]. Read the plan at [plan path]. Focus on [specific files/modules the planner identified that weren't in the original scout].",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Fold the new context into the worker tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Review Plan & Todos
|
||||||
|
|
||||||
|
Once the planner closes, read the plan and list todos:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
todo({ action: "list" });
|
||||||
|
```
|
||||||
|
|
||||||
|
Review with the user:
|
||||||
|
|
||||||
|
> "Here's what the planner produced: [brief summary]. Ready to execute, or anything to adjust?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Execute Todos
|
||||||
|
|
||||||
|
Spawn workers sequentially. Each worker gets the plan path and scout context:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Workers execute todos sequentially — one at a time
|
||||||
|
subagent({
|
||||||
|
name: "🔨 Worker 1/N",
|
||||||
|
agent: "worker",
|
||||||
|
task: "Implement TODO-xxxx. Mark the todo as done. Plan: [plan path]\n\nScout context: [paste scout summary from Phase 2, plus any re-scout from Phase 3]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check result, then next todo
|
||||||
|
subagent({
|
||||||
|
name: "🔨 Worker 2/N",
|
||||||
|
agent: "worker",
|
||||||
|
task: "Implement TODO-yyyy. Mark the todo as done. Plan: [plan path]\n\nScout context: [paste scout summary]",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always run workers sequentially in the same git repo** — parallel workers will conflict on commits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Review
|
||||||
|
|
||||||
|
After all todos are complete:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
subagent({
|
||||||
|
name: "Reviewer",
|
||||||
|
agent: "reviewer",
|
||||||
|
interactive: false,
|
||||||
|
task: "Review the recent changes. Plan: [plan path]",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Triage findings:
|
||||||
|
|
||||||
|
- **P0** — Real bugs, security issues → fix now
|
||||||
|
- **P1** — Genuine traps, maintenance dangers → fix before merging
|
||||||
|
- **P2** — Minor issues → fix if quick, note otherwise
|
||||||
|
- **P3** — Nits → skip
|
||||||
|
|
||||||
|
Create todos for P0/P1, run workers to fix, re-review only if fixes were substantial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Completion Checklist
|
||||||
|
|
||||||
|
Before reporting done:
|
||||||
|
|
||||||
|
1. ✅ Scout ran before the planner?
|
||||||
|
2. ✅ Scout context was passed to the planner?
|
||||||
|
3. ✅ All worker todos closed?
|
||||||
|
4. ✅ Every todo has a polished commit (using the `commit` skill)?
|
||||||
|
5. ✅ Reviewer has run?
|
||||||
|
6. ✅ Reviewer findings triaged and addressed?
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-auto-exit",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Auto-exit plugin for pi-spawned Claude sessions"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/on-stop.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop hook for pi-spawned Claude sessions.
|
||||||
|
# Writes a sentinel file when Claude completes autonomously (no user interjection).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Read JSON input from stdin
|
||||||
|
input=$(cat)
|
||||||
|
|
||||||
|
# Guard: if stop_hook_active is true, we're in a loop — bail out
|
||||||
|
stop_hook_active=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('stop_hook_active', False))" 2>/dev/null || echo "False")
|
||||||
|
if [ "$stop_hook_active" = "True" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Guard: only act for pi-spawned sessions
|
||||||
|
if [ -z "${PI_CLAUDE_SENTINEL:-}" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get transcript path
|
||||||
|
transcript_path=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('transcript_path', ''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count real human messages in transcript (not tool results)
|
||||||
|
# Claude's transcript format:
|
||||||
|
# Human message: {"type": "user", "message": {"role": "user", "content": "..."}}
|
||||||
|
# Tool result: {"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", ...}]}}
|
||||||
|
# We only count entries where content is a string (real human input)
|
||||||
|
user_msg_count=$(python3 - "$transcript_path" <<'EOF'
|
||||||
|
import sys, json
|
||||||
|
|
||||||
|
transcript_path = sys.argv[1]
|
||||||
|
count = 0
|
||||||
|
with open(transcript_path, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line)
|
||||||
|
if entry.get('type') != 'user':
|
||||||
|
continue
|
||||||
|
content = entry.get('message', {}).get('content', '')
|
||||||
|
# Real human messages have string content
|
||||||
|
# Tool results have array content with tool_result blocks
|
||||||
|
if isinstance(content, str):
|
||||||
|
count += 1
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
|
print(count)
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always write transcript path so the watcher can copy the session file
|
||||||
|
if [ -n "$transcript_path" ]; then
|
||||||
|
echo "$transcript_path" > "${PI_CLAUDE_SENTINEL}.transcript" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If exactly 1 user message (the initial prompt), this was autonomous — signal completion
|
||||||
|
if [ "$user_msg_count" -eq 1 ]; then
|
||||||
|
# Write last_assistant_message to sentinel so the watcher gets a clean result
|
||||||
|
echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('last_assistant_message', ''))" > "$PI_CLAUDE_SENTINEL" 2>/dev/null || touch "$PI_CLAUDE_SENTINEL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { appendFileSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export interface SessionEntry {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
parentId?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageEntry extends SessionEntry {
|
||||||
|
type: "message";
|
||||||
|
message: {
|
||||||
|
role: "user" | "assistant" | "toolResult";
|
||||||
|
content: Array<{ type: string; text?: string; [key: string]: unknown }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeededSubagentSessionMode = "lineage-only" | "fork";
|
||||||
|
|
||||||
|
function getForkContentLines(parentSessionFile: string): string[] {
|
||||||
|
const raw = readFileSync(parentSessionFile, "utf8");
|
||||||
|
const lines = raw.split("\n").filter((line) => line.trim());
|
||||||
|
|
||||||
|
let truncateAt = lines.length;
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(lines[i]);
|
||||||
|
if (entry.type === "message" && entry.message?.role === "user") {
|
||||||
|
truncateAt = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.slice(0, truncateAt).filter((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line).type !== "session";
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedSubagentSessionFile(params: {
|
||||||
|
mode: SeededSubagentSessionMode;
|
||||||
|
parentSessionFile: string;
|
||||||
|
childSessionFile: string;
|
||||||
|
childCwd: string;
|
||||||
|
}): void {
|
||||||
|
const header = {
|
||||||
|
type: "session",
|
||||||
|
version: 3,
|
||||||
|
id: randomUUID(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
cwd: params.childCwd,
|
||||||
|
parentSession: params.parentSessionFile,
|
||||||
|
};
|
||||||
|
const contentLines =
|
||||||
|
params.mode === "fork" ? getForkContentLines(params.parentSessionFile) : [];
|
||||||
|
const lines = [JSON.stringify(header), ...contentLines];
|
||||||
|
|
||||||
|
mkdirSync(dirname(params.childSessionFile), { recursive: true });
|
||||||
|
writeFileSync(params.childSessionFile, lines.join("\n") + "\n", "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEntries(sessionFile: string): SessionEntry[] {
|
||||||
|
const raw = readFileSync(sessionFile, "utf8");
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => JSON.parse(line) as SessionEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the id of the last entry in the session file (current branch point / leaf).
|
||||||
|
*/
|
||||||
|
export function getLeafId(sessionFile: string): string | null {
|
||||||
|
const entries = readEntries(sessionFile);
|
||||||
|
return entries.length > 0 ? entries[entries.length - 1].id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return entries added after `afterLine` (1-indexed count of existing entries).
|
||||||
|
*/
|
||||||
|
export function getNewEntries(sessionFile: string, afterLine: number): SessionEntry[] {
|
||||||
|
const raw = readFileSync(sessionFile, "utf8");
|
||||||
|
const lines = raw.split("\n").filter((line) => line.trim());
|
||||||
|
return lines.slice(afterLine).map((line) => JSON.parse(line) as SessionEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the last assistant message text in a list of entries.
|
||||||
|
*
|
||||||
|
* Falls back to the `errorMessage` field when the last assistant message has
|
||||||
|
* `stopReason: "error"` and no usable text content — this happens when
|
||||||
|
* auto-retry exhausts on a provider overload / rate limit / server error, and
|
||||||
|
* without this fallback the parent would silently see a stale earlier message.
|
||||||
|
*/
|
||||||
|
export function findLastAssistantMessage(entries: SessionEntry[]): string | null {
|
||||||
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (entry.type !== "message") continue;
|
||||||
|
const msg = entry as MessageEntry;
|
||||||
|
if (msg.message.role !== "assistant") continue;
|
||||||
|
|
||||||
|
const texts = msg.message.content
|
||||||
|
.filter(
|
||||||
|
(block) =>
|
||||||
|
block.type === "text" && typeof block.text === "string" && block.text.trim() !== "",
|
||||||
|
)
|
||||||
|
.map((block) => block.text as string);
|
||||||
|
|
||||||
|
if (texts.length > 0 && texts.join("").trim()) return texts.join("\n");
|
||||||
|
|
||||||
|
const stopReason = (msg.message as { stopReason?: unknown }).stopReason;
|
||||||
|
const errorMessage = (msg.message as { errorMessage?: unknown }).errorMessage;
|
||||||
|
if (
|
||||||
|
stopReason === "error" &&
|
||||||
|
typeof errorMessage === "string" &&
|
||||||
|
errorMessage.trim() !== ""
|
||||||
|
) {
|
||||||
|
return `Subagent error: ${errorMessage.trim()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a branch_summary entry to the session file.
|
||||||
|
* Returns the new entry's id.
|
||||||
|
*/
|
||||||
|
export function appendBranchSummary(
|
||||||
|
sessionFile: string,
|
||||||
|
branchPointId: string,
|
||||||
|
fromId: string | null,
|
||||||
|
summary: string,
|
||||||
|
): string {
|
||||||
|
const id = randomBytes(4).toString("hex");
|
||||||
|
const entry = {
|
||||||
|
type: "branch_summary",
|
||||||
|
id,
|
||||||
|
parentId: branchPointId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
fromId: fromId ?? branchPointId,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
appendFileSync(sessionFile, JSON.stringify(entry) + "\n", "utf8");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the session file to destDir for parallel worker isolation.
|
||||||
|
* Returns the path of the copy.
|
||||||
|
*/
|
||||||
|
export function copySessionFile(sessionFile: string, destDir: string): string {
|
||||||
|
const id = randomBytes(4).toString("hex");
|
||||||
|
const dest = join(destDir, `subagent-${id}.jsonl`);
|
||||||
|
copyFileSync(sessionFile, dest);
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read new entries from sourceFile (after afterLine), append them to targetFile.
|
||||||
|
* Returns the appended entries.
|
||||||
|
*/
|
||||||
|
export function mergeNewEntries(
|
||||||
|
sourceFile: string,
|
||||||
|
targetFile: string,
|
||||||
|
afterLine: number,
|
||||||
|
): SessionEntry[] {
|
||||||
|
const entries = getNewEntries(sourceFile, afterLine);
|
||||||
|
for (const entry of entries) {
|
||||||
|
appendFileSync(targetFile, JSON.stringify(entry) + "\n", "utf8");
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export const SNAPSHOT_STALLED_AFTER_MS = 60_000;
|
||||||
|
export const DEFAULT_STATUS_LINE_LIMIT = 4;
|
||||||
|
export const MAX_STATUS_NAME_LENGTH = 72;
|
||||||
|
export const MAX_STATUS_LINE_LENGTH = 120;
|
||||||
|
|
||||||
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const DEFAULT_STATUS_CONFIG_PATH = join(PACKAGE_ROOT, "config.json");
|
||||||
|
const STATUS_CONFIG_EXAMPLE_PATH = join(PACKAGE_ROOT, "config.json.example");
|
||||||
|
|
||||||
|
export type SubagentStatusKind = "starting" | "active" | "waiting" | "stalled" | "running";
|
||||||
|
export type SubagentStatusSource = "pi" | "claude";
|
||||||
|
export type SubagentStatusTransition = "stalled" | "recovered" | null;
|
||||||
|
export type StatusSnapshotState = "unseen" | "present" | "missing" | "invalid" | "wrong-id";
|
||||||
|
export type StatusActivityPhase = "starting" | "active" | "waiting" | "done";
|
||||||
|
|
||||||
|
export interface StatusConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
lineLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusObservation =
|
||||||
|
| {
|
||||||
|
snapshot: "present";
|
||||||
|
updatedAt: number;
|
||||||
|
sequence: number;
|
||||||
|
phase: StatusActivityPhase;
|
||||||
|
active?: boolean;
|
||||||
|
activeScope?: string;
|
||||||
|
activeSince?: number;
|
||||||
|
waitingSince?: number;
|
||||||
|
latestEvent?: string;
|
||||||
|
activityLabel?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
snapshot: "missing" | "invalid" | "wrong-id";
|
||||||
|
snapshotError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SubagentStatusState {
|
||||||
|
source: SubagentStatusSource;
|
||||||
|
startTimeMs: number;
|
||||||
|
firstObservationAtMs: number | null;
|
||||||
|
lastActivityAtMs: number | null;
|
||||||
|
lastActivitySequence: number | null;
|
||||||
|
localOverrideAtMs: number | null;
|
||||||
|
localOverrideSequence: number | null;
|
||||||
|
activeNow: boolean;
|
||||||
|
activeSinceMs: number | null;
|
||||||
|
activeScope: string | null;
|
||||||
|
waitingSinceMs: number | null;
|
||||||
|
phase: StatusActivityPhase | null;
|
||||||
|
latestEvent: string | null;
|
||||||
|
activityLabel: string | null;
|
||||||
|
snapshotState: StatusSnapshotState;
|
||||||
|
snapshotProblemSinceMs: number | null;
|
||||||
|
snapshotError: string | null;
|
||||||
|
currentKind: SubagentStatusKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusSnapshot {
|
||||||
|
kind: SubagentStatusKind;
|
||||||
|
elapsedMs: number;
|
||||||
|
elapsedText: string;
|
||||||
|
activeSinceMs: number | null;
|
||||||
|
activeDurationText: string | null;
|
||||||
|
activeScope: string | null;
|
||||||
|
waitingSinceMs: number | null;
|
||||||
|
waitingDurationText: string | null;
|
||||||
|
latestEvent: string | null;
|
||||||
|
activityLabel: string | null;
|
||||||
|
snapshotState: StatusSnapshotState;
|
||||||
|
snapshotError: string | null;
|
||||||
|
snapshotProblemText: string | null;
|
||||||
|
statusLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CappedStatusLines {
|
||||||
|
visibleLines: string[];
|
||||||
|
overflow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidStatusConfig(source: string, message: string): never {
|
||||||
|
throw new Error(`Invalid subagent status config in ${source}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireObject(value: unknown, source: string, fieldName: string): Record<string, unknown> {
|
||||||
|
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
invalidStatusConfig(source, `${fieldName} must be an object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBoolean(value: unknown, source: string, fieldName: string): boolean {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
invalidStatusConfig(source, `${fieldName} must be a boolean`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUnsupportedKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
allowedKeys: string[],
|
||||||
|
source: string,
|
||||||
|
fieldName: string,
|
||||||
|
): void {
|
||||||
|
const unsupportedKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key));
|
||||||
|
if (unsupportedKeys.length > 0) {
|
||||||
|
invalidStatusConfig(source, `${fieldName} has unsupported key(s): ${unsupportedKeys.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateText(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
if (maxLength <= 1) return text.slice(0, maxLength);
|
||||||
|
return `${text.slice(0, maxLength - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStatusName(name: string): string {
|
||||||
|
const collapsed = name.replace(/\s+/g, " ").trim() || "subagent";
|
||||||
|
return truncateText(collapsed, MAX_STATUS_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundStatusLine(line: string): string {
|
||||||
|
return truncateText(line.replace(/\s+/g, " ").trim(), MAX_STATUS_LINE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotProblemLabel(snapshotState: StatusSnapshotState): string | null {
|
||||||
|
if (snapshotState === "wrong-id") return "wrong activity id";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityLabel(snapshot: Pick<StatusSnapshot, "activityLabel" | "activeScope">): string | null {
|
||||||
|
return snapshot.activityLabel ?? snapshot.activeScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatusConfig(rawConfig: unknown, source = "config.json"): StatusConfig {
|
||||||
|
const config = requireObject(rawConfig, source, "root");
|
||||||
|
const status = requireObject(config.status, source, "status");
|
||||||
|
rejectUnsupportedKeys(status, ["enabled"], source, "status");
|
||||||
|
const enabled = requireBoolean(status.enabled, source, "status.enabled");
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
lineLimit: DEFAULT_STATUS_LINE_LIMIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStatusConfigFile(configPath: string, examplePath: string): { sourcePath: string; rawConfig: string } {
|
||||||
|
try {
|
||||||
|
return { sourcePath: configPath, rawConfig: readFileSync(configPath, "utf8") };
|
||||||
|
} catch (error) {
|
||||||
|
const errno = error as NodeJS.ErrnoException;
|
||||||
|
if (errno.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { sourcePath: examplePath, rawConfig: readFileSync(examplePath, "utf8") };
|
||||||
|
} catch (error) {
|
||||||
|
const errno = error as NodeJS.ErrnoException;
|
||||||
|
if (errno.code === "ENOENT") {
|
||||||
|
throw new Error(
|
||||||
|
`Missing subagent status config. Expected ${configPath} or ${examplePath}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStatusConfig(
|
||||||
|
configPath = DEFAULT_STATUS_CONFIG_PATH,
|
||||||
|
examplePath = STATUS_CONFIG_EXAMPLE_PATH,
|
||||||
|
): StatusConfig {
|
||||||
|
const { sourcePath, rawConfig } = readStatusConfigFile(configPath, examplePath);
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawConfig) as unknown;
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Invalid JSON in subagent config ${sourcePath}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseStatusConfig(parsed, sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElapsedDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||||
|
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatusState(params: {
|
||||||
|
source: SubagentStatusSource;
|
||||||
|
startTimeMs: number;
|
||||||
|
}): SubagentStatusState {
|
||||||
|
const initialKind = params.source === "claude" ? "running" : "starting";
|
||||||
|
return {
|
||||||
|
source: params.source,
|
||||||
|
startTimeMs: params.startTimeMs,
|
||||||
|
firstObservationAtMs: null,
|
||||||
|
lastActivityAtMs: null,
|
||||||
|
lastActivitySequence: null,
|
||||||
|
localOverrideAtMs: null,
|
||||||
|
localOverrideSequence: null,
|
||||||
|
activeNow: false,
|
||||||
|
activeSinceMs: null,
|
||||||
|
activeScope: null,
|
||||||
|
waitingSinceMs: null,
|
||||||
|
phase: null,
|
||||||
|
latestEvent: null,
|
||||||
|
activityLabel: null,
|
||||||
|
snapshotState: params.source === "claude" ? "unseen" : "unseen",
|
||||||
|
snapshotProblemSinceMs: null,
|
||||||
|
snapshotError: null,
|
||||||
|
currentKind: initialKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeStatus(
|
||||||
|
state: SubagentStatusState,
|
||||||
|
observation: StatusObservation,
|
||||||
|
now: number,
|
||||||
|
): SubagentStatusState {
|
||||||
|
if (state.source === "claude") return state;
|
||||||
|
|
||||||
|
if (observation.snapshot !== "present") {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
||||||
|
snapshotState: observation.snapshot,
|
||||||
|
snapshotProblemSinceMs: state.snapshotProblemSinceMs ?? now,
|
||||||
|
snapshotError: observation.snapshotError ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = observation.updatedAt;
|
||||||
|
const sequence = observation.sequence;
|
||||||
|
const lastActivityAtMs = state.lastActivityAtMs;
|
||||||
|
const lastActivitySequence = state.lastActivitySequence;
|
||||||
|
const olderThanLastActivity = lastActivityAtMs != null && (
|
||||||
|
updatedAt < lastActivityAtMs ||
|
||||||
|
(updatedAt === lastActivityAtMs && lastActivitySequence != null && sequence < lastActivitySequence)
|
||||||
|
);
|
||||||
|
if (olderThanLastActivity) return state;
|
||||||
|
|
||||||
|
const blockedByLocalOverride = state.localOverrideAtMs != null && (
|
||||||
|
updatedAt < state.localOverrideAtMs ||
|
||||||
|
(updatedAt === state.localOverrideAtMs && state.localOverrideSequence != null && sequence <= state.localOverrideSequence)
|
||||||
|
);
|
||||||
|
if (blockedByLocalOverride) return state;
|
||||||
|
|
||||||
|
const phase = observation.phase;
|
||||||
|
const activeNow = phase === "active" || observation.active === true;
|
||||||
|
const activeSinceMs = activeNow
|
||||||
|
? observation.activeSince ?? state.activeSinceMs ?? updatedAt
|
||||||
|
: null;
|
||||||
|
const waitingSinceMs = phase === "waiting"
|
||||||
|
? observation.waitingSince ?? state.waitingSinceMs ?? updatedAt
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
||||||
|
lastActivityAtMs: updatedAt,
|
||||||
|
lastActivitySequence: sequence,
|
||||||
|
activeNow,
|
||||||
|
activeSinceMs,
|
||||||
|
activeScope: activeNow ? observation.activeScope ?? null : null,
|
||||||
|
waitingSinceMs,
|
||||||
|
phase,
|
||||||
|
latestEvent: observation.latestEvent ?? null,
|
||||||
|
activityLabel: observation.activityLabel ?? null,
|
||||||
|
snapshotState: "present",
|
||||||
|
snapshotProblemSinceMs: null,
|
||||||
|
snapshotError: null,
|
||||||
|
localOverrideAtMs: null,
|
||||||
|
localOverrideSequence: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forceStatusAfterInterrupt(state: SubagentStatusState, now: number): SubagentStatusState {
|
||||||
|
if (state.source === "claude") return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
||||||
|
lastActivityAtMs: now,
|
||||||
|
localOverrideAtMs: now,
|
||||||
|
localOverrideSequence: state.lastActivitySequence,
|
||||||
|
activeNow: false,
|
||||||
|
activeSinceMs: null,
|
||||||
|
activeScope: null,
|
||||||
|
waitingSinceMs: now,
|
||||||
|
phase: "waiting",
|
||||||
|
latestEvent: "interrupt_requested",
|
||||||
|
activityLabel: "interrupted",
|
||||||
|
snapshotState: "present",
|
||||||
|
snapshotProblemSinceMs: null,
|
||||||
|
snapshotError: null,
|
||||||
|
currentKind: "waiting",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyProblemState(state: SubagentStatusState, now: number): Pick<StatusSnapshot, "kind" | "statusLabel"> {
|
||||||
|
const problemLabel = snapshotProblemLabel(state.snapshotState);
|
||||||
|
const hasValidSnapshot = state.lastActivityAtMs != null;
|
||||||
|
|
||||||
|
if (!hasValidSnapshot) {
|
||||||
|
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
|
||||||
|
const elapsedMs = Math.max(0, now - referenceMs);
|
||||||
|
return elapsedMs >= SNAPSHOT_STALLED_AFTER_MS
|
||||||
|
? { kind: "stalled", statusLabel: problemLabel }
|
||||||
|
: { kind: "starting", statusLabel: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const problemSinceMs = state.snapshotProblemSinceMs ?? now;
|
||||||
|
const problemMs = Math.max(0, now - problemSinceMs);
|
||||||
|
if (problemMs >= SNAPSHOT_STALLED_AFTER_MS) return { kind: "stalled", statusLabel: problemLabel };
|
||||||
|
|
||||||
|
const lastHealthyKind = state.activeNow
|
||||||
|
? "active"
|
||||||
|
: state.waitingSinceMs != null || state.phase === "done"
|
||||||
|
? "waiting"
|
||||||
|
: state.currentKind === "stalled"
|
||||||
|
? "starting"
|
||||||
|
: state.currentKind;
|
||||||
|
return { kind: lastHealthyKind, statusLabel: problemLabel };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyStatus(state: SubagentStatusState, now: number): StatusSnapshot {
|
||||||
|
const elapsedMs = Math.max(0, now - state.startTimeMs);
|
||||||
|
const elapsedText = formatElapsedDuration(elapsedMs);
|
||||||
|
|
||||||
|
if (state.source === "claude") {
|
||||||
|
return {
|
||||||
|
kind: "running",
|
||||||
|
elapsedMs,
|
||||||
|
elapsedText,
|
||||||
|
activeSinceMs: null,
|
||||||
|
activeDurationText: null,
|
||||||
|
activeScope: null,
|
||||||
|
waitingSinceMs: null,
|
||||||
|
waitingDurationText: null,
|
||||||
|
latestEvent: null,
|
||||||
|
activityLabel: null,
|
||||||
|
snapshotState: state.snapshotState,
|
||||||
|
snapshotError: null,
|
||||||
|
snapshotProblemText: null,
|
||||||
|
statusLabel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind: SubagentStatusKind;
|
||||||
|
let statusLabel: string | null = null;
|
||||||
|
|
||||||
|
if (state.snapshotState === "present") {
|
||||||
|
if (state.phase === "active" || state.activeNow) {
|
||||||
|
kind = "active";
|
||||||
|
} else if (state.phase === "waiting") {
|
||||||
|
kind = "waiting";
|
||||||
|
} else if (state.phase === "done") {
|
||||||
|
kind = "waiting";
|
||||||
|
statusLabel = "done";
|
||||||
|
} else {
|
||||||
|
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
|
||||||
|
const elapsedSinceObservationMs = Math.max(0, now - referenceMs);
|
||||||
|
kind = elapsedSinceObservationMs >= SNAPSHOT_STALLED_AFTER_MS ? "stalled" : "starting";
|
||||||
|
statusLabel = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const classified = classifyProblemState(state, now);
|
||||||
|
kind = classified.kind;
|
||||||
|
statusLabel = classified.statusLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeDurationText = state.activeSinceMs == null
|
||||||
|
? null
|
||||||
|
: formatElapsedDuration(now - state.activeSinceMs);
|
||||||
|
const waitingDurationText = state.waitingSinceMs == null
|
||||||
|
? null
|
||||||
|
: formatElapsedDuration(now - state.waitingSinceMs);
|
||||||
|
const snapshotProblemText = state.snapshotProblemSinceMs == null
|
||||||
|
? null
|
||||||
|
: formatElapsedDuration(now - state.snapshotProblemSinceMs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
elapsedMs,
|
||||||
|
elapsedText,
|
||||||
|
activeSinceMs: state.activeSinceMs,
|
||||||
|
activeDurationText,
|
||||||
|
activeScope: state.activeScope,
|
||||||
|
waitingSinceMs: state.waitingSinceMs,
|
||||||
|
waitingDurationText,
|
||||||
|
latestEvent: state.latestEvent,
|
||||||
|
activityLabel: state.activityLabel,
|
||||||
|
snapshotState: state.snapshotState,
|
||||||
|
snapshotError: state.snapshotError,
|
||||||
|
snapshotProblemText,
|
||||||
|
statusLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceStatusState(
|
||||||
|
state: SubagentStatusState,
|
||||||
|
now: number,
|
||||||
|
): {
|
||||||
|
nextState: SubagentStatusState;
|
||||||
|
snapshot: StatusSnapshot;
|
||||||
|
transition: SubagentStatusTransition;
|
||||||
|
} {
|
||||||
|
const snapshot = classifyStatus(state, now);
|
||||||
|
const transition =
|
||||||
|
state.currentKind !== "stalled" && snapshot.kind === "stalled"
|
||||||
|
? "stalled"
|
||||||
|
: state.currentKind === "stalled" && (snapshot.kind === "active" || snapshot.kind === "waiting")
|
||||||
|
? "recovered"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot,
|
||||||
|
transition,
|
||||||
|
nextState: {
|
||||||
|
...state,
|
||||||
|
currentKind: snapshot.kind,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActiveDetail(snapshot: StatusSnapshot): string {
|
||||||
|
const label = activityLabel(snapshot);
|
||||||
|
if (!label) return "active";
|
||||||
|
const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
|
||||||
|
return `active (${label}${duration})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWaitingDetail(snapshot: StatusSnapshot): string {
|
||||||
|
const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
|
||||||
|
return `waiting${duration}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStalledDetail(snapshot: StatusSnapshot): string {
|
||||||
|
const detail = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
|
||||||
|
const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
|
||||||
|
return `stalled${duration}${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStatusLine(name: string, snapshot: StatusSnapshot): string {
|
||||||
|
const boundedName = normalizeStatusName(name);
|
||||||
|
|
||||||
|
if (snapshot.kind === "starting") {
|
||||||
|
const label = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
|
||||||
|
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, starting${label}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.kind === "running") {
|
||||||
|
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.kind === "active") {
|
||||||
|
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatActiveDetail(snapshot)}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.kind === "waiting") {
|
||||||
|
const problem = snapshot.statusLabel && snapshot.statusLabel !== "done"
|
||||||
|
? ` (${snapshot.statusLabel})`
|
||||||
|
: snapshot.statusLabel === "done"
|
||||||
|
? " (done)"
|
||||||
|
: "";
|
||||||
|
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatWaitingDetail(snapshot)}${problem}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatStalledDetail(snapshot)}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTransitionLine(
|
||||||
|
name: string,
|
||||||
|
snapshot: StatusSnapshot,
|
||||||
|
transition: Exclude<SubagentStatusTransition, null>,
|
||||||
|
): string {
|
||||||
|
const boundedName = normalizeStatusName(name);
|
||||||
|
|
||||||
|
if (transition === "recovered") {
|
||||||
|
const detail = snapshot.kind === "waiting" ? formatWaitingDetail(snapshot) : formatActiveDetail(snapshot);
|
||||||
|
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, recovered; ${detail}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatStatusLine(boundedName, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capStatusLines(lines: string[], lineLimit: number): CappedStatusLines {
|
||||||
|
const visibleLines = lines.slice(0, lineLimit);
|
||||||
|
return {
|
||||||
|
visibleLines,
|
||||||
|
overflow: Math.max(0, lines.length - visibleLines.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStatusAggregate(lines: string[], lineLimit: number): string {
|
||||||
|
const { visibleLines, overflow } = capStatusLines(lines, lineLimit);
|
||||||
|
const bulletLines = visibleLines.map((line) => `• ${line}`);
|
||||||
|
if (overflow > 0) bulletLines.push(`• +${overflow} more running.`);
|
||||||
|
return `Subagent status:\n${bulletLines.join("\n")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* Extension loaded into sub-agents.
|
||||||
|
* - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+J)
|
||||||
|
* - Provides a `subagent_done` tool for autonomous agents to self-terminate
|
||||||
|
*/
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Box, Text } from "@mariozechner/pi-tui";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { createSubagentActivityRecorder } from "./activity.ts";
|
||||||
|
|
||||||
|
export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
|
||||||
|
return agentStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAutoExitOnAgentEnd(
|
||||||
|
_userTookOver: boolean,
|
||||||
|
messages: any[] | undefined,
|
||||||
|
): boolean {
|
||||||
|
// Manual input should not strand an auto-exit subagent. If the latest agent
|
||||||
|
// turn completed normally, close the session. Escape/abort still leaves it
|
||||||
|
// open for inspection or another prompt.
|
||||||
|
//
|
||||||
|
// stopReason: "error" (e.g. exhausted retries on a provider overload) also
|
||||||
|
// returns true — we want to shut down so the parent is woken up — but we
|
||||||
|
// pair this with findLatestAssistantError() so the parent learns it was an
|
||||||
|
// error, not a clean completion.
|
||||||
|
if (messages) {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (msg?.role === "assistant") {
|
||||||
|
return msg.stopReason !== "aborted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentErrorInfo {
|
||||||
|
errorMessage: string;
|
||||||
|
stopReason: "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the last assistant message in the turn ended with `stopReason: "error"`
|
||||||
|
* (typically auto-retry exhausted on an overload / rate limit / server error),
|
||||||
|
* return its error info so the parent orchestrator can surface a clear
|
||||||
|
* failure instead of silently treating the run as completed.
|
||||||
|
*
|
||||||
|
* Returns `null` when the latest assistant turn completed normally or was
|
||||||
|
* aborted by the user (handled separately by shouldAutoExitOnAgentEnd).
|
||||||
|
*/
|
||||||
|
export function findLatestAssistantError(
|
||||||
|
messages: any[] | undefined,
|
||||||
|
): SubagentErrorInfo | null {
|
||||||
|
if (!messages) return null;
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (msg?.role !== "assistant") continue;
|
||||||
|
if (msg.stopReason !== "error") return null;
|
||||||
|
const raw = typeof msg.errorMessage === "string" ? msg.errorMessage.trim() : "";
|
||||||
|
return {
|
||||||
|
errorMessage: raw || "Subagent agent loop ended with stopReason=error (no errorMessage field).",
|
||||||
|
stopReason: "error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeniedTools(rawValue: string | undefined): string[] {
|
||||||
|
return (rawValue ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let toolNames: string[] = [];
|
||||||
|
let denied: string[] = [];
|
||||||
|
let expanded = false;
|
||||||
|
|
||||||
|
// Read subagent identity from env vars (set by parent orchestrator)
|
||||||
|
const subagentName = process.env.PI_SUBAGENT_NAME ?? "";
|
||||||
|
const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? "";
|
||||||
|
const deniedToolsValue = process.env.PI_DENY_TOOLS;
|
||||||
|
const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1";
|
||||||
|
const recorder = createSubagentActivityRecorder({
|
||||||
|
runningChildId: process.env.PI_SUBAGENT_ID,
|
||||||
|
activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) {
|
||||||
|
ctx.ui.setWidget(
|
||||||
|
"subagent-tools",
|
||||||
|
(_tui: any, theme: any) => {
|
||||||
|
const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text));
|
||||||
|
|
||||||
|
const label = subagentAgent || subagentName;
|
||||||
|
const agentTag = label ? theme.bold(theme.fg("accent", `[${label}]`)) : "";
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
// Expanded: full tool list + denied
|
||||||
|
const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
|
||||||
|
const hint = theme.fg("muted", " (Ctrl+J to collapse)");
|
||||||
|
|
||||||
|
const toolList = toolNames
|
||||||
|
.map((name: string) => theme.fg("dim", name))
|
||||||
|
.join(theme.fg("muted", ", "));
|
||||||
|
|
||||||
|
let deniedLine = "";
|
||||||
|
if (denied.length > 0) {
|
||||||
|
const deniedList = denied
|
||||||
|
.map((name: string) => theme.fg("error", name))
|
||||||
|
.join(theme.fg("muted", ", "));
|
||||||
|
deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = new Text(
|
||||||
|
`${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
box.addChild(content);
|
||||||
|
} else {
|
||||||
|
// Collapsed: one-line summary
|
||||||
|
const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`);
|
||||||
|
const deniedInfo =
|
||||||
|
denied.length > 0
|
||||||
|
? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`)
|
||||||
|
: "";
|
||||||
|
const hint = theme.fg("muted", " (Ctrl+J to expand)");
|
||||||
|
|
||||||
|
const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0);
|
||||||
|
box.addChild(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return box;
|
||||||
|
},
|
||||||
|
{ placement: "aboveEditor" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userTookOver = false;
|
||||||
|
let agentStarted = false;
|
||||||
|
|
||||||
|
// Show widget + status bar on session start
|
||||||
|
pi.on("session_start", (_event, ctx) => {
|
||||||
|
recorder.sessionStart();
|
||||||
|
const tools = pi.getAllTools();
|
||||||
|
toolNames = tools.map((t) => t.name).sort();
|
||||||
|
denied = parseDeniedTools(deniedToolsValue);
|
||||||
|
|
||||||
|
renderWidget(ctx, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("input", () => {
|
||||||
|
recorder.input();
|
||||||
|
// 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;
|
||||||
|
userTookOver = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_agent_start", () => {
|
||||||
|
recorder.beforeAgentStart();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_start", () => {
|
||||||
|
agentStarted = true;
|
||||||
|
recorder.agentStart();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", (event, ctx) => {
|
||||||
|
const messages = (event as any).messages as any[] | undefined;
|
||||||
|
const shouldExit = autoExit && shouldAutoExitOnAgentEnd(userTookOver, messages);
|
||||||
|
|
||||||
|
if (shouldExit) {
|
||||||
|
// Surface stopReason: "error" turns (auto-retry exhausted, provider
|
||||||
|
// overload, etc.) to the parent via the .exit sidecar so the watcher
|
||||||
|
// can report a clear failure with the underlying error message.
|
||||||
|
// Without this the parent would only see exit code 0 and a stale
|
||||||
|
// assistant message, mistaking the crash for a successful completion.
|
||||||
|
const errorInfo = findLatestAssistantError(messages);
|
||||||
|
const sessionFile = process.env.PI_SUBAGENT_SESSION;
|
||||||
|
if (errorInfo && sessionFile) {
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
`${sessionFile}.exit`,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
errorMessage: errorInfo.errorMessage,
|
||||||
|
stopReason: errorInfo.stopReason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best effort — even without the sidecar, watcher's session-file
|
||||||
|
// fallback can still recover the errorMessage.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.agentEndDone();
|
||||||
|
ctx.shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.agentEndWaiting();
|
||||||
|
if (autoExit) {
|
||||||
|
// Reset any recorded manual input marker. Auto-exit is decided by whether
|
||||||
|
// the latest agent turn completed normally, not by who initiated it.
|
||||||
|
userTookOver = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_start", (event) => {
|
||||||
|
recorder.turnStart((event as any).turnIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_end", (event) => {
|
||||||
|
recorder.turnEnd((event as any).turnIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_provider_request", () => {
|
||||||
|
recorder.beforeProviderRequest();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("after_provider_response", () => {
|
||||||
|
recorder.afterProviderResponse();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("message_update", (event) => {
|
||||||
|
recorder.messageUpdate((event as any).assistantMessageEvent?.type);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_start", (event) => {
|
||||||
|
recorder.toolExecutionStart((event as any).toolCallId, (event as any).toolName);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_call", (event) => {
|
||||||
|
recorder.toolCall((event as any).toolCallId, (event as any).toolName);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_update", (event) => {
|
||||||
|
recorder.toolExecutionUpdate((event as any).toolCallId, (event as any).toolName);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_result", (event) => {
|
||||||
|
recorder.toolResult((event as any).toolCallId, (event as any).toolName);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_end", (event) => {
|
||||||
|
recorder.toolExecutionEnd((event as any).toolCallId, (event as any).toolName);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", (event) => {
|
||||||
|
recorder.sessionShutdown((event as any).reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle expand/collapse with Ctrl+J
|
||||||
|
pi.registerShortcut("ctrl+j", {
|
||||||
|
description: "Toggle subagent tools widget",
|
||||||
|
handler: (ctx) => {
|
||||||
|
expanded = !expanded;
|
||||||
|
renderWidget(ctx, null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "caller_ping",
|
||||||
|
label: "Caller Ping",
|
||||||
|
description:
|
||||||
|
"Send a help request to the parent agent and exit this session. " +
|
||||||
|
"The parent will be notified with your message and can resume this session with a response. " +
|
||||||
|
"Use when you're stuck, need clarification, or need the parent to take action.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
message: Type.String({ description: "What you need help with" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const sessionFile = process.env.PI_SUBAGENT_SESSION;
|
||||||
|
if (!sessionFile) {
|
||||||
|
throw new Error(
|
||||||
|
"caller_ping is only available in subagent contexts. " +
|
||||||
|
"PI_SUBAGENT_SESSION environment variable is not set.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.callerPing();
|
||||||
|
const exitData = {
|
||||||
|
type: "ping" as const,
|
||||||
|
name: process.env.PI_SUBAGENT_NAME ?? "subagent",
|
||||||
|
message: params.message,
|
||||||
|
};
|
||||||
|
writeFileSync(`${sessionFile}.exit`, JSON.stringify(exitData));
|
||||||
|
|
||||||
|
ctx.shutdown();
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Ping sent. Session will exit and parent will be notified." }],
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_done",
|
||||||
|
label: "Subagent Done",
|
||||||
|
description:
|
||||||
|
"Call this tool when you have completed your task. " +
|
||||||
|
"It will close this session and return your results to the main session. " +
|
||||||
|
"Your LAST assistant message before calling this becomes the summary returned to the caller.",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||||
|
const sessionFile = process.env.PI_SUBAGENT_SESSION;
|
||||||
|
recorder.subagentDone();
|
||||||
|
if (sessionFile) {
|
||||||
|
writeFileSync(`${sessionFile}.exit`, JSON.stringify({ type: "done" }));
|
||||||
|
}
|
||||||
|
ctx.shutdown();
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Shutting down subagent session." }],
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
name: test-echo
|
||||||
|
description: Integration test agent — completes simple file-writing tasks
|
||||||
|
model: anthropic/claude-haiku-4-5
|
||||||
|
tools: read, bash, write, edit
|
||||||
|
spawning: false
|
||||||
|
auto-exit: true
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a test agent. Complete the task given to you immediately. Be direct and concise.
|
||||||
|
When asked to write content to a file, do it right away using the bash tool.
|
||||||
|
Do not ask questions. Do not explain. Just execute the task.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: test-ping
|
||||||
|
description: Integration test agent — calls caller_ping instead of completing task
|
||||||
|
model: anthropic/claude-haiku-4-5
|
||||||
|
tools: read, bash
|
||||||
|
spawning: false
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a test agent. When given ANY task, you must call the caller_ping tool with the message set to "PING: " followed by the task text you received.
|
||||||
|
Do NOT complete the task yourself. Do NOT use any other tools. ONLY call caller_ping.
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* Integration test harness for pi-interactive-subagents.
|
||||||
|
*
|
||||||
|
* Provides utilities to:
|
||||||
|
* - Detect available mux backends (cmux, tmux, zellij)
|
||||||
|
* - Create isolated test environments with test agent definitions
|
||||||
|
* - Start real pi sessions in mux surfaces
|
||||||
|
* - Poll for file creation and screen output
|
||||||
|
* - Clean up surfaces and temp files after tests
|
||||||
|
*/
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
cpSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
existsSync,
|
||||||
|
readFileSync,
|
||||||
|
unlinkSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join, resolve, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import {
|
||||||
|
getMuxBackend,
|
||||||
|
createSurface,
|
||||||
|
createSurfaceSplit,
|
||||||
|
sendCommand,
|
||||||
|
sendLongCommand,
|
||||||
|
readScreen,
|
||||||
|
readScreenAsync,
|
||||||
|
closeSurface,
|
||||||
|
sendEscape,
|
||||||
|
shellEscape,
|
||||||
|
parseCmuxFocusedSnapshotFromJson,
|
||||||
|
parseCmuxPaneRefForSurfaceFromJson,
|
||||||
|
type MuxBackend,
|
||||||
|
} from "../../pi-extension/subagents/cmux.ts";
|
||||||
|
|
||||||
|
// Re-export mux primitives for tests
|
||||||
|
export {
|
||||||
|
createSurface,
|
||||||
|
createSurfaceSplit,
|
||||||
|
sendCommand,
|
||||||
|
sendLongCommand,
|
||||||
|
readScreen,
|
||||||
|
readScreenAsync,
|
||||||
|
closeSurface,
|
||||||
|
sendEscape,
|
||||||
|
shellEscape,
|
||||||
|
};
|
||||||
|
export type { MuxBackend };
|
||||||
|
|
||||||
|
// ── Paths ──
|
||||||
|
|
||||||
|
const HARNESS_DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PROJECT_ROOT = resolve(HARNESS_DIR, "../..");
|
||||||
|
const TEST_AGENTS_SRC = join(HARNESS_DIR, "agents");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path to the extension source in the working tree.
|
||||||
|
*
|
||||||
|
* Integration tests must exercise the code on the current branch — NOT the
|
||||||
|
* version installed as a pi-package under `~/.pi/agent/git/...` or the project
|
||||||
|
* mirror under `.pi/git/...`, which stays pinned to the last released tag.
|
||||||
|
*
|
||||||
|
* We force-load this file via `pi -ne -e <path>` in startPi() below so local
|
||||||
|
* edits are always the code under test, regardless of what pi-packages are
|
||||||
|
* installed on the host.
|
||||||
|
*/
|
||||||
|
const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.ts");
|
||||||
|
|
||||||
|
// ── 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";
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
*/
|
||||||
|
export function getAvailableBackends(): MuxBackend[] {
|
||||||
|
const backends: MuxBackend[] = [];
|
||||||
|
const orig = process.env.PI_SUBAGENT_MUX;
|
||||||
|
|
||||||
|
for (const backend of ["cmux", "tmux", "zellij"] as MuxBackend[]) {
|
||||||
|
process.env.PI_SUBAGENT_MUX = backend;
|
||||||
|
try {
|
||||||
|
if (getMuxBackend() === backend) backends.push(backend);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orig === undefined) delete process.env.PI_SUBAGENT_MUX;
|
||||||
|
else process.env.PI_SUBAGENT_MUX = orig;
|
||||||
|
|
||||||
|
return backends;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBackend(backend: MuxBackend): string | undefined {
|
||||||
|
const prev = process.env.PI_SUBAGENT_MUX;
|
||||||
|
process.env.PI_SUBAGENT_MUX = backend;
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreBackend(prev: string | undefined): void {
|
||||||
|
if (prev === undefined) delete process.env.PI_SUBAGENT_MUX;
|
||||||
|
else process.env.PI_SUBAGENT_MUX = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusSurface(backend: MuxBackend, surface: string): void {
|
||||||
|
if (backend === "cmux") {
|
||||||
|
const pane = getSurfacePane(backend, surface);
|
||||||
|
if (pane) execFileSync("cmux", ["focus-pane", "--pane", pane], { encoding: "utf8" });
|
||||||
|
execFileSync("cmux", ["focus-panel", "--panel", surface], { encoding: "utf8" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backend === "tmux") {
|
||||||
|
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Focus helpers are not implemented for ${backend}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFocusedSurface(backend: MuxBackend): string | null {
|
||||||
|
if (backend === "cmux") {
|
||||||
|
const info = execFileSync("cmux", ["identify", "--json"], { encoding: "utf8" });
|
||||||
|
return parseCmuxFocusedSnapshotFromJson(info)?.surfaceRef ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backend === "tmux") {
|
||||||
|
try {
|
||||||
|
const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
const activeLine = panes.split("\n").find((line) => line.endsWith(" 1"));
|
||||||
|
return activeLine?.split(" ")[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Focus helpers are not implemented for ${backend}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSurfacePane(backend: MuxBackend, surface: string): string | null {
|
||||||
|
if (backend === "cmux") {
|
||||||
|
const info = execFileSync("cmux", ["identify", "--surface", surface], { encoding: "utf8" });
|
||||||
|
return parseCmuxPaneRefForSurfaceFromJson(info, surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backend === "tmux") return surface;
|
||||||
|
|
||||||
|
throw new Error(`Pane lookup is not implemented for ${backend}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForFocusedSurface(
|
||||||
|
backend: MuxBackend,
|
||||||
|
surface: string,
|
||||||
|
timeout: number = PI_TIMEOUT,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
if (getFocusedSurface(backend) === surface) return;
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Timeout (${timeout}ms) waiting for focused ${backend} surface ${surface}; ` +
|
||||||
|
`current focus is ${getFocusedSurface(backend) ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test environment ──
|
||||||
|
|
||||||
|
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) */
|
||||||
|
surfaces: string[];
|
||||||
|
/** Temp files to clean up */
|
||||||
|
tempFiles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-integ-"));
|
||||||
|
const agentsDir = join(dir, ".pi", "agents");
|
||||||
|
mkdirSync(agentsDir, { recursive: true });
|
||||||
|
|
||||||
|
// Copy test agent definitions into the project-local agents dir
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dir, backend, surfaces: [], tempFiles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all resources created during the test.
|
||||||
|
*/
|
||||||
|
export function cleanupTestEnv(env: TestEnv): void {
|
||||||
|
for (const surface of env.surfaces) {
|
||||||
|
try {
|
||||||
|
closeSurface(surface);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
for (const file of env.tempFiles) {
|
||||||
|
try {
|
||||||
|
unlinkSync(file);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rmSync(env.dir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a surface and register it for automatic cleanup.
|
||||||
|
*/
|
||||||
|
export function createTrackedSurface(env: TestEnv, name: string): string {
|
||||||
|
const surface = createSurface(name);
|
||||||
|
env.surfaces.push(surface);
|
||||||
|
return surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTrackedSurfaceSplit(
|
||||||
|
env: TestEnv,
|
||||||
|
name: string,
|
||||||
|
direction: "left" | "right" | "up" | "down",
|
||||||
|
fromSurface?: string,
|
||||||
|
): string {
|
||||||
|
const surface = createSurfaceSplit(name, direction, fromSurface);
|
||||||
|
env.surfaces.push(surface);
|
||||||
|
return surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a surface from tracking (after manual close).
|
||||||
|
*/
|
||||||
|
export function untrackSurface(env: TestEnv, surface: string): void {
|
||||||
|
env.surfaces = env.surfaces.filter((s) => s !== surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pi session management ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a pi session in a mux surface with the subagents extension loaded.
|
||||||
|
* Returns immediately — the pi process runs asynchronously in the surface.
|
||||||
|
*
|
||||||
|
* The command ends with a sentinel so we can detect when pi exits:
|
||||||
|
* `pi ...; echo '__TEST_DONE_'$?'__'`
|
||||||
|
*/
|
||||||
|
export function startPi(
|
||||||
|
surface: string,
|
||||||
|
testDir: string,
|
||||||
|
task: string,
|
||||||
|
opts?: { model?: string; extraArgs?: string },
|
||||||
|
): void {
|
||||||
|
const model = opts?.model ?? TEST_MODEL;
|
||||||
|
const extra = opts?.extraArgs ?? "";
|
||||||
|
|
||||||
|
// Force pi to load the working-tree extension (not an installed pi-package
|
||||||
|
// snapshot). `-ne` disables extension auto-discovery, `-e <path>` loads the
|
||||||
|
// current branch's source directly. Without this, the tests silently run
|
||||||
|
// against whatever version is checked out under `~/.pi/agent/git/...`.
|
||||||
|
const cmd = [
|
||||||
|
`cd ${shellEscape(testDir)} &&`,
|
||||||
|
`pi`,
|
||||||
|
`-ne`,
|
||||||
|
`-e ${shellEscape(EXTENSION_SOURCE)}`,
|
||||||
|
`--model ${shellEscape(model)}`,
|
||||||
|
extra,
|
||||||
|
shellEscape(task),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
sendLongCommand(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, {
|
||||||
|
scriptPath: join(testDir, `test-launch-${Date.now()}.sh`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polling helpers ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll until a regex pattern appears in the surface's screen output.
|
||||||
|
* Throws on timeout with the last screen contents for debugging.
|
||||||
|
*/
|
||||||
|
export async function waitForScreen(
|
||||||
|
surface: string,
|
||||||
|
pattern: RegExp,
|
||||||
|
timeout: number = PI_TIMEOUT,
|
||||||
|
lines: number = 200,
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
try {
|
||||||
|
const screen = await readScreenAsync(surface, lines);
|
||||||
|
if (pattern.test(screen)) return screen;
|
||||||
|
} catch {}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalScreen = "";
|
||||||
|
try {
|
||||||
|
finalScreen = readScreen(surface, lines);
|
||||||
|
} catch {}
|
||||||
|
throw new Error(
|
||||||
|
`Timeout (${timeout}ms) waiting for pattern ${pattern}.\nLast screen:\n${finalScreen.slice(-1000)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll until a file exists and optionally matches a content pattern.
|
||||||
|
* Returns the file content on success.
|
||||||
|
*/
|
||||||
|
export async function waitForFile(
|
||||||
|
path: string,
|
||||||
|
timeout: number = PI_TIMEOUT,
|
||||||
|
contentPattern?: RegExp,
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
const content = readFileSync(path, "utf8");
|
||||||
|
if (!contentPattern || contentPattern.test(content)) return content;
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Timeout (${timeout}ms) waiting for file: ${path}` +
|
||||||
|
(contentPattern ? ` matching ${contentPattern}` : ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the pi process in a surface to exit (sentinel detection).
|
||||||
|
* Returns the exit code.
|
||||||
|
*/
|
||||||
|
export async function waitForPiExit(
|
||||||
|
surface: string,
|
||||||
|
timeout: number = PI_TIMEOUT,
|
||||||
|
): Promise<number> {
|
||||||
|
const screen = await waitForScreen(surface, /__TEST_DONE_(\d+)__/, timeout);
|
||||||
|
const match = screen.match(/__TEST_DONE_(\d+)__/);
|
||||||
|
return match ? parseInt(match[1], 10) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ──
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueId(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a temp file for cleanup.
|
||||||
|
*/
|
||||||
|
export function trackTempFile(env: TestEnv, path: string): void {
|
||||||
|
env.tempFiles.push(path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for the multiplexer surface layer.
|
||||||
|
*
|
||||||
|
* These tests exercise real mux operations: creating panes,
|
||||||
|
* sending commands, reading screen output, and closing surfaces.
|
||||||
|
* No LLM calls — fast and free.
|
||||||
|
*
|
||||||
|
* Run inside a supported multiplexer:
|
||||||
|
* cmux bash -c 'npm run test:integration'
|
||||||
|
* tmux new 'npm run test:integration'
|
||||||
|
* zellij --session pi # then run: npm run test:integration
|
||||||
|
*/
|
||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { unlinkSync } from "node:fs";
|
||||||
|
import {
|
||||||
|
getAvailableBackends,
|
||||||
|
setBackend,
|
||||||
|
restoreBackend,
|
||||||
|
createTestEnv,
|
||||||
|
cleanupTestEnv,
|
||||||
|
createTrackedSurface,
|
||||||
|
createTrackedSurfaceSplit,
|
||||||
|
focusSurface,
|
||||||
|
getFocusedSurface,
|
||||||
|
getSurfacePane,
|
||||||
|
waitForFocusedSurface,
|
||||||
|
untrackSurface,
|
||||||
|
sendCommand,
|
||||||
|
sendLongCommand,
|
||||||
|
readScreen,
|
||||||
|
readScreenAsync,
|
||||||
|
closeSurface,
|
||||||
|
sendEscape,
|
||||||
|
sleep,
|
||||||
|
uniqueId,
|
||||||
|
trackTempFile,
|
||||||
|
waitForFile,
|
||||||
|
waitForScreen,
|
||||||
|
type TestEnv,
|
||||||
|
} from "./harness.ts";
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const backend of backends) {
|
||||||
|
describe(`mux-surface [${backend}]`, { timeout: 60_000 }, () => {
|
||||||
|
let prevMux: string | undefined;
|
||||||
|
let env: TestEnv;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
prevMux = setBackend(backend);
|
||||||
|
env = createTestEnv(backend);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
cleanupTestEnv(env);
|
||||||
|
restoreBackend(prevMux);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => {
|
||||||
|
const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right");
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
focusSurface(backend, anchor);
|
||||||
|
await waitForFocusedSurface(backend, anchor, 10_000);
|
||||||
|
|
||||||
|
const childA = createTrackedSurface(env, "focus-child-a");
|
||||||
|
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
||||||
|
assert.equal(getFocusedSurface(backend), 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerA = uniqueId();
|
||||||
|
const markerB = uniqueId();
|
||||||
|
sendCommand(childA, `echo "FOCUS_A_${markerA}"`);
|
||||||
|
sendCommand(childB, `echo "FOCUS_B_${markerB}"`);
|
||||||
|
|
||||||
|
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("creates a surface, sends a command, reads output, and closes it", async () => {
|
||||||
|
const surface = createTrackedSurface(env, "echo-test");
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const marker = uniqueId();
|
||||||
|
sendCommand(surface, `echo "MARKER_${marker}"`);
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
const screen = readScreen(surface, 50);
|
||||||
|
assert.ok(
|
||||||
|
screen.includes(`MARKER_${marker}`),
|
||||||
|
`Expected screen to contain MARKER_${marker}. Got:\n${screen}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
closeSurface(surface);
|
||||||
|
untrackSurface(env, surface);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves shell special characters in echo output", async () => {
|
||||||
|
const surface = createTrackedSurface(env, "escape-test");
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const marker = uniqueId();
|
||||||
|
// Single-quoted string — $ and " are literal inside single quotes
|
||||||
|
sendCommand(surface, `echo 'SPEC_${marker}_$HOME_"quotes"_done'`);
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
const screen = readScreen(surface, 50);
|
||||||
|
assert.ok(
|
||||||
|
screen.includes(`SPEC_${marker}`),
|
||||||
|
`Expected special-char output. Got:\n${screen}`,
|
||||||
|
);
|
||||||
|
// $ should be literal inside single quotes
|
||||||
|
assert.ok(
|
||||||
|
screen.includes("$HOME"),
|
||||||
|
`Expected literal $HOME in output. Got:\n${screen}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a long command via script file without truncation", async () => {
|
||||||
|
const surface = createTrackedSurface(env, "long-cmd-test");
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const marker = uniqueId();
|
||||||
|
const longValue = "X".repeat(500);
|
||||||
|
const command = `echo "LONG_${marker}_${longValue}_END"`;
|
||||||
|
|
||||||
|
sendLongCommand(surface, command);
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const screen = readScreen(surface, 50);
|
||||||
|
assert.ok(
|
||||||
|
screen.includes(`LONG_${marker}`),
|
||||||
|
`Expected long command output. Got:\n${screen.slice(0, 300)}...`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
screen.includes("_END"),
|
||||||
|
`Expected full output (not truncated). Got:\n${screen.slice(-300)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads screen asynchronously", async () => {
|
||||||
|
const surface = createTrackedSurface(env, "async-read-test");
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const marker = uniqueId();
|
||||||
|
sendCommand(surface, `echo "ASYNC_${marker}"`);
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
const screen = await readScreenAsync(surface, 50);
|
||||||
|
assert.ok(
|
||||||
|
screen.includes(`ASYNC_${marker}`),
|
||||||
|
`Async read should find marker. Got:\n${screen}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manages multiple surfaces concurrently", async () => {
|
||||||
|
const s1 = createTrackedSurface(env, "multi-1");
|
||||||
|
const s2 = createTrackedSurface(env, "multi-2");
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
const m1 = uniqueId();
|
||||||
|
const m2 = uniqueId();
|
||||||
|
sendCommand(s1, `echo "S1_${m1}"`);
|
||||||
|
sendCommand(s2, `echo "S2_${m2}"`);
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
const screen1 = readScreen(s1, 50);
|
||||||
|
const screen2 = readScreen(s2, 50);
|
||||||
|
|
||||||
|
assert.ok(screen1.includes(`S1_${m1}`), `Surface 1 missing marker. Got:\n${screen1}`);
|
||||||
|
assert.ok(screen2.includes(`S2_${m2}`), `Surface 2 missing marker. Got:\n${screen2}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes output to a file and verifies via surface", async () => {
|
||||||
|
const surface = createTrackedSurface(env, "file-test");
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const marker = uniqueId();
|
||||||
|
const filePath = `/tmp/pi-mux-test-${marker}.txt`;
|
||||||
|
|
||||||
|
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
|
||||||
|
|
||||||
|
await waitForScreen(surface, new RegExp(`WRITTEN_${marker}`), 10_000, 50);
|
||||||
|
const content = await waitForFile(filePath, 10_000, new RegExp(`FILE_${marker}`));
|
||||||
|
assert.ok(content.includes(`FILE_${marker}`), `File content wrong. Got: ${content}`);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
try {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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'
|
||||||
|
* tmux new 'npm run test:integration'
|
||||||
|
*
|
||||||
|
* Configuration:
|
||||||
|
* PI_TEST_MODEL — model for all pi sessions (default: anthropic/claude-haiku-4-5)
|
||||||
|
* PI_TEST_TIMEOUT — per-test timeout in ms (default: 120000)
|
||||||
|
*/
|
||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||||
|
import {
|
||||||
|
getAvailableBackends,
|
||||||
|
setBackend,
|
||||||
|
restoreBackend,
|
||||||
|
createTestEnv,
|
||||||
|
cleanupTestEnv,
|
||||||
|
createTrackedSurface,
|
||||||
|
startPi,
|
||||||
|
waitForScreen,
|
||||||
|
waitForFile,
|
||||||
|
sleep,
|
||||||
|
uniqueId,
|
||||||
|
trackTempFile,
|
||||||
|
readScreen,
|
||||||
|
PI_TIMEOUT,
|
||||||
|
type TestEnv,
|
||||||
|
} from "./harness.ts";
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
cleanupTestEnv(env);
|
||||||
|
restoreBackend(prevMux);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Basic spawn + completion ──
|
||||||
|
|
||||||
|
it("spawns a subagent that writes a file and verifies the session", async () => {
|
||||||
|
const id = uniqueId();
|
||||||
|
const markerFile = `/tmp/pi-integ-echo-${id}.txt`;
|
||||||
|
trackTempFile(env, markerFile);
|
||||||
|
|
||||||
|
const surface = createTrackedSurface(env, `echo-${id}`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const task = [
|
||||||
|
`Call the subagent tool with these EXACT parameters:`,
|
||||||
|
` name: "Echo-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` task: "Run this bash command: echo 'PASS_${id}' > '${markerFile}'"`,
|
||||||
|
`Do not do anything else. Just call the subagent tool once.`,
|
||||||
|
`After you receive the subagent result, say INTEGRATION_COMPLETE.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
startPi(surface, env.dir, task);
|
||||||
|
|
||||||
|
// Verify: subagent created the marker file
|
||||||
|
const content = await waitForFile(markerFile, PI_TIMEOUT, /PASS/);
|
||||||
|
assert.ok(
|
||||||
|
content.includes(`PASS_${id}`),
|
||||||
|
`Marker file should contain PASS_${id}. Got: ${content.trim()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify: outer pi received the subagent result
|
||||||
|
const screen = await waitForScreen(
|
||||||
|
surface,
|
||||||
|
/INTEGRATION_COMPLETE|completed|Sub-agent.*"Echo/i,
|
||||||
|
PI_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify: session file was created (shown in steer result)
|
||||||
|
const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/);
|
||||||
|
if (sessionMatch) {
|
||||||
|
const sessionFile = sessionMatch[1];
|
||||||
|
assert.ok(existsSync(sessionFile), `Subagent session file should exist: ${sessionFile}`);
|
||||||
|
|
||||||
|
const lines = readFileSync(sessionFile, "utf8").trim().split("\n");
|
||||||
|
assert.ok(lines.length >= 2, `Session should have ≥2 entries, got ${lines.length}`);
|
||||||
|
|
||||||
|
const header = JSON.parse(lines[0]);
|
||||||
|
assert.equal(header.type, "session", "First entry should be session header");
|
||||||
|
assert.ok(header.id, "Session header should have an id");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── In-progress activity snapshots ──
|
||||||
|
|
||||||
|
it("keeps a long active tool call from surfacing false stalled status", async () => {
|
||||||
|
const id = uniqueId();
|
||||||
|
const startFile = `/tmp/pi-integ-status-start-${id}.txt`;
|
||||||
|
const markerFile = `/tmp/pi-integ-status-${id}.txt`;
|
||||||
|
trackTempFile(env, startFile);
|
||||||
|
trackTempFile(env, markerFile);
|
||||||
|
|
||||||
|
const surface = createTrackedSurface(env, `status-${id}`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const task = [
|
||||||
|
`Call the subagent tool with these EXACT parameters:`,
|
||||||
|
` name: "Status-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` task: "Run this bash command: echo 'START_${id}' > '${startFile}'; sleep 90; echo 'STATUS_${id}' > '${markerFile}'"`,
|
||||||
|
`Do not do anything else. Just call the subagent tool once.`,
|
||||||
|
`After you receive the subagent result, say STATUS_TEST_DONE.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
startPi(surface, env.dir, task);
|
||||||
|
|
||||||
|
const activeScreen = await waitForScreen(surface, /active[\s\S]*bash|bash[\s\S]*active/i, PI_TIMEOUT, 300);
|
||||||
|
assert.doesNotMatch(activeScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i);
|
||||||
|
|
||||||
|
await waitForFile(startFile, PI_TIMEOUT, /START_/);
|
||||||
|
assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the long sleep");
|
||||||
|
await sleep(65_000);
|
||||||
|
assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the watchdog assertion");
|
||||||
|
const watchdogScreen = readScreen(surface, 300);
|
||||||
|
assert.doesNotMatch(watchdogScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i);
|
||||||
|
|
||||||
|
const content = await waitForFile(markerFile, PI_TIMEOUT, /STATUS_/);
|
||||||
|
assert.ok(content.includes(`STATUS_${id}`), `Marker file should contain STATUS_${id}`);
|
||||||
|
|
||||||
|
const completionScreen = await waitForScreen(
|
||||||
|
surface,
|
||||||
|
/STATUS_TEST_DONE|completed|Sub-agent.*"Status-/i,
|
||||||
|
PI_TIMEOUT,
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
assert.ok(/STATUS_TEST_DONE|completed/i.test(completionScreen));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Parallel subagent spawn ──
|
||||||
|
|
||||||
|
it("spawns two subagents in parallel and both complete", async () => {
|
||||||
|
const id = uniqueId();
|
||||||
|
const fileA = `/tmp/pi-integ-para-${id}-a.txt`;
|
||||||
|
const fileB = `/tmp/pi-integ-para-${id}-b.txt`;
|
||||||
|
trackTempFile(env, fileA);
|
||||||
|
trackTempFile(env, fileB);
|
||||||
|
|
||||||
|
const surface = createTrackedSurface(env, `parallel-${id}`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const task = [
|
||||||
|
`You must call the subagent tool TWICE. Make both calls before waiting for results.`,
|
||||||
|
``,
|
||||||
|
`First call:`,
|
||||||
|
` name: "ParaA-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` task: "Run: echo 'DONE_A_${id}' > '${fileA}'"`,
|
||||||
|
``,
|
||||||
|
`Second call:`,
|
||||||
|
` name: "ParaB-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` task: "Run: echo 'DONE_B_${id}' > '${fileB}'"`,
|
||||||
|
``,
|
||||||
|
`Call both subagent tools NOW, do not wait between them.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
startPi(surface, env.dir, task);
|
||||||
|
|
||||||
|
// Both marker files should appear
|
||||||
|
const [contentA, contentB] = await Promise.all([
|
||||||
|
waitForFile(fileA, PI_TIMEOUT, /DONE_A/),
|
||||||
|
waitForFile(fileB, PI_TIMEOUT, /DONE_B/),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.ok(contentA.includes(`DONE_A_${id}`), `File A should contain marker`);
|
||||||
|
assert.ok(contentB.includes(`DONE_B_${id}`), `File B should contain marker`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fork mode ──
|
||||||
|
|
||||||
|
it("fork mode creates a child session linked to the parent", async () => {
|
||||||
|
const id = uniqueId();
|
||||||
|
const markerFile = `/tmp/pi-integ-fork-${id}.txt`;
|
||||||
|
trackTempFile(env, markerFile);
|
||||||
|
|
||||||
|
const surface = createTrackedSurface(env, `fork-${id}`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const task = [
|
||||||
|
`Call the subagent tool with these EXACT parameters:`,
|
||||||
|
` name: "Fork-${id}"`,
|
||||||
|
` fork: true`,
|
||||||
|
` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`,
|
||||||
|
`Do not set the agent parameter. Just set name, fork, and task.`,
|
||||||
|
`After you receive the result, say FORK_COMPLETE.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
startPi(surface, env.dir, task);
|
||||||
|
|
||||||
|
// Verify: forked subagent created the file
|
||||||
|
const content = await waitForFile(markerFile, PI_TIMEOUT, /FORK_OK/);
|
||||||
|
assert.ok(content.includes(`FORK_OK_${id}`), `Fork marker file should exist with content`);
|
||||||
|
|
||||||
|
// Wait for the outer pi to show the result
|
||||||
|
const screen = await waitForScreen(
|
||||||
|
surface,
|
||||||
|
/FORK_COMPLETE|completed|Sub-agent.*"Fork/i,
|
||||||
|
PI_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify: the forked session has a parent link
|
||||||
|
const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/);
|
||||||
|
if (sessionMatch) {
|
||||||
|
const sessionFile = sessionMatch[1];
|
||||||
|
assert.ok(existsSync(sessionFile), `Fork session file should exist: ${sessionFile}`);
|
||||||
|
|
||||||
|
const entries = readFileSync(sessionFile, "utf8")
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => JSON.parse(l));
|
||||||
|
const header = entries[0];
|
||||||
|
assert.equal(header.type, "session", "First entry should be session header");
|
||||||
|
assert.ok(header.parentSession, "Fork session should have parentSession field");
|
||||||
|
// Fork sessions include parent context (model_change entries etc.)
|
||||||
|
assert.ok(entries.length >= 2, "Fork session should have context entries beyond header");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── caller_ping ──
|
||||||
|
|
||||||
|
it("subagent caller_ping sends notification back to the parent", async () => {
|
||||||
|
const id = uniqueId();
|
||||||
|
|
||||||
|
const surface = createTrackedSurface(env, `ping-${id}`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const task = [
|
||||||
|
`Call the subagent tool with these EXACT parameters:`,
|
||||||
|
` name: "Ping-${id}"`,
|
||||||
|
` agent: "test-ping"`,
|
||||||
|
` task: "PING_TEST_${id}"`,
|
||||||
|
`Just call the subagent tool once. Do not do anything else before calling it.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
startPi(surface, env.dir, task);
|
||||||
|
|
||||||
|
// The test-ping agent calls caller_ping, which steers a "needs help" message
|
||||||
|
// back to the outer pi. Look for it on screen.
|
||||||
|
const screen = await waitForScreen(
|
||||||
|
surface,
|
||||||
|
/needs help|PING|caller_ping|ping/i,
|
||||||
|
PI_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
/needs help|PING/i.test(screen),
|
||||||
|
`Screen should show ping notification. Got:\n${screen.slice(-800)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent discovery ──
|
||||||
|
|
||||||
|
it("subagent discovers project-local test agents", async () => {
|
||||||
|
const id = uniqueId();
|
||||||
|
const markerFile = `/tmp/pi-integ-discovery-${id}.txt`;
|
||||||
|
trackTempFile(env, markerFile);
|
||||||
|
|
||||||
|
const surface = createTrackedSurface(env, `discovery-${id}`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
// Use subagents_list to verify test agents are discoverable,
|
||||||
|
// then spawn one to prove it works end-to-end.
|
||||||
|
const task = [
|
||||||
|
`First, call the subagents_list tool to see available agents.`,
|
||||||
|
`Then call the subagent tool:`,
|
||||||
|
` name: "Disco-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` task: "Run: echo 'DISCO_${id}' > '${markerFile}'"`,
|
||||||
|
`After you receive the subagent result, say DISCOVERY_DONE.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
startPi(surface, env.dir, task);
|
||||||
|
|
||||||
|
// The test-echo agent (discovered from project .pi/agents/) should work
|
||||||
|
const content = await waitForFile(markerFile, PI_TIMEOUT, /DISCO/);
|
||||||
|
assert.ok(content.includes(`DISCO_${id}`), `Discovery test marker should exist`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Subagent with custom system prompt ──
|
||||||
|
|
||||||
|
it("passes systemPrompt to subagent", async () => {
|
||||||
|
const id = uniqueId();
|
||||||
|
const markerFile = `/tmp/pi-integ-sysprompt-${id}.txt`;
|
||||||
|
trackTempFile(env, markerFile);
|
||||||
|
|
||||||
|
const surface = createTrackedSurface(env, `sysprompt-${id}`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const task = [
|
||||||
|
`Call the subagent tool with these parameters:`,
|
||||||
|
` name: "SysP-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` systemPrompt: "Always start your response with CUSTOM_PROMPT_ACTIVE."`,
|
||||||
|
` task: "Write 'SYSPROMPT_${id}' to ${markerFile} using bash: echo 'SYSPROMPT_${id}' > '${markerFile}'"`,
|
||||||
|
`After the subagent completes, say SYSPROMPT_TEST_DONE.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
startPi(surface, env.dir, task);
|
||||||
|
|
||||||
|
const content = await waitForFile(markerFile, PI_TIMEOUT, /SYSPROMPT/);
|
||||||
|
assert.ok(content.includes(`SYSPROMPT_${id}`), `System prompt test marker should exist`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for the systemPromptMode feature.
|
||||||
|
* Tests: frontmatter parsing, identity routing, CLI flag generation.
|
||||||
|
*/
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function assert(condition: boolean, msg: string) {
|
||||||
|
if (condition) {
|
||||||
|
console.log(` ✅ ${msg}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ ${msg}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extracted logic under test ---
|
||||||
|
|
||||||
|
function parseFrontmatter(content: string) {
|
||||||
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (!match) return null;
|
||||||
|
const frontmatter = match[1];
|
||||||
|
const get = (key: string) => {
|
||||||
|
const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
||||||
|
return m ? m[1].trim() : undefined;
|
||||||
|
};
|
||||||
|
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
|
||||||
|
const spm = get("system-prompt");
|
||||||
|
return {
|
||||||
|
systemPromptMode: spm === "replace" ? "replace" : spm === "append" ? "append" : undefined,
|
||||||
|
body: body || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateRouting(
|
||||||
|
agentBody: string | undefined,
|
||||||
|
systemPromptMode: "append" | "replace" | undefined,
|
||||||
|
paramSystemPrompt: string | undefined,
|
||||||
|
) {
|
||||||
|
const identity = agentBody ?? paramSystemPrompt ?? null;
|
||||||
|
const identityInSystemPrompt = systemPromptMode && identity;
|
||||||
|
const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
|
||||||
|
|
||||||
|
let cliFlag: string | null = null;
|
||||||
|
if (identityInSystemPrompt && identity) {
|
||||||
|
cliFlag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roleBlock, cliFlag, identityInSystemPrompt: !!identityInSystemPrompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fixtures ---
|
||||||
|
|
||||||
|
const AGENT_REPLACE = `---
|
||||||
|
model: anthropic/claude-sonnet-4-20250514
|
||||||
|
system-prompt: replace
|
||||||
|
auto-exit: true
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a specialized agent.`;
|
||||||
|
|
||||||
|
const AGENT_APPEND = `---
|
||||||
|
model: anthropic/claude-sonnet-4-20250514
|
||||||
|
system-prompt: append
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an appended identity.`;
|
||||||
|
|
||||||
|
const AGENT_DEFAULT = `---
|
||||||
|
model: anthropic/claude-sonnet-4-20250514
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a default agent.`;
|
||||||
|
|
||||||
|
const AGENT_INVALID = `---
|
||||||
|
model: anthropic/claude-sonnet-4-20250514
|
||||||
|
system-prompt: foobar
|
||||||
|
---
|
||||||
|
|
||||||
|
Body here.`;
|
||||||
|
|
||||||
|
// --- Test 1: Frontmatter parsing ---
|
||||||
|
console.log("\n🧪 Frontmatter parsing of system-prompt field");
|
||||||
|
|
||||||
|
const r1 = parseFrontmatter(AGENT_REPLACE)!;
|
||||||
|
assert(r1.systemPromptMode === "replace", "system-prompt: replace → mode is 'replace'");
|
||||||
|
assert(r1.body === "You are a specialized agent.", "body extracted correctly");
|
||||||
|
|
||||||
|
const r2 = parseFrontmatter(AGENT_APPEND)!;
|
||||||
|
assert(r2.systemPromptMode === "append", "system-prompt: append → mode is 'append'");
|
||||||
|
|
||||||
|
const r3 = parseFrontmatter(AGENT_DEFAULT)!;
|
||||||
|
assert(r3.systemPromptMode === undefined, "no system-prompt field → mode is undefined");
|
||||||
|
|
||||||
|
const r4 = parseFrontmatter(AGENT_INVALID)!;
|
||||||
|
assert(r4.systemPromptMode === undefined, "system-prompt: foobar → mode is undefined (ignored)");
|
||||||
|
|
||||||
|
// --- Test 2: Identity routing ---
|
||||||
|
console.log("\n🧪 Identity routing (system prompt vs user message)");
|
||||||
|
|
||||||
|
const s1 = simulateRouting("You are X.", "replace", undefined);
|
||||||
|
assert(s1.roleBlock === "", "replace mode: roleBlock empty (not in task)");
|
||||||
|
assert(s1.cliFlag === "--system-prompt", "replace mode: uses --system-prompt flag");
|
||||||
|
|
||||||
|
const s2 = simulateRouting("You are X.", "append", undefined);
|
||||||
|
assert(s2.roleBlock === "", "append mode: roleBlock empty (not in task)");
|
||||||
|
assert(s2.cliFlag === "--append-system-prompt", "append mode: uses --append-system-prompt flag");
|
||||||
|
|
||||||
|
const s3 = simulateRouting("You are X.", undefined, undefined);
|
||||||
|
assert(s3.roleBlock === "\n\nYou are X.", "no mode: roleBlock contains identity");
|
||||||
|
assert(s3.cliFlag === null, "no mode: no CLI flag");
|
||||||
|
|
||||||
|
const s4 = simulateRouting(undefined, undefined, undefined);
|
||||||
|
assert(s4.roleBlock === "", "no identity: roleBlock empty");
|
||||||
|
assert(s4.cliFlag === null, "no identity: no CLI flag");
|
||||||
|
|
||||||
|
const s5 = simulateRouting(undefined, "replace", undefined);
|
||||||
|
assert(s5.roleBlock === "", "mode set but no body: roleBlock empty");
|
||||||
|
assert(s5.cliFlag === null, "mode set but no body: no CLI flag");
|
||||||
|
|
||||||
|
const s6 = simulateRouting(undefined, "replace", "Param identity");
|
||||||
|
assert(s6.cliFlag === "--system-prompt", "mode + param systemPrompt: uses CLI flag");
|
||||||
|
assert(s6.roleBlock === "", "mode + param systemPrompt: roleBlock empty");
|
||||||
|
|
||||||
|
// --- Test 3: End-to-end with temp agent files ---
|
||||||
|
console.log("\n🧪 End-to-end with temp agent files");
|
||||||
|
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), "pi-test-spm-"));
|
||||||
|
const agentsDir = join(tmpDir, ".pi", "agents");
|
||||||
|
mkdirSync(agentsDir, { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(join(agentsDir, "test-replace.md"), AGENT_REPLACE);
|
||||||
|
writeFileSync(join(agentsDir, "test-append.md"), AGENT_APPEND);
|
||||||
|
writeFileSync(join(agentsDir, "test-default.md"), AGENT_DEFAULT);
|
||||||
|
|
||||||
|
function loadFromDir(name: string) {
|
||||||
|
const p = join(agentsDir, `${name}.md`);
|
||||||
|
if (!existsSync(p)) return null;
|
||||||
|
return parseFrontmatter(readFileSync(p, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const t1 = loadFromDir("test-replace")!;
|
||||||
|
assert(t1.systemPromptMode === "replace", "file test-replace.md → replace mode");
|
||||||
|
assert(t1.body === "You are a specialized agent.", "file test-replace.md → body correct");
|
||||||
|
|
||||||
|
const t2 = loadFromDir("test-append")!;
|
||||||
|
assert(t2.systemPromptMode === "append", "file test-append.md → append mode");
|
||||||
|
|
||||||
|
const t3 = loadFromDir("test-default")!;
|
||||||
|
assert(t3.systemPromptMode === undefined, "file test-default.md → no mode");
|
||||||
|
|
||||||
|
rmSync(tmpDir, { recursive: true });
|
||||||
|
|
||||||
|
// --- Summary ---
|
||||||
|
console.log(`\n${"=".repeat(40)}`);
|
||||||
|
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
|
console.log("All tests passed! ✅\n");
|
||||||
+2378
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user