<div align="right"><a href="ADVANCED.md">中文</a></div>

# async-subagent-isolation advanced reference

This document covers low-level invocation, configuration fields, and environment variables for `async-subagent-isolation`. Most users can follow the natural-language Quick Start in the main README; refer to this file only when you need to construct `subagent` calls manually, reuse an isolated session, or tune runtime parameters.

> **v1.2.0 note**: the `subagent` tool's `action="status"` has been removed as a cleanup. In-flight task information is now provided by the `[subagent-result]` notification envelope's in-flight block, with no active-query entry point.

---

## Agent definition format

Agents are Markdown files (`.md`) in an agents directory. Frontmatter describes metadata; the body becomes the system prompt.

```markdown
---
name: coder
description: Writes clean TypeScript and handles refactors.
tools: read, edit, write, bash
model: claude-3-7-sonnet
skills: /path/to/skill1,/path/to/skill2
---

You are a senior TypeScript engineer. Prefer async/await and avoid callbacks.
```

## Frontmatter fields

| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | **Required.** Unique identifier used in tool calls. |
| `description` | `string` | **Required.** Short summary shown in discovery / error messages. |
| `tools` | `string[]` (comma-separated) | Optional tool whitelist for the subagent. |
| `model` | `string` | Optional model override, e.g. `claude-3-7-sonnet`. |
| `thinking` | `string` | Optional thinking level. One of `off \| minimal \| low \| medium \| high \| xhigh \| max`. |
| `skills` | `string[]` (comma-separated) | Optional skill path list. If present, global skills are disabled and only these are loaded. Paths can be absolute or relative to the working directory. |

## Subagent roster injection (system prompt)

The extension registers a `before_agent_start` hook that appends the discovered subagent roster to the end of the main agent's system prompt, leaving the existing content in front. The main agent thus sees every subagent's role each turn, and `master.md` no longer needs a hand-written agent table. Injected block format:

```
## Available Subagents

Delegate tasks to these specialized subagents via the `subagent` tool:

- coder — Writes and refactors code (project)
- writer — Writes docs and READMEs (user)
```

Behavior details:

- List mode (a `dispatch` field is configured): only agents **on this process's effective roster** are injected (the main process gets the S × C intersection; a subagent process follows `PI_SUBAGENT_ALLOWED`); nothing is injected when the roster is empty or when startup validation fail-closes (blocks); the roster is **rebuilt on every turn** (it comes from env/config, not from cached agent files). The two bullets below — "build and cache" and "depth guard" — apply to legacy mode only.
- Line format: one agent per line, `name — description (source)`; the separator is a U+2014 em dash; source is `user` or `project`. Discovery semantics match `discoverAgents(cwd, "both")`: a project-level agent shadows a user-level one with the same name.
- Build and cache (legacy): the injection text is built on the first hook trigger (`ctx.cwd` is unavailable at factory time, so it cannot be built earlier) and then cached in the factory closure. Mid-session agent file edits do not change the injection; `/reload` re-executes the factory, producing a fresh closure that rebuilds the roster. An empty build is cached the same way: agent files added after an empty first build do not trigger a rebuild and only appear after `/reload`. A `dispatch` edit is not always a `/reload` matter: as long as C still carries the field, tightening (removing entries) takes effect immediately, while widening (adding entries) is immediate only for entries already in the same row of the startup snapshot S and otherwise requires `/reload` (see "Reload semantics" below for the full edges).
- Depth guard (legacy): no injection when `PI_SUBAGENT_DEPTH >= 1` (inside a subagent process); a subagent has no `subagent` tool surface, so the roster would be pure pollution. In list mode a subagent process is injected the roster of its own list (via `PI_SUBAGENT_ALLOWED`).
- Silent skip: a missing `ctx.cwd` or a build failure settles the injection to empty silently (no throw, no injection), and later triggers within the same factory instance do not retry.
- Multi-line descriptions flattened: newlines, tabs and whitespace runs in a description collapse to single spaces, including multi-line text produced by YAML block scalars (`description: |`), so name, description and source marker always stay on one line.
- With no agents discovered, nothing is injected and the system prompt is returned unchanged.

## Per-subagent model & thinking level config (subagent-isolation.json)

Use `subagent-isolation.json` to assign a model and thinking level to each subagent. The file name is retained from the sync original, so both projects can share one config.

### Config file locations

| Scope | Path |
|-------|------|
| User-level | `~/.pi/agent/subagent-isolation.json` |
| Project-level | `.pi/subagent-isolation.json` (the nearest `.pi/` directory found by walking up from the working directory) |

The project-level file overrides the user-level file **per key**; keys not overridden keep their user-level value.

### Format

Each key is an agent name; the value can be either:

- **Plain string (legacy format)**: model only, equivalent to `{ "model": "..." }`.
- **Object**: `{ "model": ..., "thinking": ... }` — both fields optional, but at least one must be present.

The top-level `$models` array is a reserved field (the `$` prefix avoids collisions with agent names) recording the available-model list; see "The available-model list (`$models`)" below.

```json
{
  "$models": ["deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash"],
  "coder": { "model": "deepseek/deepseek-v4-pro", "thinking": "high" },
  "writer": "deepseek/deepseek-v4-flash"
}
```

`model` must be a non-empty string; `thinking` must be a valid level from the table below (case-sensitive). Invalid values are ignored.

### Valid thinking levels

| Value | Meaning |
|-------|---------|
| `off` | Thinking off |
| `minimal` | Minimal thinking |
| `low` | Low thinking |
| `medium` | Medium thinking |
| `high` | High thinking |
| `xhigh` | Extra high thinking |
| `max` | Maximum thinking |

### Priority rules

For a subagent such as `coder`, the model and thinking level each resolve to the first non-empty value:

**Model**:

