Skip to content

Subagents

This module of pi-async-tasks launches subagents. This README is the sole owner of the module’s supported behavior. Source types own exact executable shapes; tests are non-normative evidence.

Named helper agents for Pi, launched from the parent session as async tasks, each a separate pi --mode rpc child process. It replaces the third-party edxeth/pi-subagents package with the headless surface this repository actually uses: launch, watch, complete, resume, stop, child asks, and a status row per live child. There is no pane, overlay, verifier, orchestrator, or forked-context path.

A definition is a Markdown file with YAML frontmatter, read from .pi/agents/<name>.md in the session’s launch directory (Pi’s session cwd, unchanged by worktree enter) and from $PI_CODING_AGENT_DIR/agents/<name>.md (default ~/.pi/agent/agents/). A project file shadows a global file with the same name. Every definition with a description appears in the roster; a file without one is ignored.

description is the only supported key. It is the roster line the parent reads. Every other key is ignored, including the reference-era keys mode, skills, auto-exit, enabled, model, thinking, timeout, idle-timeout, no-context-files, and env; hide an agent by deleting its file instead.

The file body is appended to the launch task after a blank line, so a definition’s role instructions reach the child without repeating them in every call.

One budget applies to every child: the parent stops a child that shows none of its own activity for one hour, even while it waits on its own work. There is no per-agent budget and no wall-clock limit. A call’s model and thinking override the parent session’s current values; nothing else sets them.

One tool, subagent, with a required action. Invalid arguments throw with the action and the field named (subagent send needs id.), before any side effect. Every result carries details.action. id is the target field for status, send, and stop: a live child’s launch id or its name.

ActionRequiredOptional
startLaunch: agent, name, title, task. Resume: sessionFile, task. Or children[].Launch: model, thinking. Resume: agent, name, title, model, thinking.
status—id
sendid, message—
stopid—
  • start takes one entry at the top level or several in children[]; a non-empty children[] is the batch, and top-level entry fields then do not count, except that a top-level sessionFile beside children[] throws. Each entry has the fields of the table, and a blank field counts as absent. An entry with sessionFile resumes that finished child; any other entry is a new launch. Names must be lower-kebab <scope>-<role> with two to four words and unique among live children and within the batch; a name is also part of a new child’s session file name. Every entry is validated before any child spawns, and a refused entry throws with its position (children[1]), so one bad entry starts nothing. A batch starts every child before any waiting. A launch result is one started record, or a started container whose children[] are records; each record carries sessionFile. Its text has one line per entry, a started child’s name, id, and transcript path, or a child that ended at once with its status, then one closing line when any child started: each report arrives later as a wake, so the parent keeps working or answers rather than polling.
  • A resume reopens the recorded session file and keeps its header. The last launch record the child wrote supplies the agent, name, model, thinking, and cwd; an entry field overrides the record for that launch, and the child records the override. A resume without name or a recorded name gets a generated resumed-… name. A resume throws when the session file does not exist, when the child is already running in this parent (use send), when the agent definition no longer exists, and when the file has no recorded agent and the entry passes none. The follow-up task goes to the child without the agent body, which the first launch already received.
  • status without id lists every live child; with id it shows that child. It reports status, current tool, time since the last event, output tokens, context usage, any open ask, and the work a settled child waits on (waitingOn) in details.jobs[]. A waiting child stays active; only an open ask is waiting. A finished child is no longer listed; its subagent_result is the record.
  • send delivers message and names the delivery: ask answers an open ask. Any other message goes to the child as a Pi prompt with steer behavior, which steers a busy child and starts a run in an idle one, so a send that crosses the child’s settle still runs. Its label is prompt when the parent last saw the child settled and steer otherwise.
  • stop aborts the child, then terminates its process group; the subagent still ends with exactly one result, and the tool returns status: "stopping".
  • status, send, and stop with an id that matches no live child return error: "not-found" and change nothing: a finished child has left the registry, so this is a state answer, not an argument error.

