pi-subagents

How this repo's pi extension defines, runs, and orchestrates subagents — no predefined roster, everything authored on the fly.

Overview

This repo is a single pi (the @earendil-works coding-agent CLI) extension called pi-subagents. It registers two tools with the host agent:

  • subagent — delegate one job, or a fixed set of jobs, to isolated agent sessions (single / parallel / chain).
  • workflow — hand the model a small JavaScript sandbox with agent(), spawn(), parallel(), race(), waitAny(), and phase() primitives, so it can script arbitrary multi-agent orchestration.
No predefined agents

There is no roster of named roles or fixed prompts anywhere in this codebase. Every subagent is defined inline by the calling model: a systemPrompt (role/approach) plus a task (the specific job), decided at call time.

Tools registered
2
subagent, workflow
subagent modes
3
single / parallel / chain
workflow primitives
6
agent, spawn, parallel, race, waitAny, phase
Source size
2,723
lines across 2 files

The subagent tool

Defined in index.ts. Every mode bottoms out in the same runSingleAgent() function: it spins up a fresh, isolated createAgentSession() (in-memory session, headless), runs one prompt to completion, and returns the transcript, usage, and (optionally) a structured result.

Modes accepted by the subagent tool
ModeShapeBehavior
single { task, systemPrompt? } One subagent, awaited directly.
parallel { tasks: [{ task, systemPrompt? }, ...] } Up to 8 tasks, run 4 at a time via a concurrency-limited worker pool; results collected in order with a per-task success/failure summary.
chain { chain: [{ task }, ...] } Steps run sequentially. {previous} in a step's task is replaced with the prior step's output (structured JSON if a schema was used, else plain text). Stops immediately on a failed step.

Per-subagent options

systemPrompt
Role/approach text, appended to pi's default system prompt.
task
The specific job — sent to the child session as Task: <task>.
tools
Restrict the child to a named subset of built-in tools; omit for the full default set.
model
Override as provider/id or a loose pattern; falls back to the parent session's model on no match.
cwd
Working directory for the child session; defaults to the parent's.
schema
JSON-schema-ish shape. When present, a one-shot structured_output tool is injected and the child must call it as its final action.

Structured output

jsonSchemaToTypebox() converts the caller's plain JSON-schema-style object (object/array/string/number/integer/boolean, enums, required, descriptions) into a TypeBox schema, which becomes the parameters of a synthetic structured_output tool. The subagent is instructed to call it exactly once as its last action; the call is captured and returned to the caller as result.structured instead of parsed from prose.

Isolation

Each child session is built with DefaultResourceLoader({ noExtensions: true }) — it still discovers skills and AGENTS.md context, but the pi-subagents extension itself is excluded, so a subagent can never call subagent or workflow and recurse.

The workflow tool

Defined in workflow.ts. Instead of a fixed shape, the caller supplies a JavaScript orchestration script. It runs inside an AsyncFunction sandbox with a handful of host-provided functions in scope — everything else (loops, conditionals, .map()/.filter(), template strings) is plain JS, so the model can fan out dynamically and branch on prior results.

export const meta = { name: 'audit', description: 'Audit each module', phases: [{ title: 'Scan' }, { title: 'Report' }] }
const FINDINGS = { type: 'object', properties: { issues: { type: 'array', items: { type: 'string' } }, ok: { type: 'boolean' } }, required: ['issues', 'ok'] }

phase('Scan')
const files = ['a.ts', 'b.ts']
const results = await parallel(files.map((f) => () =>
  agent(`Audit ${f} for security issues.`, { label: `scan:${f}`, phase: 'Scan', schema: FINDINGS })))

