Overview
The project is a single pi extension living at .pi/extensions/pi-subagents/, made of two files:
| File | Lines | Responsibility |
|---|---|---|
index.ts | ~1350 | The subagent tool (single / parallel / chain modes), the shared runSingleAgent() core, structured-output plumbing, and TUI rendering. |
workflow.ts | ~1370 | The workflow tool: a sandboxed JavaScript orchestration runtime with agent(), spawn(), parallel(), race(), waitAny(), journaling, and resume. |
Unlike systems with a registry of named agents, there are no roles, prompts, or agent files here. The parent model defines each subagent inline at call time by passing a systemPrompt (the role) plus a task (the job). The extension is pure mechanism; the model supplies all the policy.
Dependencies are the pi SDK packages (@earendil-works/pi-agent-core, pi-coding-agent, pi-tui, all ^0.80.2) plus typebox for tool parameter schemas. The only script is npm run check (tsc --noEmit) — pi loads the .ts extension files directly.
Architecture: in-process sessions
Each subagent is not a separate OS process. runSingleAgent() creates a fresh headless createAgentSession() from the pi SDK, running in the same process as the parent, with:
- An in-memory session (
SessionManager.inMemory(cwd)) — nothing is written to session history. - A
DefaultResourceLoaderwithnoExtensions: true— the subagent/workflow tools never load inside a child, which prevents recursion, while skills andAGENTS.mdcontext are still discovered normally. - The parent's model registry — subagents resolve models and auth exactly like the parent, and inherit the parent session's model unless a spec pins its own
model(matched asprovider/idor exact id; loose substring matches are deliberately rejected so a typo falls back to the parent model instead of silently picking the wrong one). - A live event subscription — the runner listens for
message_end/tool_execution_endevents to stream progress into the parent TUI, and reads the final transcript and token usage straight off the session.
The subagent's extra instructions are delivered via appendSystemPrompt, so the inline systemPrompt is appended to pi's default system prompt rather than replacing it. The task itself arrives as the first user message: Task: <task>.
Abort is cooperative: an AbortSignal from the parent triggers session.abort(), and an aborted subagent returns a result with stopReason: "aborted" instead of throwing — so parallel and chain runs keep partial results rather than crashing.
The subagent tool
Registered by index.ts, this is the direct delegation tool. Exactly one mode must be supplied per call:
| Mode | Shape | Behavior |
|---|---|---|
| Single | { task, systemPrompt? } |
Runs one subagent and returns its final assistant text. |
| Parallel | { tasks: [{ task, ... }, ...] } |
Fans out up to 8 tasks with concurrency 4. Returns a combined report (Parallel: N/M succeeded plus a per-task section); each task's text is capped at 50 KB with the full output preserved in tool details. |
| Chain | { chain: [{ task, ... }, ...] } |
Runs steps sequentially. Each step's task can contain the {previous} placeholder, which is replaced with the prior step's output (using a function replacer so $&/$1 in the output stays literal). A failed step stops the chain and returns partial results with isError. |
Every task item (in any mode) accepts the same optional spec fields:
systemPrompt- Role/approach written on the fly; appended to pi's default system prompt.
name- Short display label (e.g.
auth-scout). Defaults tosubagent,task N, orstep N. tools- Restrict to specific built-in tool names, e.g.
['read','grep','find','ls']for a read-only scout. Omit for all default tools. model- Override as
provider/idor exact id. Omit to inherit the session model. cwd- Working directory for the subagent. Defaults to the parent's cwd.
In chain mode, when a step produced structured output (see below), {previous} carries the JSON-serialized structured object rather than the step's prose — so data, not narration, flows between steps.
There's also a small /pi-subagents command that just prints a reminder that the tool is active and how it works.
Structured output
Any spec may include a JSON-schema-ish schema. When present, the runner:
- Converts the schema to TypeBox via
jsonSchemaToTypebox()— covering objects, arrays, strings, numbers, booleans, enums,required, and descriptions. It tolerates schemas that omittypebut are unambiguous (bareproperties⇒ object, bareitems⇒ array); anything unrecognized degrades toType.Any(). - Injects a one-shot
structured_outputtool into the subagent whose parameters are that schema, so the model's arguments are validated at the tool-call layer (mismatches trigger a retry by the model, not a parse failure). - Appends an instruction telling the subagent to call it exactly once as its final action; the tool sets
terminate: trueso the run ends there. - Captures the validated object onto the result as
result.structured, which chain mode and the workflow runtime both prefer over final text.
If a spec restricts tools and also supplies a schema, structured_output is automatically added to the allowlist.
The workflow tool
Registered by workflow.ts (wired in from index.ts via registerWorkflowTool(pi)), this is the orchestration layer: the model writes a JavaScript script inline, and the extension runs it in a lightweight sandbox built from an AsyncFunction whose parameters are the host primitives. Top-level export keywords are stripped so export const meta = {...} runs as a plain const; the meta object itself is extracted separately (by brace-matching the literal) to feed the progress UI before execution.
| Primitive | Semantics |
|---|---|
agent(prompt, opts) | Run one subagent and await it. Returns the parsed structured object when opts.schema is given, otherwise final text. Never rejects — a failed subagent still resolves with whatever it produced. |
spawn(prompt, opts) | Starts a subagent and returns a thenable handle immediately. await handle rejects with SubagentTaskError (carrying .label/.result) on failure — this is what lets race/waitAny distinguish success from failure. |
parallel([thunks]) | Run zero-arg thunks concurrently (limit 6), wait for all, return results in order. |
race([participants]) | Like Promise.race: settles with the first participant to finish, win or lose. Losers keep running — hold their handles and await them later. |
waitAny([participants]) | Like Promise.any: first to succeed wins; failures are ignored unless all fail (then an AggregateError). Use spawn() handles here so failures are visible. |
phase(title) | Advance the live progress phase; agents started afterward group under it (or set opts.phase explicitly). |
log(...args) | Annotate the run; lines appear in the progress UI and journal. |
agent()/spawn() options are { label?, phase?, schema?, model?, systemPrompt?, tools? } — the same knobs as a subagent spec. Everything else in the script is plain JavaScript (map/filter/if/await/template strings), so fan-out can be dynamic and later prompts can interpolate earlier results. A final return value becomes the workflow's reported result.
Concurrency is enforced by a single fair FIFO Semaphore (6 slots) shared by every entry point — so ad-hoc fan-out like a bare Promise.all([spawn(...), spawn(...), ...]) is bounded the same way parallel() is. The semaphore hands released slots directly to the next queued waiter, so a fresh acquire can't jump the queue. Every spawn()/race participant is also tracked in an inFlight list and joined with Promise.allSettled before the run finalizes, so a race loser nobody awaited is still fully journaled and counted in the pass/fail status.
Determinism, journaling, and resume
Workflows are designed to be resumable, which requires the script to be deterministic:
- Guards:
DateandMath.randomare shadowed with proxies that throw with an explanatory message. Timestamps must be passed in via prompts or stamped after the workflow returns. - Journal: each run gets a directory
<agentDir>/workflows/<runId>/containingscript.js(the exact script),journal.jsonl(start/phase/log/started/result/race_result entries appended as they happen), and a finalworkflow.jsonsummary. - Call keys: every
agent()/spawn()call is keyed by a SHA-256 of{ callIndex, prompt, schema, label, model, systemPrompt, tools }. The ordinalcallIndexis included so two identical calls in a loop don't collide in the cache. - Resume: re-invoking with
{ scriptPath, resumeFromRunId }replays completed calls from the journal instead of re-running them. Failed calls are replayed as the same rejection (shown as a cached failure, still counted toward pass/fail) rather than silently retried, so any branch that depended on the failure stays reproducible. - Race decisions: which participant won a
race()/waitAny()is non-deterministic in real time, so the decision itself is journaled (keyed by call order). On resume the same winner is re-awaited — its underlying call is separately cached, so this resolves near-instantly — and the script takes the same branch it took originally. An all-failedwaitAny()replays itsAggregateErrortoo.
Execution model & UI
Backgrounding. In interactive (TUI/RPC) sessions, workflows run in the background by default: the tool returns a run id, transcript directory, script path, and resume instructions immediately; live progress shows in the status footer; and completion is delivered back to the parent model as a follow-up user message (pi.sendUserMessage(..., { deliverAs: "followUp" })). In non-interactive (print/JSON) sessions there is nothing to background against, so runs are always synchronous — and if background: true was explicitly requested, a note says it was ignored rather than silently dropping it. On session_shutdown all active background runs are aborted.
Watching runs. The /workflows command lists runs (active ones marked with ●, read live from an in-memory registry; finished ones read from workflow.json), and /workflows <runId> shows one run's status and result.
Rendering. Both tools ship custom renderCall/renderResult implementations for the pi TUI: compact collapsed views (task previews, per-step ✓/✗ icons, running ⏳ and cached ⟲ states, Ctrl+O to expand hints) and expanded views with the full task, tool-call trails (bash/read/write/edit/grep calls formatted with home-relative paths and line ranges), markdown-rendered final output, and per-agent plus total usage lines (turns, ↑input/↓output tokens, cache read/write, cost, context size, model).
Limits & defaults
| Setting | Default |
|---|---|
| Model | Parent session's model (override per spec with model; exact match only) |
| Tools | All default built-in tools (restrict per spec with tools) |
| Working directory | Parent's cwd (override per spec with cwd) |
| Extensions | Disabled in children (prevents subagent recursion) |
| Skills / AGENTS.md | Discovered normally in children |
| Session persistence | In-memory only; workflow runs journal to <agentDir>/workflows/<runId>/ |
| Workflow background | On in interactive sessions, off (forced synchronous) otherwise |