Only start returns status: "started" records, so a launch detector that keys on details.action === "start" cannot mistake another action for a launch.

Every launch and resume is asynchronous. In a TUI session the tool returns started records at once and the parent’s run goes on: it can keep working while children run, and each result arrives later as a subagent_result message that wakes it. Nothing ends the parent’s run after a launch; the tool description tells the model not to wait, poll, redo the delegated work, or state a result before it arrives, and the launch result repeats the wait rule at that point, because without it models polled the child’s status and transcript in the tool-surface evals. In a headless parent (pi -p, --mode json) the launch waits for the children and returns their terminal records, because a one-shot parent would otherwise exit before they report.

  • subagent_done — ends the subagent. The child’s last assistant message is the report. The child leaves its shutdown to the parent, which closes its input once the child settles.
  • caller_ping — sends a message and ends the subagent like subagent_done. The parent receives subagent_ping, then the subagent’s single result, and the session stays resumable.
  • caller_ask — sends a question over the extension dialog channel and waits for subagent send. The subagent stays live. One ask may be open at a time, the answer is the tool result, and an unanswered ask reports that no answer arrived. The timeout is ten minutes by default and follows PI_SUBAGENT_ASK_TIMEOUT_MS.

Before appending subagent_settle:done, both explicit done/ping and the delayed settle check await the current session’s registered background-compaction cleanup, when present. The delayed check then rechecks its generation, idle state, queued messages, and current user-turn domain: an overtaking run invalidates it, and an open or unknown domain reports waiting instead. Only an absent domain uses the existing wake-work fallback. A waiting child does not invoke cleanup, so its compaction preparation can continue. With no matching session registration this seam does nothing; subagents retain shutdown and process-cleanup ownership.

  1. Launch writes the child session file’s header, including parentSession when the parent has a session file, then spawns pi --mode rpc --session <file> -e <package>/subagents/tools/child.ts --no-approve in the parent’s work root (a resume uses its recorded cwd), with the resolved model and thinking (call, then the parent session’s current values) when set, and sends the task with prompt.
  2. The parent reads the child’s NDJSON event stream: message_end for the report and usage, tool_execution_start for subagent_done, caller_ping, and caller_ask, agent_start and agent_settled for the child’s busy span, and entry_appended for the child’s settle report.
  3. The child decides when it is finished. One second after each settle, an idle child with no queued message appends a subagent_settle entry, its settle report: waiting while any of its user turns remains open, and done only when its user-turns domain is empty. This includes the interval while a task is completing but its result is not yet placed, and the run that incorporates that result. The domain and fallback policy defines missing or unreadable services. A run that starts within that second, such as a stall-guard retry or a wake, cancels the check. On done the parent ends the subagent and closes the child’s stdin, Pi’s orderly RPC shutdown. On waiting it keeps the subagent open, with no timer, until that work wakes the child. A settled child that neither reports nor starts a run within 30 seconds ends as settled, and the parent logs a [subagents] warning. TUI and headless parents follow the same rules.
  4. A child’s own verdict — subagent_done or caller_ping — ends the subagent at its settle and outranks any parent-initiated stop: a verdict that races a timeout or stop still reports completed with exit code 0. A stop, a spent timeout, the one-hour idle budget, or a parent shutdown ends the subagent without waiting for a report, whether or not the child is waiting; a spent budget times out and a stop cancels. The parent aborts the child’s run, sends SIGTERM to its group 2 seconds later and SIGKILL at 7 seconds, and delivers the result when the child exits. A waiting child is idle, so it exits after that SIGTERM once its own shutdown has stopped its tasks: about 5 seconds after the stop when a task ignores SIGTERM. A subagent the child ends with a report is classified by its last assistant stopReason: an abort cancels, a provider error or a stop at a tool boundary fails, and a stop without subagent_done completes with a note. A settle that ends in an abort, an error, or a tool boundary still waits for the report, because a stall retry or the child’s own subagents can continue the work. When a subagent ends — a stop, a budget, a parent shutdown, or subagent_done — while the child’s last settle report listed wake-capable work, the result note names that work and says it was stopped. Work the child started after that report is not named, and a new run clears the report. Only the child’s own work resets the idle budget: a run start, its assistant and tool-result messages, and tool starts and ends; tool updates, settle reports, and messages the runtime writes do not.
  5. Every terminal path — completion, failure, cancel, timeout, ping — submits one subagent_result message keyed by the launch id through its captured Relay sender and the shared filesystem mailbox. A notifying batch preflights the sender before admission or process/file effects. A publication failure reports an error and leaves ended work completing until placement; it never reruns the child. A ping also delivers subagent_ping first; an ask delivers subagent_ping and nothing else until the subagent ends. The job leaves the registry after its result. In a TUI or RPC parent the result is a follow-up with a run trigger, as a shell task’s is: a running parent gets it once its run answers, and an idle parent starts a run for it. A result Pi drops from its queue is sent again. In native TUI and RPC, including frontend parents and children, answer continuation revises an eligible origin answer with it. Three results steer into the running run instead, because a follow-up’s wake would answer them a second time with nothing to revise that reply: a headless parent’s, whose launch waits for its children and returns their records; a nesting child’s (below); and one a launch sends when its child fails to start, which the launch’s own result already reports. A subagent_ping is always a steer, which a running parent sees after its current tool round, because an asking child waits for the reply.
  6. A graceful parent shutdown aborts every child and waits for its process group. Cleanup keeps its own ownership after a result is delivered, so a subagent already gone from the live registry still delays shutdown. Children are process-group leaders, so a kill reaches their descendants. A killed subagent’s result is delivered when its leader exits; descendants get SIGTERM 2 seconds later and SIGKILL at 7 seconds. A child closing itself (a done report, subagent_done, or caller_ping) gets 10 seconds before SIGTERM, so its own session shutdown can stop its shell tasks, which run in their own process groups; a second signal would make Pi exit mid-cleanup. Its leader’s exit ends that grace. Known limit: a nesting child (PI_SUBAGENT_ALLOW_NESTING=1) that closes itself first stops its own subagents, which takes 2 to 7 seconds, before its tasks stop, which takes up to 4.5 seconds, so its shutdown can outlast the grace and the parent’s SIGTERM can end it before its tasks stop. Shutdown then waits for a native signal-0 group probe, not the leader exit, on a relative timer: the probe ends one escalation after the SIGKILL. A group still present at that bound ends cleanup as unconfirmed: the parent logs a [subagents] warning, shutdown continues, no second result is sent, and the live count is unchanged. A parent that dies without a handler leaves the child running until its own orphan guard ends it.
  7. A session replacement fires the same shutdown first, so live children are cancelled then, with exactly one result each, before the replacement session starts. A still-present group at the bound stays unconfirmed and does not hold the replacement past that bound. Delivery uses the original bound Relay sender, never a replacement-session lookup.