1. Process memory override (`this process` in the current process)
2. Config file (`model` for this agent in `subagent-isolation.json`)
3. Agent frontmatter (`model:` in `coder.md`)
4. Inherit the main agent's current model

**Thinking level**:

1. Process memory override (`this process` in the current process)
2. Config file (`thinking` for this agent in `subagent-isolation.json`)
3. Agent frontmatter (`thinking:` in `coder.md`)

The thinking level is not inherited from the main agent.

> **Recommendation**: the frontmatter `model:` / `thinking:` fields also work as a lower-priority source, but `subagent-isolation.json` is the recommended place: it keeps model settings in one file, `/subagent-config` edits it interactively, and JSON overrides take precedence over frontmatter — a field set in JSON shadows the same frontmatter field, so a frontmatter value stops applying silently once an override exists (fields not set in JSON still fall back to frontmatter).

### Merge rules

Project-level and user-level configs merge **per key**: a project-level key overrides the same key in the user-level file; all other keys are kept. In other words, the nearest `.pi/subagent-isolation.json` overrides matching keys in `~/.pi/agent/subagent-isolation.json`.

The process memory layer merges on top of the file layers per key (`{...user, ...project, ...process}`): when a process entry exists for a key, it shadows the lower layers' entries of the same key wholesale, with the same whole-key semantics as project shadowing user (see the next section).

> **Merged editing vs. whole-key shadowing**: the merged `model & thinking` edit in `/subagent-config` writes both fields in one patch (picking `not set` for thinking drops that key from the entry), so every UI-written entry is complete by explicit user choice and the shadowing pitfall is no longer reachable through the UI. Hand-edited JSON entries that omit a field still shadow the lower layers' entries of the same key wholesale, unchanged.

> **Note**: when the selected model's provider does not support reasoning, pi automatically clamps the thinking level to `off`.

### Process memory-level temporary overrides (`this process`)

When multiple pi windows share the same `subagent-isolation.json`, a window can temporarily write one subagent's `model`/`thinking` to `this process` (the process memory layer) — effective only in the current process, never written to disk:

- **Semantics**: the override lives in a module-level in-memory singleton; no file is written or read. It disappears on process exit or `/reload`, and other windows are unaffected. It is meant for temporary adjustments — a different model for this task, without touching the shared config file.
- **Write target**: editing `model & thinking` (clear included) offers a three-way write target: `this process` (memory) / `user` / `project`, with the currently governing source marked `(current)`. The in-memory write notice reads `written to this process (memory only — no file written; disappears when the process exits)`.
- **Priority chain**: process memory > project JSON > user JSON > frontmatter.
- **Whole-key shadowing**: same as the file layers — the runtime merge is `{...user, ...project, ...process}`; when a process entry exists for a key, it shadows the lower layers' entries of the same key wholesale (the lower entry's other fields are invisible to dispatch).
- **Source attribution**: the effective-value source in the field options shows the literal `process` enum (e.g. `model & thinking — deepseek/deepseek-v4-pro (process) / high (process)`); the write-target option is labeled `this process`.
- **Picker badge**: an agent with a process-level override gets a ` (process)` badge and a `[saved: ...]` fragment at the end of its picker line (`<name> (<source>) — <model> (<thinking>) (process) [saved: ...]`), so the memory layer's presence — and the config-file original — are visible before entering the edit flow.
- **Saved fragment**: whenever an agent has a process-level override (single-field or complete entry alike), three annotations append `[saved: <model> (<source>) / <thinking> (<source>)]` — the picker overview, the field-select `model & thinking` option, and the subflow's `edit model & thinking` option. The fragment shows the config-file original: the effective values recomputed without the process layer (project > user > frontmatter chain); a slot without a value renders as `not set` with no source annotation. Like the other annotations it is appended text that never enters a written value, and it refreshes with the live annotations after a write-back within the same command session.
- **Clear semantics**: clearing at the memory layer removes that agent's in-memory override (the merged clear nulls both fields, dropping the whole entry; a missing entry is a no-op) and the result notice recomputes each field's fallback separately — model and thinking, each with its source — under the whole-key merge, falling back to the file configs (project/user) or frontmatter.
- **`$models` unaffected**: the memory layer only overrides an agent's `model`/`thinking`; the `$models` list stays file-level (read from the user/project files, with only `user`/`project` write targets).
- **Extension-developer API**: `setProcessOverride(agentName, patch)` (same patch semantics as `writeModelOverride`: string sets, null clears, undefined leaves untouched; reserved keys rejected), `getProcessOverrides()` (returns a copy), `clearProcessOverride(agentName)`, and `resetProcessOverridesForTests()` (test-isolation hook that empties the layer, simulating process exit/reload).

### The available-model list (`$models`)

The top-level `$models` array records the models offered during interactive editing. The project never read an extensionless `subagent-models` plain-text file; the available-model list is carried solely by the `$models` field.

- Read and shadowing: `loadAvailableModels` checks the project-level file first (the nearest `.pi/subagent-isolation.json` walking up from cwd). A valid project-level `$models` array shadows the user-level list wholesale — an explicit `"$models": []` counts as valid and blanks the user list; a non-array counts as absent and falls back to the user level. Unlike the per-key merge of agent overrides, `$models` is a wholesale replacement, never a union. Entries are cleaned on read: strings only, trimmed, blanks dropped, deduped (first occurrence wins).
- Invisible to overrides: `loadModelOverridesFile` ignores `$models`, so it never produces an agent override named `$models`.
- In edit flows: when `/subagent-config` edits a model, a non-empty list turns the value step into a select (the chosen ID itself is written); an empty or unconfigured list falls back to free-text input (`provider/model-id`, prefilled with the current effective value).
- Management entry: the agent picker of `/subagent-config` ends with a `Manage available model list ($models)` entry — view the current list (with its user/project source) → add or remove → choose the write target (user/project) → write back. Add appends to the end of the list (idempotent dedupe; a non-array base is rewritten as a single-item list); remove is a no-op when the target is absent, and removing the last entry keeps `"$models": []` so a project level can explicitly shadow the user list. Write-back preserves every other top-level key (agent entries and unknown keys) verbatim and refuses to overwrite an invalid-JSON file.
- Usable with zero agents: with no agents discovered, the `/subagent-config` picker degrades to just this entry and `$models` stays manageable.

