# @nicknisi/pi-subagents

First-party subagent dispatch and fleet for pi — fan out parallel child agents and inspect their runs, with **no dependency on pi-subagents**. Children are hermetic in-process agent sessions spawned through `@nicknisi/pi-shared`'s runtime (`createAgentSession` under the hood): fast to start, version-matched to the running pi, and unable to spawn children of their own.

## What it adds

- **`dispatch` tool** (model-facing) — fan out up to 8 child agents in parallel. Each task gets its own prompt, optional label/model/system-prompt, and a tool allowlist (default read-only: `read`, `grep`, `find`, `ls`). Running rows show cumulative token usage after each completed model response. Typed per-task results aggregate into one tool result. `background: true` runs detached and surfaces completion via a transcript message. Tasks whose allowlist includes `edit`, `write`, or `bash` mutate the shared working tree: they must declare `allowTreeMutation: true` (otherwise that task is refused) and always run **sequentially**, one at a time, after the parallel read-only batch completes — never concurrently with each other or the read-only batch.
- **`fleet` tool** (model-facing) — `list` shows queued/running runs owned by the current Pi session. Pass `scope: "all"` for persisted machine-wide history, or fetch a `result` directly by runId. This is how the model checks on background dispatches without mixing in unrelated sessions.
- **`/fleet` command** — user-facing active-run table. `/fleet all` opens persisted machine-wide history; `/fleet <runId-prefix>` jumps to a historical result.
- **Fleet radar overlay + statusline** — `Alt+Ctrl+F` (rebind via `~/.pi/agent/keybindings.json`) opens a tmux-choose-tree-style overlay listing active runs owned by the current Pi session as per-child lanes: status, model, current tool, token burn, last activity. `Enter` inspects the live run transcript; `c` cancels the focused run (wired into the cascading-cancellation registry); `Esc` closes. The ambient footer segment (`ctx.ui.setStatus`) exists only while this session owns queued/running subagents; runs from other sessions never make it appear.
- **`/patches` command** — staging area for worktree-subagent `.patch` handoffs. Opens a keyboard-driven overlay over every pending patch with diffstat and a pre-flight stamp (`clean` / `conflicts` / `stale`, checked via `git apply --check` **without** applying). `Enter` applies the whole patch (`git apply --3way`); `e` expands the full diff with per-hunk navigation (`n`/`p`); `s` applies the focused hunk; `d` discards. Apply/discard decisions persist to `~/.pi/agent/subagent-patches/state.json` so `/patches` survives restart.
- **`&` dispatch prefix** — `&scout how does auth work` at position zero dispatches a single subagent inline (reusing the same spawn/cancel path as the `dispatch` tool). Live progress shows in a widget above the editor; the final result lands as a collapsible `subagents:inline` transcript block rendered with the same vocabulary as a dispatch tool result, and the answer reaches the model's context. Each dispatch is captured as a session custom entry so it survives restart.
- **`/again [amendment]` command** — re-fires the last `&` dispatch verbatim, or with the amendment appended.
- **Profiles (personas)** — named agent-definition markdown files bundling a skill basket, tool allowlist, model, and system prompt. A dispatch task passes `profile: "reviewer"` (or the editor uses `&reviewer …`) instead of hand-assembling those per task. See **Profiles** below.

## Usage

Ask naturally:

```text
Dispatch three reviewers: one for correctness, one for tests, one for unnecessary complexity.
```

```text
Dispatch a background scout to map the auth module while we keep working.
```

```text
Check the fleet for that background run's result.
```

or directly:

```text
/fleet       # active runs owned by this session
/fleet all   # persisted machine-wide history
```

### Inline `&` dispatch

Prefix a prompt with `&` to dispatch a single subagent inline — the run reuses the same spawn/cancel path as the `dispatch` tool, but is driven from the editor instead of the model:

```text
&scout how does auth work
```

Live progress shows in a widget above the editor; when the child settles, the result lands in the transcript as a collapsible block (same vocabulary as a `dispatch` tool result) and the answer is added to the model's context. The dispatch is recorded as a session entry, so `/again` can re-fire it:

```text
/again
/again focus only on the JWT path
```