The parent sets PI_SUBAGENT_SESSION, PI_SUBAGENT_NAME, PI_SUBAGENT_AGENT, PI_SUBAGENT_PARENT_SESSION, and PI_SUBAGENT_ASK_TIMEOUT_MS; those values replace any inherited ones. The child entrypoint is also loaded in the parent process, which returns early while PI_SUBAGENT_SESSION is set, so a child gets only the child-side tools. PI_SUBAGENT_ALLOW_NESTING=1 re-enables the parent tools inside a child. A nesting child runs in RPC with answer continuation enabled by its mode, as in other RPC sessions. Its own subagents’ results still steer into its run: a result placed after a tool round joins that run, while one placed after an answer receives the eligible origin revision before the final report. Ordinary headless results also retain their existing steer delivery. No result-delivery mode changes here.

Child sessions live next to the parent session file (or in the system temp directory when the parent has none) as <name>-<id>.jsonl, and that path travels in the started record and in every result. The parent writes each file’s header before launch with pi’s current session version; when the parent has a session file, the header names it as parentSession, so a lineage reader can link a session to its children. A resume reopens the recorded file and keeps its header. PI_SUBAGENT_PI_COMMAND overrides the spawned command; otherwise the parent reuses process.execPath with its own script path, falling back to pi for a generic Node or Bun runtime.