phase('Report')
const summary = await agent(`Summarize: ${JSON.stringify(results)}`, { label: 'report', phase: 'Report' })
return { results, summary }
Primitives exposed inside a workflow script
PrimitiveReturns / rejectsPurpose
phase(title)Advances the live progress display; groups subsequent agents under that title.
agent(prompt, opts)Never rejectsRuns one subagent and awaits it. Returns structured object (with schema) or final text.
spawn(prompt, opts)Handle; await rejects on failureStarts a subagent and returns immediately. Needed so race()/waitAny() can observe failure.
parallel(fns)Array, in orderRuns zero-arg thunks concurrently, capped by the shared semaphore; waits for all.
race(participants)First to settleLike Promise.race — resolves or rejects on whichever handle finishes first. Losers keep running.
waitAny(participants)First to succeedLike Promise.any — ignores failures; only rejects if every participant fails.
log(...args)Annotates the run for the live/expanded view.

A racing example straight from the tool's own description, showing spawn() + race() used to act on whichever of two concurrent checks finishes first:

const ci = spawn('Run `gh pr checks --watch` in bash and report pass/fail.', { label: 'ci', tools: ['bash'] })
const review = spawn('Review the diff for correctness issues.', { label: 'review' })
const first = await race([ci, review])
log('First to finish:', first)
// ci/review keep running if still pending; race([ci, review]) again later, or await ci / await review directly.

Execution & concurrency

Parallel subagent tasks (max)
8
MAX_PARALLEL_TASKS
subagent parallel concurrency
4
MAX_CONCURRENCY
workflow concurrency
6
WORKFLOW_CONCURRENCY, shared by every entry point
Per-task output cap
50 KB
parallel subagent summaries

Background execution and resume

In interactive sessions, workflow runs in the background by default: the tool call returns a run id immediately (wf_<hex>), progress is watchable with /workflows, and the parent is notified via a follow-up message on completion. Non-interactive sessions (no UI to background against) always run synchronously.

Every agent()/spawn() call is journaled to <agentDir>/workflows/<runId>/journal.jsonl, keyed by a content hash of its call index, prompt, and options (agentKey()). Re-invoking the tool with { scriptPath, resumeFromRunId } replays every cached call — including cached failures — instead of re-running it, and edits below the first changed call take effect while everything above it stays cached.

Deterministic resume

Date.now(), new Date(), and Math.random() are shadowed inside the sandbox and throw if called — a script must get timestamps/randomness from its inputs, not generate them, so a resumed run reproduces exactly. Because race()/waitAny() outcomes are otherwise a real timing race, the winner of each decision is journaled too, so a resumed script re-awaits the same winner instead of racing for real again.

Design decisions

Shared runner core
runSingleAgent() in index.ts is the one place a child session is created; both subagent's three modes and every workflow primitive call through it, so isolation, model resolution, and usage accounting only exist once.
Fair FIFO semaphore, not per-call limits
A single Semaphore instance gates agent(), spawn(), parallel(), and race()/waitAny() together, so an ad-hoc Promise.all([spawn(...), spawn(...)]) is bounded the same way parallel([...]) is — concurrency isn't only enforced at one entry point.
Exact-match model resolution
resolveModel() only accepts an exact provider/id or id match against the registry; it deliberately avoids a loose substring match that could silently resolve to an unintended model instead of falling back to the parent's.
Structured output over prose in chains
When a chain step used a schema, its {previous} substitution uses the captured structured JSON rather than the step's final text, so later steps get real data instead of reprocessing prose.

Reference

Repo layout
PathLinesContents
.pi/extensions/pi-subagents/index.ts1,350runSingleAgent() core, structured-output support, the subagent tool and its terminal UI rendering.
.pi/extensions/pi-subagents/workflow.ts1,373Sandboxed script engine (agent/spawn/parallel/race/waitAny/phase/log), journaling/resume, background execution, the workflow tool and its rendering, and the /workflows command.
package.jsonDeclares the extension as pi-subagents, depending on @earendil-works/pi-agent-core, pi-coding-agent, pi-tui, and typebox.
Package name
pi-subagents
Version
0.0.1
Host runtime
pi coding agent (@earendil-works), extension type
Key dependency
@earendil-works/pi-coding-agent — supplies createAgentSession, DefaultResourceLoader, SessionManager, and the extension API