Extension¶
The extension exposes the library inside pi: a subagent tool the model can
call, rendered live in the TUI, plus the interactive commands.
pi -e extension # this session only, the flag accepts a directory
pi install ./extension # permanently, via settings
Every feature is usable from a script before it is exposed here. The extension is a second surface over one core, never a place where logic lives.
The tool¶
> use subagent to review src/usage.ts with coder then reviewer, looping until LGTM
> use subagent with three scouts and reduceWith "synthesiser" to explain the export path
> use subagent in orchestrate mode with planner and candidates scout, reviewer to audit the usage code
> use subagent with export true to explore the parser with three scouts
The model may leave mode out: the fields it gives pick one. candidates
reads as a route, unless concurrency, maxTasks or reduceWith stands beside
it, three fields a route never reads, and then it is an orchestration. Each
field’s description in the schema ends with the modes that read it, so a model
knows which fields a mode will ignore. The
cheatsheet has the table.
Agents come from ~/.pi/agent/agents/ by default. The demo agents of this
repository live in .pi/agents/, so ask for them explicitly:
> use subagent with scope "project" and agent "scout" to find the auth code
That is deliberate: project agents are repository-controlled content, so they are never loaded by default. See Agents.
The tool also runs a flow by name, with task as its input:
> use subagent to run the interview flow on "add a --verbose flag to scripts/drive-pi.py"
The model calls it with flow: "interview" and task, and the flow runs the
way /run runs it: checked against this terminal before anything is spawned,
in a runs/<timestamp>/ of its own, its plan drawn above the prompt. Its
questions are put to you during the model’s turn, on the same question
card. Somebody is there only in pi’s terminal: in pi -p or pi --mode rpc, which has no card to draw, a flow that could reach a
question with no default: and no enough: is refused before it starts, and
the others take their default: or enough:. Two calls in one message run
one after the other, so only one flow puts its cards up at a time. The model reads the output of the flow’s last root node and the
line on how the run ended, which names the run directory; a run that stopped
says what /run resume <run directory> would do with it. The tool does not
resume.
flow takes task, model, timeoutMs, scope and herdrAll beside it,
and nothing else. A composition field given with it (steps, until,
candidates, lifetime…) is refused by name, since the file says what runs:
Error: subagent: `flow` runs a flow as its file describes it, and takes only `task`, `model`, `timeoutMs`, `scope`,
`herdrAll` beside it - drop `steps`, `until`
The flows come from the same places as the agents: the package’s own and
~/.pi/agent/flows/, and .pi/flows/ with scope: "project" or "both".
/interview writes its transcript into a runs/<timestamp>/ folder of its own.
That folder is made before the first question rather than after the last,
because a failed interview is the one moment “what was actually sent” is the
only question worth asking, and it used to leave nothing to read. The failure
names the folder.
The interview is watched like any other run: a row per subagent, with what it is reading. Measured on this repository against a small open-weight model, its first turn is 35 seconds and seven file reads, and the whole interview 57 seconds over three turns. Without the row that first turn is half a minute of a status line that does not move, which is indistinguishable from a turn that has hung.
--model covers the interview too, and --questions caps how many it asks.
The interviewer reads the repository between questions rather than asking what
it could find out, so a question is an exploration and not a round trip: six of
them on a slow model is minutes before the brief exists. Each turn
gets a five minute deadline, because pi’s agent loop has no step cap and a
person waiting cannot tell a slow turn from a stuck one.
Flags come first, in any order, and a line may end on a \ when the command is
too long for one: what follows the wrap is read like the rest of it. /run
also reads its two flags at the end of the line, where a flag in the middle of
the text stays text. That wrap
used to end the parse, which is how a /swarm written over two lines ran every
member on pi’s own model - its --model had become the first two words of the
goal, and nothing said so.
--agent on /step takes no value, unlike --model: a flag that swallowed
the word after it would eat the first word of the request. It is written
--agent, --agent=true or --agent=false, and any other value is refused,
--agent=no included. A count (--members, --rounds, --hold,
--questions) is a whole number of at least 1, and anything else is refused
too. The refusal names the flag and what it takes, and nothing runs. Whether a flow’s
workers get a copy of the repository is the file’s to say, with copies: true
on a block; see Worktrees for what it costs and what it buys.
until is a whole-line match: the loop stops when the word stands alone on
one of the lines, whatever decoration the model put around it. A line with
anything else on it does not count, so a review that writes “I cannot say LGTM
yet” keeps the loop going. It reads through
saysWord, which a workflow of your own can call.
The tool also takes model, which puts every subagent of the call on one model,
whatever their frontmatter says. The parent session’s model is never
inherited: a subagent running on whatever the operator’s TUI happens to be on
is the same bug one level up. The model comes from an explicit artifact - an
argument or a file - or pi’s settings take over as the last resort.
A subagent that splits further¶
A subagent spawned here can have subagents of its own, and its own definition
decides: an agent whose tools: names subagent is handed the tool, anybody
else is offered nothing. Nothing in the call turns it on, and nothing turns it
on for an agent that did not ask.
> use subagent with agent "explorer" to compare the four reporters
The roster a child may reach is the one this call loaded, so scope covers it
too. maxDepth is the bound, two by default: the session, a child, a
grandchild. At the bound the tool is still handed over and refuses when called,
because a model told “unknown tool” tries again, and that is the runaway turn
timeoutMs exists to survive.
The children run on the terms of the call - its model, its deadline, its export directory, its signal - and they are drawn under the subagent that asked for them, in the dots above the prompt and in both views of the finished call. A run with no delegation looks exactly as it did.
What the run looks like while it happens is covered in Display, and what a tree costs in Measurements.
The commands¶
Command |
What it does |
|---|---|
|
Turns a vague request into a brief, one question at a time. |
|
Runs a flow in a run directory of its own, drawn as it goes; its answer lands in the conversation. |
|
Carries on a run that stopped: the newest one here that can go on, or the one named. |
|
Lists the flows, as |
|
Lists the agents that can be spawned, grouped by where they came from. |
|
Lists the flows, where each comes from, the most it can cost in turns and time, and its description. A refused file is listed beside them with its faults, in the warning colour, a file left in an old |
|
Prints that flow’s plan: every node, what it reads, its agent and file, its bound. |
|
Runs one flow or agent on the previous step’s output. Drawn, and kept out of this session’s context. |
|
Several copies of one agent on one job, with a board between them. Finished by coverage of what |
|
The steps walked so far; or drop them and start a new chain. |
|
Put one step of the chain into the conversation, attributed. |
|
Stops the selected subagent, one named by id, or the whole run. |
|
Give every subagent its own herdr split for this session. |
A question card is shown during a model’s turn as well as during a command:
the subagent tool puts a flow’s questions to you while the turn waits. /run build asks nothing; see Deliver a change.
/flows runs a flow’s check and nothing else, so it spawns nothing and needs no
model: a typo in a flow costs a glance at the list. What the check cannot see,
the tree and the ports a run is launched with, is checked when a run starts.
See Flows.
/step is the other way of running a flow: one stage per command, with this
session kept out of it until you say otherwise. A flow stage gets a run
directory of its own, the step’s folder, which /run resume <run directory>
carries on. See Walk a chain by hand.
The extension brings its own agents and flows, so it works the moment it
is loaded rather than only inside a repository where the definitions were copied
by hand. They sit at the lowest priority: a definition of the same name in
~/.pi/agent/ replaces one of ours, and one in the repository replaces both.
That is safe for a reason worth stating plainly: loading an extension already runs its code - pi’s own documentation says so - so reading Markdown from the same directory adds no risk that installing it did not already accept. What matters is that it can never take a name away from you.
Running a flow¶
/run <flow> <input> checks the flow whole, then holds it to this terminal:
the working directory, its question card, the project’s scripts and git. A
fault at either stage is said before anything is spawned, one per line, file at: message, so /run build outside a git repository is refused at the node
that needs one, flows/build.md deliver/work.copies: <directory> is not in a git repository, before any model is called.
Everything that describes the work is in the file: its questions, its checks,
its copies, its commit. The command line holds only what belongs to whoever
types it: --model, the model every agent turn runs on, and --timeout, the
bound of one turn (90s, 10m, 1h). Both may lead the line or end it, and
an input written as one quoted string is the text inside the quotes.
Every run gets runs/<timestamp>/, its run directory:
the snapshot, the journal, each subagent’s transcript, this session’s JSONL
and the usage.json a measuredRun writes there
when the run ends. The widget above the prompt draws the flow’s plan as the
visits go, each running visit expanded with what its subagents are doing under
it:
● build · 3 visits · 1m51s · ↑40k ↓4.1k
✓ locate · scout · 33s · ↑12k ↓831
✓ plan · planner · 10s · ↑9.8k ↓1.1k
● deliver · #1 of 2
● deliver#1
● deliver#1/work · 0/1 so far
● deliver#1/work[1]
● deliver#1/work[1]/pair · #1 of 3
● deliver#1/work[1]/pair#1
✓ deliver#1/work[1]/pair#1/code · coder · 37s · ↑18k ↓2.2k
● deliver#1/work[1]/pair#1/review
● reviewer#1 verdict approved=false raised=["Replace `t.strictEqual` wit… remarks=The tests use an inc…
○ deliver#1/tests · check .pi/checks/test.sh · timeout 10m · ≤ 20m
○ deliver#1/audit · agent auditor (.pi/agents/auditor.md) · reads input, work, tests, diff, deliver.ledger · verd…
○ report · agent synthesiser (.pi/agents/synthesiser.md) · reads input, diff, deliver.output.last.work, deliver.outpu…
esc stops everything · ctrl+↑↓ selects · ctrl+del stops the selected one
A running subagent’s line is its last call, its model, its tokens once a turn
has ended and its clock, cut to the terminal’s width: the reviewer here is
calling verdict, and the call fills the room.
The plan takes sixteen rows at most. Past that it is cut above and below what
runs now, and each cut says how many lines it holds. The keys are those of any
run: esc stops it, ctrl+↑↓ and ctrl+del stop one subagent.
When the run ends, its answer lands in the conversation as a message that triggers no turn: the output of the flow’s last root node, then one line on how the run ended, then its last frame. The model reads the answer and the line; the frame is drawn for you only.
◆ explore
Result of the explore flow, asked to: where is the condition language implemented?
The condition language (a subset of CEL) is implemented in the src/flow/condition/ directory.
…
ok · runs/2026-09-23_20-06-33
✓ explore · 5 visits · 27s · ↑44k ↓3.4k
✓ look · 3 items · 15s · ↑41k ↓2.8k
✓ answer · synthesiser · 12s · ↑2.4k ↓567
The line says ok, and converged or not converged when the last root node
is a loop. A failed run says where and why, then what a resume would do:
failed at answer: provider: … · runs/… · /run resume runs/… picks it up at answer, or why it cannot be resumed.
/run resume takes the newest run under this directory’s runs/ that can go
on, and says which, and from which visit, before it starts:
run: resuming build in runs/2026-09-23_20-21-17, from deliver#1/work[1]/pair#1/code
/run resume <run directory> takes that one. A run that cannot go on is
refused with why: it ended well, it failed by a decision of the flow, its lock
is held by a live process, HEAD is off its branch (with the git switch to
type). The input and the model are the run’s own, so --model is refused;
--timeout may be given again. What a resume keeps and what it runs again is
Resuming a run.
A refusal is fitted to the terminal before pi draws it: paths inside this
directory are named from here, and a line too long is cut before a path
rather than through it, the Error: or Warning: pi puts in front counted.
With herdr, /herdr on gives each subagent of a flow a split named by its
agent and where the flow keeps it, coder @ deliver#1/work[1]/pair, for as
long as that subagent lives. A check, an ask or a commit has no subagent,
and opens none.
The question card¶
/interview asks through a card, and a flow’s ask node asks through the
same one, whether /run, /step or the subagent tool runs the flow. A card
holds one question. It draws the header and the visit that asks, the reads
above the question, each under its name, then what takes the answer, and a
help line that says what the keys do:
────────────────────────────────────────────────────────────
[plan] review/1/go
brief
Add an in-memory cache in front of the store, with a TTL.
Build this?
→ Build it start the subtasks
Change the plan
Stop here
↑↓ choose • enter answer • esc Stop here
────────────────────────────────────────────────────────────
What takes the answer depends on the form the caller asks for:
Form |
The card |
The answer |
|---|---|---|
a choice, open |
the options, then |
a label, or the typed text with |
a choice, closed |
the options only: literal labels take no typed answer beside them |
a label |
a yes or no |
|
|
a free text |
a text box |
what was typed, |
esc declines the card, and the help line says what that means there. On the
interview’s card it writes the brief with what you have. Where a flow’s card
offers “that’s enough”, esc is that entry, under the label the flow gave it.
Where it offers none, esc stops the run, and the help line reads
esc stop the run. esc in the text box behind Other… goes back to the
options instead: a key pressed to leave a text box must not end a run. The
interview’s Other… still opens pi’s own text box, where esc submits.
A card also comes down when its question no longer stands. The flow runner
hands it a signal that aborts on the node’s timeout: or on a stop. The card
closes, pi’s editor comes back, and the answer is not read. For as long as a
card is up, the run’s stop key is held and the widget stops offering it, so
pressing esc on it means only what the help line says. That holds during a
model’s turn too: the card has the keys, and pi’s own esc, which would end
the turn, does not reach it.
When the terminal is too narrow for the help line, each key goes on a line of its own rather than being cut from what it does.
Which pi it runs against¶
An extension is loaded into pi’s own process, so it resolves pi’s copy of the
package, not this repository’s node_modules. The version that matters is the pi
you launched.
Homebrew ships 0.80.6, npm is on 0.80.10, and those two disagree on the model
API: 0.80.7 replaced AuthStorage and ModelRegistry with a single
ModelRuntime. A pi patch release can break the API.
The library therefore chooses by presence of the export rather than by version string. A version number can be patched or mis-set; a missing export cannot be faked.
This is the failure mode worth remembering: the whole suite was green while the
extension died on undefined.create() inside a real pi, because every test
injects a fake session and none of them touches pi’s real module. A fake
session cannot tell you the package it stands in for has changed shape.
Anything that only runs against the real pi has to be exercised against the real
pi.
How it is split¶
extension/ is three places. Its root is the floor: index.ts, which keeps
only what genuinely needs a terminal - the renderers - and registers every
command; execute.ts, the tool body, where everything it touches is
injectable: agent loading, spawn, the second reporter, the UI, the repaint
timer; and the files below that every command stands on. extension/commands/
holds one file per slash command. extension/ui/ holds what paints or reads the
terminal without registering anything. Each of the two has an index.ts, and
that index is the only file anything outside the directory imports.
pi comes in through one file, extension/pi.ts. It names the slice of pi’s API
the extension registers through (PiApi) and the slice of pi’s context a
command reads (CommandCtx, with Ui declared once and the narrower RunUi,
KeyUi and AskUi picked from it), and it holds the two wirings only that door
knows: what the tool body is handed (ToolDeps, which execute.ts widens with
what a test may replace), and how a command reaches the session. A
handler is written against CommandCtx; pi hands it the whole
ExtensionCommandContext, and TypeScript checks at every registerCommand
that the whole has what the slice reads. There is no cast between pi and a
command, so when pi changes shape the extension compiles red, and the fake a
test builds stands in for exactly what the code asked of pi.
The commands stand on one floor, in three files. extension/deps.ts is what a
command reaches for: CommandDeps are the doubles a test puts in place, and
resolved() fills what was left unsaid with the real thing, once, so a command
reads deps.runDir and never asks which it is. extension/flags.ts reads
what was typed. extension/command.ts is the shape every command that launches
work has: CommandCtx, the slice of pi it is handed; loadRoster, the same
roster everywhere; checked(), the checks that must pass before anything is
spawned, a thrown explanation becoming a refusal; and watched(), the live view
for as long as the work runs, with the finally that takes it down and writes
usage.json whatever happened. /run, /step, /swarm and /interview
each write their flags, their target and their call, and nothing of that
shape. A step of a hand-walked chain is extension/relay.ts’s to begin
and to finish - named before it runs, recorded and drawn in one call after -
whether /step or /swarm ran it; the entry a step leaves and the door it
leaves it through are both declared there. /quote sends the message /run
sends, which is commands/answer.ts’s. How a flow is launched, checked at
both stages, run under the plan’s live view and measured in its run
directory, is commands/launch.ts’s, for /run, a flow stage of /step and
the tool’s flow mode (execute-flow.ts) alike.
Each command’s own file under commands/ holds only what that command does.
That split exists because the path that wires the reporters and calls the combinators is where the three worst bugs so far have hidden, each behind a green suite. It is now covered offline.
One live-run path. The dots above the prompt, the repaint timer, the herdr
reporter and the clean-up are liveRun() in extension/ui/run.ts - one
implementation, reached only through watched(), which the four commands and the
subagent tool all stand on. They must look identical
while they run, and several call sites with several timers is exactly how the
one nobody is watching that day drifts. What a view measures - the picture, the
clock, usage.json - is the library’s measuredRun, the same one an
experiment’s cell stands on; the view only adds a terminal to it. ui/run.ts
paints and nothing else: a flow’s plan is painted by ui/flow.ts, which draws
the frame a finished run’s message ends on too, the herdr session switch is
ui/herdr-switch.ts, and
who owns escape while a question card is up is ui/asking.ts. The one thing the
view reaches in commands/ is /stop’s watchRun, because a run has to be
known to the stop key for as long as it lasts.
Reference¶
Display - the widget, the tool row, herdr splits.
Deliver a change - what
/run buildactually does.Flows - the format
/runruns.Walk a chain by hand -
/step,/chainand/quote.API reference - the library the extension calls.