A child whose stdin reaches EOF treats the parent as gone: it finishes the run in flight, appends a subagent_orphaned entry, and shuts down, with a hard abort at 120 seconds and exit at 135. An ask dialog from an inherited extension is cancelled after 30 seconds and never proxied.

  • subagent_result carries id, name, task, agent, mode, status, deliveryState, async, exitCode, elapsed (milliseconds), outputTokens, optional contextTokens/contextWindow, sessionFile, and error/errorMessage/timedOut when they apply. status is completed, failed, cancelled, timed-out, or idle; timed-out and idle are this package’s own additions.
  • subagent_ping carries id, name, message, optional expectsReply, and sessionFile. A plain ping’s content quotes the message and names the session file to resume. An ask’s content quotes the question and says to answer with subagent send and the child’s name, because the child is still running and a resume would be refused.
  • subagent_roster is a hidden <subagent-roster> message from before_agent_start that lists each definition’s name and description as the helper types for start’s agent field, rebuilt only when the agent set changes.
  • The child appends one subagent_launch entry per launch: name, agent, model, thinking, cwd, session.
  • The child appends a subagent_settle entry, { state: 'waiting' | 'done', waitingOn: { tasks: [{ id, name }], subagents } }, as its settle report and when subagent_done or caller_ping closes it. Automatic completion reads the versioned user-turns service on every check, never imported live domain state. The unchanged waitingOn format projects domain wait ids, with shell-task names looked up only for labels. Explicit done/ping still lists the running wake work it stops from the async-task service.
  • The module lists its subagents in the async-task service: each child under the call that launched it, nested or not, running while it is in the live registry, then completing, with its outcome, until its result is in the session. A child whose Goal session is no longer current gets no result and stays completing. Its result is always delivered and wakes the session, a Goal-owned child’s through Goal’s guarded wake. The service keeps the latest 100 ended children and every child still completing, is published at session_start, and is removed once session_shutdown has cancelled the live children.
  • The registry’s running children are the subagents the service lists as running, which the worktree remove gate counts; the pi-subagents/running-subagent-count getter it read before is no longer published. The registry pushes one work-status row per live child labelled <name> <model> (<effort>), or <name> <model> when the launch sets no effort. Usage events republish the row with the child’s live contextTokens, so the block updates its count while the state stays active. The row uses the child session file as its stable identity. A resumed child replaces its old completed or failed row, even when its name, model, or effort changes.

User-turns owns when user turns are done (decision); this module owns process lifecycle and report delivery. Automatic completion reads readUserTurnsSnapshot() at each delayed idle/quiet check. Any open user turn holds it, including an older origin while a newer turn runs or ends. Completing work holds that origin until its result lands and its answering run settles. An empty valid domain permits done. A running turn with no wait ids still holds completion.

A present but malformed or throwing service is unknown, never done and never a reason to reconstruct tasks: the child reports waiting with an empty waitingOn if it cannot read the domain. This keeps the parent’s silent-report fallback from falsely completing it. A later run checks again; explicit completion, stop, abort, idle budget and cleanup retain their authority.

Only when the user-turns publisher is genuinely absent does the existing compatibility fallback apply: running wake-capable shell tasks and running subagents from the async-task service hold the child; completing tasks do not, and no async-task publisher means none. This preserves standalone child-entrypoint launches without requiring user-turns, rather than silently applying that weaker legacy rule to an unreadable domain. The real-child tests retain standalone coverage, and domain report tests distinguish absence from unknown state.