`/again` re-fires the last `&` dispatch verbatim, or with an amendment appended. Cancellation: an inline run is registered in the cascading-cancellation registry, so it is aborted on session shutdown and can be cancelled mid-flight from the fleet radar (`Alt+Ctrl+F` → `c`) or the `fleet` tool — it is **not** aborted by a bare Esc (the `input` event fires while idle, so no agent abort signal is available to thread in).

### Worktree isolation

Builder tasks should prefer `worktree: true` over `allowTreeMutation: true`:

```json
{
  "tasks": [
    {
      "task": "Implement the parser in packages/foo",
      "tools": ["read", "edit", "write", "bash", "grep"],
      "worktree": true
    }
  ]
}
```

The child runs in a detached worktree at `~/.pi/agent/subagent-worktrees/<runId>` from current `HEAD`. Its writes never touch your working tree, mutating tools stay **parallel** (no `allowTreeMutation`, no serialization), and on completion the full change set — **including new untracked files** — is captured as an untruncated patch at `~/.pi/agent/subagent-runs/subagents/<runId>.patch`. Integration is your call (the central-integrator pattern): inspect the patch, `git apply` what you want. `fleet` `action: 'result'` shows the worktree path, patch path, and changed-file count. Fails fast if the cwd isn't a git repo.

#### `/patches` staging area

Instead of hunting for `.patch` files by hand, run `/patches`. It scans every completed worktree run's patch alongside its run artifact, pre-flights each one **without applying** (`git apply --check`), and stamps it:

- `clean` — applies cleanly to the current tree.
- `conflicts` — `--check` fails; the context no longer matches (inspect before applying).
- `stale` — a modified (non-created) target file no longer exists in the working tree.

Keys in the overlay: `↑↓` select, `Enter` applies the whole patch (`git apply --3way`), `e` expands the full diff with per-hunk navigation, `s` applies the focused hunk, `d` discards, `Esc` closes. Apply/discard decisions persist to `~/.pi/agent/subagent-patches/state.json`, so already-applied or discarded patches don't re-appear after restart.

Cuts (honest): `s` applies a **single focused hunk** (reconstructed as a sub-patch with its file header and `git apply --3way`-ed); multi-hunk selection is not implemented. The full-diff view is capped at 2000 lines (truncated with a marker) so an enormous patch can't swamp the overlay. The pre-flight stamp is a heuristic: `stale` vs `conflicts` is decided by whether a modified target file still exists, not by a true base-commit comparison (the run record doesn't store the base commit).

#### Worktree cleanup policy

- **Completed / failed / empty / schema-invalid runs** (the child finished): the worktree and its `.patch` are kept side-by-side under `~/.pi/agent/subagent-runs/subagents/` for a **7-day inspection window**, then removed together by the startup GC sweep (the `.json` artifact, the `.patch`, and the worktree itself). This is the "kept until artifact GC" window the fleet result view refers to.
- **Aborted runs** (Esc/interrupt, `fleet` `cancel`, or host shutdown): the child did not complete, so no `.patch` is captured and the worktree is **removed immediately** — an interrupt or parent exit can never leak a detached worktree. The worktree path is dropped from the run record, so `/fleet` never advertises a path that no longer exists.
- **Hard exit** (SIGKILL, crash, power loss) that skips the graceful shutdown handler: any `running`/`queued` record whose `hostPid` is no longer alive is reaped as `aborted` on the next host startup, and **its worktree is removed at reap time** — so even an ungraceful kill cannot strand a worktree long-term (the 7-day age GC is the final backstop).
- **Unclaimed `.patch` files**: a `.patch` only exists for a finished run that changed files, and is always a sibling of its `<runId>.json` artifact. It is removed exactly when its artifact is removed — either by the 7-day age GC, or never (if the artifact is still fresh). There is no code path that deletes an artifact but leaves its patch behind, and no code path that writes a patch without an artifact.

#### Cascading cancellation

Esc/interrupt deterministically kills **all** running children — foreground and background alike:

- **Foreground tasks** are aborted through the tool `signal` pi passes to `dispatch`'s `execute()`; the abort propagates to the child `AgentSession.abort()`.
- **Background tasks** deliberately carry no tool signal (pi aborts tool signals once `execute()` returns, which would kill them prematurely), so they are tracked in a single live `runId → AbortController` registry. A `session_shutdown` handler (quit / reload / `/new` / `/resume` / `/fork`) walks that registry and aborts every active controller, so quitting pi or replacing the session cannot orphan a background run.
- The `fleet` tool's `cancel` action aborts a single run by runId prefix.
- Worktree runs that are aborted tear their worktree down immediately (see the cleanup policy above); completed runs keep theirs for the inspection window.

The one residual exposure is a hard `SIGKILL` of the host: in-process children die instantly (they share the host's event loop), but their worktrees and `running` records linger until the next host startup reaps them. There is no way to intercept `SIGKILL` from an extension; the startup reap + 7-day GC are the backstop.

## How it works

`dispatch` maps each task to a `spawn()` call on a shared in-process runtime (`namespace: "subagents"`). Foreground tasks run concurrently (the runtime caps parallelism, default 4) and their results return as the tool output. Background tasks use `spawnDetached()` and report completion via a transcript message.

Every run persists a record to `~/.pi/agent/subagent-runs/subagents/<runId>.json` (status, timing, usage, bounded output, and owning parent-session path). Because pi isolates module state per extension, this directory is the cross-extension fleet view: any extension using `@nicknisi/pi-shared` with the same artifacts root can appear in `/fleet all`. The default fleet filters those records to queued/running children whose `ownerSession` matches the current Pi session.

### Standard pi session mirror

Every dispatch run is **also** dual-written as a standard pi session JSONL via pi's real `SessionManager` (from `@earendil-works/pi-coding-agent`), into the default sessions dir (`~/.pi/agent/sessions/<encoded-cwd>/`), with the session header's `parentSession` set to the owning pi session's file path (read from `ctx.sessionManager.getSessionFile()` inside the `dispatch` tool). This means a subagent run shows up in pi's native `/resume` list, can be inspected with `/tree`, and can be branched/forked with `--fork` — exactly like a session you drove yourself. The `fleet` tool's `result` view prints the mirror path (`session: <path> (pi /resume, /tree, --fork)`).

This is **additive dual-write**, not a replacement: the bespoke `.json` run store above is unchanged, and the fleet/registry still read it (it carries bounded output, transcripts, worktree/patch info, and the cross-extension fleet view that the sessions dir doesn't encode). The session mirror carries the full message transcript instead.

Compat caveats:

- The mirror is written only when the owning pi session is persisted (i.e. `getSessionFile()` returns a path). In `pi -p` print mode or other in-memory hosts there is no owning session file, so no mirror is written — the bespoke `.json` record is still the source of truth.
- `SessionManager` creates the JSONL lazily — only once the first assistant message is appended. A run that crashed before producing any assistant turn (kind `crashed`/`empty` with no assistant message) leaves no session file on disk; `record.sessionFile` is left undefined in that case rather than advertising a path to nothing.
- The mirror reflects the child's messages as pi sees them (user prompt → assistant turns → tool results). It does **not** carry the subagent-specific metadata (runId, namespace, transcript summary, worktree/patch paths) — that lives only on the bespoke `.json` artifact, which is why both are kept.
- Worktree-isolated runs mirror with the **worktree's** cwd (where the child actually ran), not the caller's cwd. The session header's `cwd` is honest about where the work happened.

Children are **hermetic by construction**: no user extensions, skills, prompt templates, themes, or `AGENTS.md` context load unless explicitly requested. Tool scoping is likewise by construction — a child receives exactly its allowlist, and no spawn capability exists as a tool, so children cannot recurse. The ecosystem recursion guard (`PI_SUBAGENT_DEPTH` / `PI_SUBAGENT_CHILD`) is honored: inside a pi-subagents child, spawns are refused.

`spawn()` never rejects; results are a discriminated union (`ok | crashed | empty | schema_invalid | aborted`). See `packages/shared/README.md` for the full runtime API.

## Profiles

A profile is a markdown file — frontmatter on top, persona system prompt below — that names a reusable child persona: which skills it carries, which tools it may use, optionally which model it runs, and how it should behave. Hermetic children get **no** skills by default; a profile is the curated basket that opts specific ones in.

```markdown
---
name: reviewer
description: Code review — correctness, tests, simplicity
skills: pr, review-checklist
tools: read, grep, find, ls
model: anthropic/claude-sonnet-4-5
---

Review the change against the task. Prefer the smallest correct fix.
```

| Field         | Meaning                                                                                                                                                                                                                                                      |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`        | Profile name (defaults to the file stem).                                                                                                                                                                                                                    |
| `description` | Required — shown in the dispatch tool's profile catalog.                                                                                                                                                                                                     |
| `skills`      | Skill basket: bare names resolve against the parent session's skill catalog (wherever pi discovered them — user dir, project, packages, `--skill` flags); entries with a `/` or `.md` resolve relative to the profile file. Missing skills warn, never fail. |
| `tools`       | Tool allowlist. Omitted = dispatch's read-only default.                                                                                                                                                                                                      |
| `model`       | Optional model spec.                                                                                                                                                                                                                                         |
| `worktree`    | Default the task to worktree isolation — set this on builder personas so their `edit`/`write`/`bash` tools stay parallel and need no `allowTreeMutation`.                                                                                                    |
| `replace`     | Replace pi's system prompt with the persona prompt instead of appending.                                                                                                                                                                                     |

Lists accept comma-separated scalars or `- item` block lists. Unknown keys are ignored, so agent files shared with other harnesses load. The parser is deliberately small — flat `key: value` frontmatter, not full YAML.

For compatibility with legacy read-only profiles, inherited `bash` is omitted when the profile has no `edit` or `write` tools and the task does not set `worktree: true` or `allowTreeMutation: true`. An explicit task `tools` list is never changed.

**Discovery** (first-wins by name, highest precedence first):

1. `<cwd>/.pi/agents/*.md` — project profiles
2. `~/.pi/agent/agents/*.md` — user profiles
3. Host-provided extra dirs (`SubagentsOptions.profileDirs`) — e.g. a distribution's bundled defaults

Dirs are rescanned on each dispatch, so edits apply without a restart (the tool _description_'s profile catalog refreshes on session start).

**Merge semantics:** explicit task fields win — call beats persona, persona beats defaults. `profile` on a dispatch task is strict: an unknown name refuses that task and lists the known profiles. The `&` prefix resolves softly: `&reviewer fix this` applies the `reviewer` profile when it exists and otherwise keeps the old plain-label behavior. `replace` applies only when the persona's own prompt is used.

```json
{ "tasks": [{ "task": "Review this diff for correctness", "profile": "reviewer" }] }
```

## Configuration

None required. Embedders can pass options through the factory:

```ts
import subagents from '@nicknisi/pi-subagents';

export default (pi: ExtensionAPI) => subagents(pi, { profileDirs: ['/path/to/bundled/profiles'] });
```

`profileDirs` appends lowest-precedence profile directories — the hook for a distribution (e.g. arc) to ship default personas that user and project files can shadow.

## Caveats

- **In-process means no crash isolation.** Children share the parent session's event loop and memory; a pathological child can hurt the host. Untrusted or heavy parallel work should stay on pi-subagents (or a future RPC transport) until this platform grows an isolation option.
- **Background completion is a notification, not a turn.** The completion message lands in the transcript but doesn't drive the agent — the model learns results when it next acts (or when asked to check `fleet`).
- **Fleet history is per-machine, per-agent-dir.** `/fleet all` reads records under `~/.pi/agent/subagent-runs/`; they are garbage-collected at startup after 7 days (along with their patches and worktrees). The default `/fleet`, shortcut, tool list, and footer are current-session active views.
- **Background runs live only as long as the host session.** They are detached in-process children; cancel them via the `fleet` tool (`action: 'cancel'`), or let the `session_shutdown` handler abort them deterministically on quit/reload/session-replacement (see **Cascading cancellation** above). Running records left by a hard exit are reaped as `aborted` (and their worktrees removed) on the next host startup.
- Depends on pi SDK internals (`createAgentSession`, `DefaultResourceLoader` flags, `SessionManager.inMemory`) that could change across pi versions — runtime-aliased to the host at load time, but type-level drift would surface at extension load.
- **The recursion guard has a hole.** The in-process depth guard only covers spawns made through the shared runtime; a child that itself shells out to `pi -p` via bash starts a fresh process with none of that context — the same exposure as any pi session with bash access.