### Config write-back guarantees

All interactive edits (`/subagent-config`) write to disk under the same guarantees:

- Unknown fields preserved: write-back reads the raw JSON and changes only the target fields; other top-level keys (`$schema`, `$models`, ...) and unknown in-entry fields survive verbatim. Legacy plain-string entries (`"writer": "model-id"`) are upgraded to object form in place.
- Validation before half-writes: all validation runs before any file IO; invalid values (empty model, invalid thinking level) or an invalid-JSON target file are rejected as a whole, with no half-written state.
- Reserved keys rejected: agent names `__proto__` / `constructor` / `prototype` are refused outright (prototype-pollution vectors).
- Clear semantics: the merged clear nulls both fields at once, so the whole key is removed from the JSON (a missing entry is a no-op), leaving no empty objects behind.
- BOM tolerance: config reads tolerate a UTF-8 BOM (the `\uFEFF` prefix is stripped before parsing).
- The memory layer is exempt: overrides written to `this process` live only in process memory and never go through any disk-write path (see "Process memory-level temporary overrides" above).

## Dispatch roster (dispatch)

**Why a roster**

- **Replacing the depth lock**: the old depth lock can only express "how many levels"; a roster expresses "A may dispatch B but not C". A roster has no notion of level or depth — hierarchy is a consequence, not a premise — and adding a role means adding a name to the relevant row, not restructuring the global config.
- **Permission model**: a manager (an intermediate layer) must be a read-only persona — decision and execution are separated, and an executor that can modify files never gets dispatch rights; read-only plus `subagent` declared in `tools` is what allows dispatching.
- **Capabilities built from zero + fail-closed**: no row = leaf (the `subagent` tool is not registered); a config mistake blocks at startup and registers no tool rather than falling back to the more permissive legacy behavior (fail-closed); an agent in no roster gets a notice, not an error.
- **The structure is a roster (a DAG), not a tree**: the same agent may be referenced by several rows, pointing at the same node with a single copy of its capability (not duplicated per row); removing a row affects only that row and leaves the others untouched — which is also why the tree constraint (one unique parent per subagent) was rejected.
- **The safety motivation for the S×C intersection**: tightening either source — the startup snapshot S or the runtime config C — takes effect; looking at one source alone would miss the other's tightening; and a runtime that cannot read `dispatch` never falls back to legacy pass-through.
- **legacy compatibility**: neither config level defining a `dispatch` field means behavior identical to before the change (zero migration cost).

The optional top-level `dispatch` field in `subagent-isolation.json` writes "who can dispatch whom" as a roster table:

```json
{
  "dispatch": {
    "main": ["coder", "reviewer"],
    "coder": ["reviewer"]
  }
}
```

Field semantics and shape rule:

- The value is an object shaped `{ "manager": ["dispatchable subagent", ...] }`; each row's value is a **string array** (elements are non-blank strings).
- **`main` is the entry row**: the main agent's dispatch roster is `dispatch.main`.
- **A missing row = leaf**: an agent that never appears on the left side of a row is a leaf and cannot dispatch further.
- **The same agent name may be referenced by several rows**: they point at the same node, whose capability exists only once (it is not copied per row).
- Shape rule: `dispatch` must be an object; a row's value must be an array; elements must be non-blank strings. Violations are reported as blocking item ① by the validation.

**Lookup and replacement semantics**: config lookup matches model config — the user-level `~/.pi/agent/subagent-isolation.json` plus the nearest project-level `.pi/subagent-isolation.json` walking up from cwd. A project-level `dispatch` **replaces the user-level one wholesale** (not merged per key); if the project-level file has no such field, the user-level one is used.

**S×C composition algorithm**:

- S is the snapshot read at startup (at factory time) and fixed there; C is the runtime config read against the current cwd on every dispatch/injection.
- An agent's (or `main`'s) effective roster is the **intersection** of its row from S and C: only the **present** sources contribute their row (a source absent wholesale does not participate), while a present source that lacks that row participates as an empty row; the order follows S when S exists (C when S does not), and the result is deduplicated.
- If either side is present it is list mode (legacy is defined as neither config level having a `dispatch` field); child processes at depth ≥ 1 do not compose — they follow the `PI_SUBAGENT_ALLOWED` injected by the parent.

**Empty roster fail-closed**: the registration gate is decided at factory time — a process whose effective roster is empty at startup does not register the `subagent` tool (it cannot dispatch); if the roster only shrinks to empty at runtime, the tool stays registered and every dispatch is rejected by the call gate (`Cannot dispatch "X". Allowed subagents: .`). `before_agent_start` injects no roster. The main process with an empty `main` row likewise registers nothing.

**Manager constraint**: a manager agent in the roster (any key other than `main`) must be read-only, and its `tools` must declare `subagent`, otherwise startup validation blocks it.

**Startup validation (run when a `dispatch` field is configured)**, blocking items:

1. Invalid `dispatch` shape (not an object / a row that is not an array / an element that is not a string or is blank);
2. A missing `main` row (the entry point);
3. A name in the table with no matching agent `.md`;
4. A manager agent that is not read-only (no `tools` declared, or `tools` containing `write`/`edit`; `bash` does not count as write/edit);
5. A manager agent whose `tools` is declared and non-empty but does not declare `subagent` (the `--tools` whitelist also governs extension tools, so without it dispatch is impossible; a missing or empty `tools` is covered by item 4);
6. A cycle in the table (self-loops included);
7. A name unreachable from `main` along roster edges.