subagent_done and caller_ping are explicit terminal overrides, not automatic settle checks. They still stop remaining work, preserve the last-assistant-report contract and stopped-work note, outrank racing parent stops, and deliver exactly once. A result placed before the explicit decision can receive its normal revision. A merely queued result after that decision has no revision guarantee and cannot revoke completion or cause a second report. There is no new persisted format.

During a nonterminal owned Goal, every launch needs an active owned parent run. A resume must name a child file already owned by that same Goal; pre-Goal and foreign files cannot be adopted. Goal records DIRTY state before admission and uses physical model provenance and raw usage for pricing. Missing or unpriced usage pauses visibly, never as zero. Admitted children still count during pause.

Subagents retain process launch, stop, and cleanup ownership. Goal receives usage, waiting, resume, settlement, and cleanup facts: a child’s settle report of waiting stops its active-work time, and its next run starts it again. Result and ping mail bind notificationAdmission(token) hooks that revalidate the original session/binding, Goal identity and child token before injection, with settled tokens and paused-current context still admissible. Temporary holds retain mail until owner reconsideration. Only actual placement notifies resultWake/pingWake; the separate Goal wake guard still checks current authority, limits and pending user input. Canceled Goals cannot wake. Disposed session callbacks cannot deliver into a replacement session. Navigation cancellation requests stops without waiting for reconciliation. Stop can wait for the existing bounded cleanup owner, but reports unconfirmed settlement, cleanup, and final cost truthfully. Ordinary subagents retain their lifecycle.

Tool-surface eval tasks measure model use of the parent tool with the full Pi setup. Central tool-surface verification owns selection; the eval runner owns paid runs. Their graders are tested offline in eval/tasks.test.ts.

Child-tool tests check that compaction cleanup settles before natural or explicit done/ping produces its actual done entry, that a blocked cleanup emits no done, that overtaking runs, queued messages, and wake work reject stale completion, and that waiting bypasses cleanup. They also check session-scoped cleanup lookup and disposer ownership.

Tool tests cover the required fields per action, a mixed launch-and-resume batch, the all-or-nothing batch validation, and each resume refusal. Consumer tests pass real launches through the async-task service, real results through the result message details, and registered children through a recording Goal children service, including send, status, and stop in the same turn as a start. Display, footer and Herdr readings of those values are their own packages’ tests. Delivery host tests run a real session on a scripted model with fake children: in a TUI parent a result that arrives mid-run waits for the run’s answer and then wakes it, while a ping lands before that answer; in a nesting child the result steers in before the child’s report; and a headless launch’s result, or a failed launch’s, steers in so the run answers it once. With user-turns and display loaded, the user-turns host test checks that the wake revises that answer, that only the revision is drawn, and that the answer summary closes after it; the package’s redelivery host tests check that a result an Esc drops is held and placed once at the next run. Scripted RPC tests exercise real stdio with a controlled child. Real-child tests run the installed pi as an RPC child with the child entrypoint, the shell-task package, and stall guard against a local model server: a child waiting on its own task is woken by the task’s result and reports its answer, a stall retry’s answer is the result, both subagent_done and a stop of a waiting child, each with a task that ignores SIGTERM, leave no task group behind, and a managed launch with a loaded profile whose default differs keeps the model the parent requested. Replacement tests use a fake spawner to check one cancellation result and a usable next session. Cleanup tests use a real leader whose descendant outlives it and ignores SIGTERM; they prove one result while the descendant lives, a live count of zero, a shutdown that waits for the process group, a bounded unconfirmed outcome with its warning, and a bound free of the report clock. Replacement integration drives a real AgentSessionRuntime replacement with a real spawned child, and proves the next session’s session_start happens only after that group is gone. See the shared-boundary verification route.

Nested-child host tests drive real RPC grandchildren and actual result values through child continuation, including a queued result, a grandchild provider failure and cancellation cleanup.

Pi owns RPC framing, tool/event APIs, the dialog channel, and the session file format. These are compatibility dependencies, not behavioral authority for this package. The package loads through the aggregate manifest at extensions/package.json.