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 withagent(),spawn(),parallel(),race(),waitAny(), andphase()primitives, so it can script arbitrary multi-agent orchestration.
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.
subagent, workflowThe 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.
| Mode | Shape | Behavior |
|---|---|---|
| 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/idor 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_outputtool 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 }
| Primitive | Returns / rejects | Purpose |
|---|---|---|
phase(title) | — | Advances the live progress display; groups subsequent agents under that title. |
agent(prompt, opts) | Never rejects | Runs one subagent and awaits it. Returns structured object (with schema) or final text. |
spawn(prompt, opts) | Handle; await rejects on failure | Starts a subagent and returns immediately. Needed so race()/waitAny() can observe failure. |
parallel(fns) | Array, in order | Runs zero-arg thunks concurrently, capped by the shared semaphore; waits for all. |
race(participants) | First to settle | Like Promise.race — resolves or rejects on whichever handle finishes first. Losers keep running. |
waitAny(participants) | First to succeed | Like 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
MAX_PARALLEL_TASKSMAX_CONCURRENCYWORKFLOW_CONCURRENCY, shared by every entry pointBackground 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.
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()inindex.tsis the one place a child session is created; bothsubagent's three modes and everyworkflowprimitive call through it, so isolation, model resolution, and usage accounting only exist once.- Fair FIFO semaphore, not per-call limits
- A single
Semaphoreinstance gatesagent(),spawn(),parallel(), andrace()/waitAny()together, so an ad-hocPromise.all([spawn(...), spawn(...)])is bounded the same wayparallel([...])is — concurrency isn't only enforced at one entry point. - Exact-match model resolution
resolveModel()only accepts an exactprovider/idor 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
| Path | Lines | Contents |
|---|---|---|
.pi/extensions/pi-subagents/index.ts | 1,350 | runSingleAgent() core, structured-output support, the subagent tool and its terminal UI rendering. |
.pi/extensions/pi-subagents/workflow.ts | 1,373 | Sandboxed script engine (agent/spawn/parallel/race/waitAny/phase/log), journaling/resume, background execution, the workflow tool and its rendering, and the /workflows command. |
package.json | — | Declares 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— suppliescreateAgentSession,DefaultResourceLoader,SessionManager, and the extension API