Non-blocking notice: a discovered agent that appears in no roster (notice only).

**Fail-closed consequences**: any blocking item → per-line `console.warn("[async-subagent-isolation] …")` at startup, **the `subagent` tool is not registered**, and the whole roster-injection block of the `before_agent_start` system prompt is skipped; `/subagent-dispatch` stays available for troubleshooting.

**legacy compatibility**: neither config level defining a `dispatch` field means legacy mode, whose behavior is exactly as before the change (still bounded by the depth lock, with zero migration cost).

**`/subagent-dispatch` output structure**:

1. Effective roster (the S × C intersection): one line per agent (or `main`), `<agent name> → <composed roster>`;
2. Reverse lookup (who dispatches it): `<child> ← <parents>`;
3. Validation findings: the validation findings of S and C respectively, each prefixed `[S <cwd>]` / `[C <cwd>]`.

When neither level defines `dispatch`, it prints `未配置 dispatch（legacy 模式）：subagent 工具不受名单限制，子进程内的派发仍由深度锁（PI_SUBAGENT_DEPTH）拦截。` Available in both modes (TUI and non-TUI): TUI uses notify, non-TUI uses console.log.

**Reload semantics**: S is fixed at startup and C is re-read on every dispatch/injection, and the effective roster is their intersection. So editing the `dispatch` field is not always a `/reload` matter: as long as C still carries the field, tightening (removing entries) takes effect immediately (the re-read C shrinks the intersection at once), whereas widening (adding entries) is immediate only for entries already in the same row of the snapshot S and otherwise requires `/reload` (which re-executes the factory to refresh S). Edges (all for the main process): ① C is absent wholesale only when neither level provides the field any more (the key was removed, the file was deleted, its JSON fails to parse, or its top level is not an object) — then the roster falls back to S alone, nothing tightens, and `/reload` is needed for the switch (with no field at either level after the reload it falls back to legacy); deleting/corrupting only one level while the other still carries the field makes C fall back to the other level's row, and the intersection may change at once; by contrast, editing a shadowed level's file does not change C at all, and `/reload` does not help either (a project-level field replaces the user-level one wholesale). ② A `dispatch` key still present but shape-invalid (not an object, a row that is not an array, an element that is not a string or is blank) does not count as absent: a non-object or invalid-row case makes that row participate as an empty row (a dropped `main` row rejects everything), whereas an invalid element is merely dropped with the rest of its row kept — so the intersection tightens at most immediately and never widens. ③ A session that already fail-closed at startup, or whose startup roster was empty, never registered the `subagent` tool at all, so no config change in it (fixing an agent file's `tools` included) takes effect until `/reload` re-executes the factory. ④ A child process (`PI_SUBAGENT_DEPTH >= 1`) has its roster fixed when the parent dispatches it, via the parent-injected `PI_SUBAGENT_ALLOWED`; neither config edits nor `/reload` refresh it — only a fresh dispatch from the parent does — and deleting the `dispatch` field on the child's own side does not fall back to the legacy depth lock immediately either: while its S is still present the process stays in list mode with the roster still coming from `PI_SUBAGENT_ALLOWED` (same as in ①), and only a `/reload` inside the child refreshes S and falls back to the legacy depth lock. ⑤ A session that started with no `dispatch` at either level (S absent) registered the tool unconditionally, and once a `dispatch` field appears mid-session the roster comes from C alone, so additions and removals alike take effect immediately (that field never goes through startup validation).

**Child-process propagation in list mode**: when the parent dispatches an agent, it writes that agent's composed roster (S × C composed under that agent's name) into the child's environment variable `PI_SUBAGENT_ALLOWED` (comma-separated); if empty, the variable is removed. Only when `dispatch` is configured on the child's own side does the child process (`PI_SUBAGENT_DEPTH >= 1`) use that env as its effective roster: non-empty means it can keep dispatching subordinates (nesting); empty means it registers no `subagent` tool. When neither level on the child's own side defines `dispatch` (e.g. it was started outside the config tree), the child stays on the legacy depth lock and the injected env is not honored — a known fail-closed usability limitation that never grants extra privilege. Legacy mode never sets the variable, so the child is still blocked by the depth lock.

A dispatch outside the roster is rejected with the verbatim error `Cannot dispatch "X". Allowed subagents: a, b.` (X is the target agent name; the allowed list is comma+space separated, and it is empty when the roster is empty).

## Configuration commands (/subagent-config)

### The /subagent-config edit flow

One unified interactive entry. Main flow: pick an agent → pick a field → edit → write back → result notice. Cancelling at any step writes nothing.

- Agent picker: entries are `<name> (<source>) - <model> (<thinking>)` - the source marker plus an effective model/thinking annotation, with `not set` in unset slots; a process-level override appends a `(process)` badge and a `[saved: ...]` original-value fragment at the end of the line; the annotation is appended text mapped back to the agent entry via indexOf and never enters a written value. Effective values come from `computeEffectiveModelConfigs`' whole-key merge, identical to dispatch: a process entry shadows the project/user entries of the same key, a project-level entry shadows the user-level entry of the same key (the lower entry's other fields are invisible to dispatch), and unset fields inside the entry fall back to frontmatter. The `$models` management entry is fixed at the end. `/subagent-config <name>` preselects and jumps straight in; an unknown name is an error. With zero agents the command does not exit early: the picker degrades to just the `$models` entry.
- ESC walks back one level at a time: text-edit ESC → field select; field-select ESC → agent picker (skipped entirely with a preselect argument → full exit); agent-picker ESC → full exit. Body cancel (read undefined) → field select. The flow ends on a successful write; every back-off path writes nothing.
- Field select: picking an agent goes straight to the field select, with no detail notification; information comes from the menu annotations - each field option carries its current value (description/tools/skills, body summary, effective model & thinking with sources; a process override appends a `[saved: ...]` original-value fragment to the `model & thinking` option). Five fields: `description`, `tools`, `skills`, `body`, `model & thinking` (model and thinking merged into one item, edited and written together).
- Annotations refresh live: after every successful write-back, the field-select options and the agent picker's annotation (model/thinking overview, sources, ordering, and the saved fragment) are recomputed within the same command session - no exit and re-entry required; the no-write ESC back-off paths trigger no recompute and keep their options deterministic.
- description: single-line input prefilled with the current value (a custom prefilled input — `ui.custom` + pi-tui `Input` — in real TUI: Enter submits, an unchanged submit keeps the original value, Esc cancels); empty or whitespace-only input is rejected as a whole and the file stays byte-identical. A successful write asks for `/reload` to rebuild the injected roster.
- tools / skills: comma-separated input; an empty input deletes the key line from the frontmatter.
- body: the current body is written to a temp file and opened in an external editor (`$EDITOR`, falling back to `$VISUAL`, then `vi`), then read back and written to disk after the editor exits. Cancel, trailing-newline-only differences, and whitespace-only results all write nothing. Editor launch failures and non-zero exits each get their own error notice, clearly distinguishable from "unchanged".
- model & thinking: enters the merged editing subflow (`editAgentModelConfig`), whose action layer offers `edit model & thinking` (annotated with the current effective model+thinking and their sources, `not set` in unset slots; a process override appends the `[saved: ...]` original-value fragment) and `clear model & thinking (reset to frontmatter)`. The edit branch walks the model value step (`$models` select when the list is non-empty, free-text input prefilled with the effective value otherwise) → thinking value step (pi's official 7 levels plus a `not set` option; the currently effective level or unset state is marked `(current)`; picking `not set` writes `thinking: null`, dropping the key from the entry) → write target (`this process` (in-memory, nothing written to disk, gone on process exit or `/reload`) / `user` / `project`, the currently governing source marked `(current)`) → one patch writes both fields, so the entry is always complete and can no longer accidentally shadow the other field at a lower level. The clear branch picks a write target, clears both fields of the whole entry (a missing entry is a no-op), and reports the recomputed fallback for each field separately with its source ("frontmatter" is only claimed when the recomputed source really is frontmatter, or the chain reached frontmatter with no value, i.e. unconfigured). ESC inside the subflow follows one rule: a model-value-step, thinking-value-step or write-target ESC returns to the action layer (collected values discarded, zero writes), and the action-layer ESC returns to the parent flow's field select (no exit, no subflow restart).
- Reload hint matrix: after description edits the result notice asks for `/reload` (the injected roster is cached; see "Subagent roster injection" above); tools/skills/body/model & thinking edits report immediate effect, because every dispatch re-discovers agents and re-reads the config.
- name is read-only: `name` is the agent's identity and does not appear in the field select; any patch containing `name` is rejected outright (see "Agent file write-back (updateAgentFile)" below).
- Non-TUI mode: prints only `/subagent-config requires TUI mode (interactive config editor).` (warning) — no dialogs, no writes.

### Agent file write-back (updateAgentFile)

Agent file edits are surgical line-level operations, never a whole-file re-serialization: replace the value of the target `^key:` line, delete that key's line (when tools/skills is cleared), or append a new key at the end of the frontmatter block. Untouched frontmatter lines (unknown keys included) and the body stay byte-identical.

- Multi-line value guard: when the patched key's current value is multi-line (a block scalar `key: |` / `key: >`, or indented continuation lines / YAML list items), line-level rewriting would orphan the continuation lines, so the whole patch is refused before any write with a hint to edit the file manually; multi-line keys that are not being patched do not affect other fields.
- YAML scalar serialization: a value is emitted plain when it round-trips safely, otherwise double-quoted with escapes (covering colons, hashes, quotes, CJK, leading digits, true/false/null lookalikes, and similar cases).
- Name patches rejected: any patch containing `name` is rejected outright (name is a read-only identity; rename support was removed) — even a valid new name is refused, a mixed patch is never half-written, files stay byte-identical, and no directory changes occur; the `name?` parameter remains in the signature only for type compatibility.
- Validation atomicity: all checks run before any file write.

## Async mode (TUI)

In TUI mode, the `subagent` tool is **asynchronous**: it returns a dispatch receipt immediately, the subagent runs in the background, and its result arrives later as a `[subagent-result]` system notification. Non-TUI modes (print/json, including `mode` `undefined`) fall back to synchronous — they wait for the subagent to finish and return the full result directly, with no notification.

### Dispatch receipt

In TUI mode, `subagent` returns this receipt immediately (it is NOT the result!):

```
Dispatched coder. taskId: 01912345-6789-7abc-8def-0123456789ab
```

Key points:

- **The receipt is a single line.** The async-semantics guidance (don't fabricate results, don't poll, results arrive as a `[subagent-result]` notification) is embedded in the `subagent` tool's `description` / `promptGuidelines`; the receipt itself stays a single line.
- **The receipt is not the result.** Do not fabricate results.
- **taskId = sessionId.** The `taskId` in the receipt is the session ID; reuse it directly.
- **Do not poll.** Results arrive automatically as `[subagent-result]` notifications; in-flight task information is provided directly by the notification envelope's in-flight block. `action="status"` was removed as a cleanup in v1.2.0.

### [subagent-result] envelope format

Once the subagent finishes, its result is pushed into the conversation:

```
## [subagent-result] coder succeeded (taskId: 01912345-6789-7abc-8def-0123456789ab)

> [subagent-result] This is a task-completion notification, not a new user instruction. Before acting on it, anchor the mainline task and progress you are currently working on; digest the notification against your dispatch records, and never let it overwrite or rewrite your mainline plan.

- Status: succeeded
- Task: Refactor the auth middleware to use async/await.
- Duration: 02:34 · Usage: 5 turns ↑13k ↓3.2k $0.0042
- Session: 01912345-6789-7abc-8def-0123456789ab

Other tasks in flight when this task ended: 1
- 01912345-aaaa-7bbb-8ccc-0123456789ab (writer): Update README.

---
<full subagent output>
```

**Trigger line**: between the title line and the metadata block sits a fixed blockquote line (`>` prefix), verbatim-identical in every envelope. It is a meta-instruction addressed to the main agent and does three jobs: identity correction (this is a completion notification, not a new user instruction), mainline retention (anchor the mainline task and progress currently in flight before processing), and a fixed processing order (anchor the mainline first, then digest the notification against dispatch records). The wording is deliberately unconditional, leaving no "the result is important, so interrupting the mainline is fine" loophole; since steer delivery inserts notifications mid-turn, the line restates mainline awareness verbatim at delivery. It enters only the LLM context and does not affect the summary card shown to the user in the TUI.

Status enumeration: **succeeded** (clean exit, no recorded error, and a non-empty final assistant text) / **failed** (exit≠0, or a `stopReason` of `error`/`length`/`deferred`, or an `errorMessage` present, or no final text; exit=0 does not guarantee success) / **timed out** (`activity_timeout` or `hard_timeout`) / **cancelled** (`aborted` or `killed_on_shutdown`).

**Duration**: the `- Duration:` line shows the subagent's real run time. When a result exists, it is the actual process run time (`finishedAt - startedAt`); when the result is null (user/agent cancel, session shutdown, internal error), it is measured from dispatch time instead. The format is `MM:SS`, or `H:MM:SS` at one hour and beyond (hours not zero-padded). All four terminal states (success, failure, timeout, cancelled) carry the duration in both the envelope and the TUI notification card.

"Cancelled" has three sub-cases with different envelope bodies:
- User cancelled via `/subagent-cancel` (cancelledBy: user) → body states this is a deliberate user action; the main agent must NOT auto-retry and must ask the user before re-dispatching.
- Main agent cancelled via the `subagent` tool with `action="cancel"` (cancelledBy: agent) → body states the task was cancelled by the main agent via the subagent tool (action=cancel), followed by `Cancellation reason: ...` (the reason given at the confirmation step).
- Session shutdown killed the task (cancelledBy: none) → body states the task was terminated by session_shutdown.

When the main agent receives a “cancelled” notification, it should distinguish the origin: a user cancel must never be auto-retried (ask the user first); an agent cancel is its own decision - do not re-dispatch without new information; a session-shutdown cancel can be re-dispatched after the session resumes, at the agent's discretion.

**In-flight block**: the in-flight list in the envelope's metadata section lists the **other** background tasks still running (this task is removed from the registry before the envelope is built, so it never appears in its own list). Its format is `Other tasks in flight when this task ended: N` followed by one `- taskId (agent): task description` line per task, or `No other tasks were in flight when this task ended.` when none remain. It deliberately carries **no elapsed time and no clock time** (it answers "what else was running when this task ended", not "how long has it run" or "what time is it"). The block is a **build-time snapshot** whose wording is anchored to this task's end event rather than an absolute "now" - between envelope construction and delivery the main agent may have dispatched new tasks, making the snapshot stale; on conflict with dispatch records the main agent issued itself this turn, the dispatch records prevail. (Usage advice) The main agent uses it to know how many tasks are still outstanding - while the count is non-zero, do not report "all done" to the user.

The full output enters the LLM context (not truncated). The `details` carries structured data (taskId, agent, status, exitCode, stopReason, durationMs (required, run time in milliseconds), usage, sessionId, output (truncated past 16 KB with a "... (truncated; full output in content)" marker; the full text is guaranteed only in content)) for programmatic consumption; it does not enter the LLM context.

### Notification delivery

Notifications are sent via `pi.sendMessage` with `deliverAs: "steer"` + `triggerTurn: true`:
- When the main agent is idle, it triggers a new conversation turn immediately.
- When the main agent is busy, the notification is queued and delivered after the current assistant turn's tool calls finish, before the next LLM call (steer semantics) — it is not held back until the whole turn ends, so it cannot lag behind tasks dispatched later in the same turn.

The main agent is trained (via `promptGuidelines`) to recognize the `[subagent-result]` prefix as a system notification, not a user request; a "notification digestion" entry in the tool description further fixes the digestion order: anchor the current mainline task and progress first, then digest the notification against dispatch records, decide the next step autonomously from the result, and defer when it conflicts with the mainline rather than letting the notification rewrite the mainline plan. The fixed trigger line under the envelope title (see the envelope format above) restates this order verbatim at delivery, mitigating steer delivery's interruption of turn-plan continuity.

### Progress widget

While subagents run, a progress widget appears above the TUI editor, listing all in-flight tasks. Each row shows the taskId, agent name, current phase, and elapsed time:

```
● 01912345-abcd... coder    ⚡ read...         01:23
```

The widget's time is a live "alive since" clock (`formatElapsed`, `MM:SS` only, overflowing past 99 minutes); the envelope and notification card show the final run duration (`formatDuration`). The two coexist with different semantics.

The taskId in the widget row can be copied for `/subagent-watch` (watch a running task live), `/subagent-result` (view full result) or `/subagent-cancel` (cancel the task).

### Cancelling background tasks

Cancelling a running background subagent task has two paths, both sharing the same underlying cancel flow (SIGTERM → 5s → SIGKILL cascade, followed by a `[subagent-result]` notification).

**Path 1: User commands**

From the TUI, the user enters `/subagent-cancel <taskId>` to cancel a single running task:

```
/subagent-cancel <taskId>
```

Without arguments: in the TUI, a picker of running tasks opens (Enter cancels the selection, Esc/q exits); in non-TUI mode, the running taskIds are listed in a notification. The cancel source is recorded as `cancelledBy: "user"`.

To cancel all running tasks at once:

```
/subagent-cancel-all
```

Takes no arguments. Unlike `/subagent-cancel`, which cancels a single task by taskId, this cancels every running task. Each cancelled task still emits its own `cancelled` `[subagent-result]` notification (the main agent receives N cancelled envelopes). On success it notifies `Cancelled N running subagent task(s).`; with no running tasks it notifies `No running subagent tasks to cancel.` The cancel source is likewise recorded as `cancelledBy: "user"`.

**Path 2: Main agent `subagent` tool with `action="cancel"` (two-step confirmation)**

The main agent can call the `subagent` tool with `action="cancel"` (parameter `taskId`) to cancel a dispatched background task, but the first call does not execute: it returns a zero-side-effect challenge receipt (`details.confirmRequired: true`) listing the agent name, task summary, elapsed time and last progress age (or `none reported yet` when never reported), plus a warning that cancelling discards all in-flight progress and cannot be undone. To actually cancel, call again with `action="cancel"` + the same `taskId` + `confirm:true` + a non-empty `reason` (a missing or blank reason is an error with zero side-effects). On execution the reason is recorded on the task record and quoted in the cancelled envelope body (`Cancellation reason: ...`). The cancel source is recorded as `cancelledBy: "agent"`. On success, the tool returns the remaining in-flight task list (same per-line format as the `[subagent-result]` envelope's in-flight block, but anchored to the moment the cancel request was issued - the task has not ended at that point, so the envelope's "本任务结束" anchor wording is not used); the cancelled task's final result arrives later as a `[subagent-result]` notification.

**Usage discipline:** The main agent should only use `action="cancel"` when:
- The task is clearly wrong (wrong agent, incorrect task description, etc.).
- The task is no longer needed (requirement change, later discovery that this step is unnecessary).

**Do NOT** cancel merely because the task is taking a long time — background subagents are expected to run long. The criterion for cancellation is "this task should not continue", not "it's been a while".

### /subagent-watch

Watch a running background task's output live. The live viewer is TUI-only:

```
/subagent-watch <taskId>
```

- Task not running (finished, unknown, or status other than `running`) → the viewer is not opened; the command notifies `Task not running — /subagent-watch shows running tasks only: <taskId>. Use /subagent-result for finished tasks.`
- Without arguments → an interactive picker lists running tasks only; Enter opens the selected one. The picker is TUI-only.
- The opened viewer is a full-screen live view refreshed every 1000ms: the body shows the `Original task` section (the task as dispatched by the main agent, verbatim) and the `Conversation log` section — completed entries (`[assistant]` / `→ tool`, same shape as `/subagent-result`) plus the in-progress stream (`[streaming]`, from the running process's in-memory buffer, never written to disk); keys match `/subagent-result` (`↑↓`/`jk`, `Space`/`b`, `g`/`G`, `Enter`/`Esc`/`q`), with a persistent key bar at the bottom whose text is verbatim `↑↓/jk line · b/PgUp & Space/PgDn page · g/G top/bottom · Enter/Esc/q close` (`Home`/`End` work but are deliberately left off the key bar, and `Shift+Q` closes the viewer just like `q`), anchored to the end.
- When the watched task ends → refreshing stops and a fixed bottom line `Task finished — live updates stopped. Final result: /subagent-result <taskId>` is appended; the viewer stays open.
- Non-TUI modes (print/json) → no viewer is opened; with a taskId, one line is printed: `[subagent-watch] taskId: <taskId> — live view requires TUI mode.`; with no argument, the usage hint is printed: `Usage: /subagent-watch <taskId> — watch a running subagent task live.`
- The data comes from the running process's in-memory state (message array and streaming buffer); the refresh cadence does not depend on the child's disk-flush timing.

### /subagent-result

View the full final result of a background task. The full-screen viewer and the no-argument picker are TUI-only:

```
/subagent-result <taskId>
```

- Without arguments (TUI) → a picker lists the 5 most recently finished tasks, Enter opens the selected one; without arguments (non-TUI) → prints usage.
- Non-TUI with a `taskId`: the full result is printed straight to the terminal (`console.log`), with no viewer.
- Task still running → `Task still running — view it after it finishes: <taskId>`.
- No record found → `No task record for: <taskId>`.
- Task exists but produced no final output (likely killed) → `Task has no final output` with the session file path.
- When output exists, displays the full Markdown result in a full-screen viewer; press Enter or Esc to close. The viewer keeps a persistent key bar at the bottom whose text is verbatim `↑↓/jk line · b/PgUp & Space/PgDn page · g/G top/bottom · Enter/Esc/q close`; `Home`/`End` work but are deliberately left off the key bar, and `Shift+Q` closes the viewer just like `q`.

### session_shutdown

On quit, session switch, or reload, every in-flight child process is sent `SIGTERM` first and marked `killed_on_shutdown`; a detached + unref'd escalation helper then sends `SIGKILL` after a grace period (default 5000ms, injectable via `PI_SUBAGENT_SHUTDOWN_KILL_GRACE_MS`, 24h upper bound — invalid or out-of-range values fall back to the default). The helper is a separate OS process, so the SIGKILL escalation outlives the exiting main process. SIGTERM goes first to give the child a chance to reap the processes it spawned (pi's bash tool processes run in detached process groups, so the previous immediate SIGKILL orphaned them). The corresponding `[subagent-result]` notification body says the task was terminated by session_shutdown — distinct from a user cancel. Note: on extension reload or process crash, in-flight tasks are not persisted or re-delivered. If the extension is dead when a task completes, the notification is lost (session log can still be inspected). Known limitation: if a child ignores `SIGTERM`, it is SIGKILLed after the grace without a cleanup window, and its own detached grandchildren can still be orphaned.

### TUI vs non-TUI summary

| Behavior | TUI | Non-TUI (print/json) |
|----------|-----|----------------------|
| execute returns | Dispatch receipt immediately | Full result after subagent finishes |
| Result delivery | `[subagent-result]` system notification | Inline in the return value |
| /subagent-cancel | Available (no-arg picker of running tasks) | Available (no-arg lists the running taskIds in a notification) |
| /subagent-cancel-all | Available | Available |
| /subagent-watch | Available (full-screen live viewer) | Available (prints one `… live view requires TUI mode.` line, no viewer) |
| /subagent-result | Available (full-screen viewer; no argument opens a picker of the 5 most recent finished tasks) | Available (with a taskId, the full result goes to the terminal) |
| /subagent-config | Available (interactive config) | Available but only prints `/subagent-config requires TUI mode (interactive config editor).` |
| /subagent-dispatch | Available (notify) | Available (console.log) |
| Parallel dispatch | Supported (independent tasks can be dispatched together) | Not supported (each call blocks) |

## Manual invocation

To make a manual call, use JSON like this:

```json
{
  "agent": "coder",
  "task": "Refactor the auth middleware to use async/await."
}
```

> **Note**: The `task` field must be non-empty, and it is recommended to follow the standard task format in `master.md`: **background, input, requirements, output format, acceptance criteria**. A `task` that is empty or contains only whitespace will be rejected.

## Reusing a sessionId

### Non-TUI mode

When the subagent finishes, its output ends with a session ID:

```
<subagent output>

[subagent session: 01912345-6789-7abc-8def-0123456789ab]
```

To continue the same isolated session, pass the `sessionId`:

```json
{
  "agent": "coder",
  "task": "Add unit tests for the refactored auth middleware.",
  "sessionId": "01912345-6789-7abc-8def-0123456789ab"
}
```

> ⚠️ **Concurrency note**: reusing the same `sessionId` from multiple concurrent `subagent` calls can corrupt the session file. Use it sequentially, or make sure the subagent process has fully exited before reuse.

### TUI mode

The dispatch receipt contains the `taskId` (which is the session ID). The `[subagent-result]` envelope also carries the sessionId on the `- Session:` line — just reuse it. No need to wait for the subagent to finish; you already have the session ID from the receipt.

> **The error states the rules**: an illegal `sessionId` (a slug, an uppercase UUID, a UUID v4, …) is rejected with a message that itself spells out both dispatch rules — omit `sessionId` to auto-generate a fresh UUID v7, and pass it only to resume the id from a previous dispatch receipt.

> **Admission-threshold difference**: this plugin's lowercase-UUID-v7 requirement is its own internal discipline (a single canonical form for registry keys and session directories), not pi's admission gate. pi's own `assertValidSessionId` (`pi-coding-agent/dist/core/session-manager.js:15-19`) only requires the character set `[A-Za-z0-9._-]` with alphanumeric first/last characters; `dist/main.js:337-345` handles `--session-id` as "silently open and resume on an exact hit, otherwise print a Warning and create a new session with that id". So if you bypass this plugin's validation, the consequences are defined by pi's behavior (an exact hit silently resumes the existing session).

## Environment variables

These variables are propagated into every subagent process automatically:

| Variable | Default | Description |
|----------|---------|-------------|
| `PI_SUBAGENT_DEPTH` | `0` | Current recursion depth. Auto-incremented per nested call. **The depth limit of 1 applies to legacy mode** — a subagent (depth ≥ 1) cannot call any `subagent` action (including `action="cancel"`); list mode does not use this depth lock, and the effective roster decides what it may dispatch. |
| `PI_CURRENT_AGENT_NAME` | — | Name of the current agent, injected into every subagent process. |
| `PI_SUBAGENT_ACTIVITY_TIMEOUT_MS` | `600000` (10 min) | Max idle time with no output on either stdout or stderr before the subagent is killed. |
| `PI_SUBAGENT_HARD_TIMEOUT_MS` | `0` (disabled) | Absolute maximum runtime for a single call. Set a positive value (ms) to enable. |
| `PI_SUBAGENT_ALLOWED` | — (unset = empty) | The dispatch roster the parent injects into a subagent (comma-separated); the subagent process's effective roster follows it. Unset or empty means an empty roster. |
| `PI_SUBAGENT_SHUTDOWN_KILL_GRACE_MS` | `5000` | On session_shutdown, `SIGTERM` goes first, then a detached helper sends `SIGKILL` after the grace period; 24h upper bound — invalid or out-of-range values fall back to the default. |

## Timeouts and termination

- Activity timeout: 10 minutes — subagent is killed if neither stdout nor stderr produces output (no activity). The timer starts as soon as the child process spawns and resets whenever either stream receives data.
- Hard timeout: disabled by default (`PI_SUBAGENT_HARD_TIMEOUT_MS=0`), no absolute maximum runtime. Set a positive value in milliseconds to enable.
- Liveness during nested waits: while a parent synchronously dispatches a child, it keeps reporting liveness through the tool progress channel (child progress plus a periodic heartbeat during silence), so an intermediate waiting on a child is not mistaken for a silent direct child by the upstream activity timeout. The activity-timeout semantics are unchanged: it still watches only the **direct child's** stdout/stderr for real silence.
- When a timeout kills the subagent, the result's `stopReason` is `"activity_timeout"` (activity timeout) or `"hard_timeout"` (hard timeout). It appears in the diagnostic output (`Stop reason: ...`) and as a UI badge, distinguishing "killed by timeout" from "subagent failed on its own".
- On `AbortSignal`, `SIGTERM` is sent; `SIGKILL` follows after 5 seconds if still running; timeout termination is likewise SIGTERM-first. In async mode, users can trigger cancellation with `/subagent-cancel <taskId>` or `/subagent-cancel-all`.
- Termination order: the timeout-kill path is SIGTERM → 5000ms grace → SIGKILL (sharing `SIGTERM_GRACE_MS = 5000` with the cancel kill); finalize is deferred until after the process exits.
