Design decisions¶
Why combo is shaped the way it is, and what was reversed along the way. This page records decisions, not the state of the code: read the relevant section before undoing a design choice, and add to it when you take one.
AGENTS.md, at the root of the repository, holds the short version - the
invariants an agent must not violate. The rest of docs/ explains how
to use what these decisions produced.
Structural decisions (do not undo without discussion)¶
In-process execution through the pi SDK (
createAgentSession()from@earendil-works/pi-coding-agent), notspawn("pi", ["--mode", "json"]). Each subagent is anAgentSessionwith its own context, model and tools. No NDJSON parsing, no process startup cost, native events, testable without a network. It is also what makes persistence possible: a session we keep alive.Two surfaces, one core: the logic lives in a pure TS library (
src/), and a pi extension (extension/) exposes it as a tool in the TUI. Every feature must be usable from a script before it is exposed in the extension.Agents and flows are data; our code decides what runs next. An agent is declared in Markdown + frontmatter (pi’s convention); a flow is YAML + Markdown built from a closed set of nodes; a workflow is written in TypeScript with combinators, for what a file cannot say. A model produces values, never the next node. Reversed: this used to read “Agents are data, workflows are code. No YAML DSL: we want composable code, not a configuration engine.” See Flows: a closed language for why the line moved.
Display is an observer, never a participant. No workflow may depend on a UI being present. Reporters (herdr, pi TUI, silent) subscribe to an event stream; unplug them all and the result is identical.
The pi API lives in one file.
src/session.tsis the only place that imports from@earendil-works/pi-coding-agent. Everything else talks toSessionPort, a minimal subset ofAgentSession. When pi moves, one file moves - and tests inject a fake session with no network, no disk, no~/.pi.A subagent inherits nothing from the user’s environment. Its system prompt goes through our own
StaticResourceLoader: no extensions, no skills, no context files.DefaultResourceLoaderwould re-read the disk on every spawn and pull in non-deterministic context nobody asked for.English everywhere - documentation, comments, public API, agent system prompts, and git commit messages. Everything that lands in the repository or in its history is English.
Mental model¶
definition (.md) ──► Agent "who" : prompt, model, tools
spawn(agent) ──► Subagent "alive" : a session, a memory, a state
subagent.ask(task) ──► Result "one turn of work"
combinators ──► Workflow "how" : chain, fanOut, orchestrate, loop
Two levels of API, the second built on the first:
// Low level: a live subagent whose lifetime you control
const coder = await spawn(agents.coder, { lifetime: "workflow" });
await coder.ask("Implement the parser");
await coder.ask("Apply these remarks: …"); // it remembers the previous turn
await coder.close();
// High level: disposable, everything is handled (spawn → ask → close)
const result = await run(agents.scout, "Find the authentication code");
Result is the one contract shared by everything else:
type Result = {
agent: string;
output: string; // last assistant text
messages: AgentMessage[];
usage: Usage; // time, tokens, cost, turns, context
ok: boolean;
error?: string;
};
A workflow is a function (input) => Promise<Result | Result[]>. Workflows
compose because they share that signature - that is all.
Subagent lifetime¶
The central point of the project. A subagent is a session: keeping it open means keeping a context, a memory, and a token cost that accumulates. Closing it means starting clean but amnesic. Both are legitimate; the choice must be explicit and local.
|
The subagent… |
Cost / context |
When |
|---|---|---|---|
|
is born and dies with each task |
minimal context, reproducible |
exploration, fan-out, independent tasks |
|
lives for the workflow |
remembers iterations, growing context |
coding↔review loop, iterative refinement |
|
lives as long as the pi session |
long memory, watch it |
“companion” agent consulted several times |
Coding ↔ review loop: the two regimes¶
Same workflow, two behaviours, one parameter:
// "Team" regime: coder and reviewer remember the previous turns.
// The reviewer does not repeat its remarks, the coder knows what it was told.
await loop({
steps: [agents.coder, agents.reviewer],
lifetime: "workflow",
until: (r) => r.output.includes("LGTM"),
maxIterations: 5,
});
// "Freshness" regime: brand new subagents at every iteration.
// No accumulated bias, every review starts from the code alone. More expensive
// in re-reading, more honest about the result.
await loop({
steps: [agents.coder, agents.reviewer],
lifetime: "task",
until: (r) => r.output.includes("LGTM"),
});
Rules:
"task"is the default. Persistence is asked for, it is never obtained by accident.A persistent
Subagentis an explicit object withask(),usage,close(). There is no global session cache hidden behindrun().Whoever opens, closes. The owner of a
Subagentis whoeverspawn()ed it. A workflow that creates its subagents closes them in afinally, cancellation included. A workflow that receives live subagents never closes them.Persistent subagents do not share their history. “Working together” means passing
Results as input, not merging contexts. If an agent must see another one’s work, you tell it in the task.Context growth is visible:
subagent.usage.contextTokensis reported to the TUI. A"workflow"agent approaching its limit must either compact (session.compact()) or fail cleanly - never truncate silently.No shared mutable state between fan-out branches, whatever the lifetime.
A turn is the pool’s interface¶
SubagentPool used to hand out subagents: acquire, release, closeAll.
Every combinator then wrote the same six lines around them - check the signal,
acquire under a key, try, ask(task, { signal, timeoutMs }), finally
release - ten times over, and the pool’s constructor, which absorbed eight of
the shared options, dropped the two that a turn needs. Twenty-four call sites
threaded signal and timeoutMs by hand, and the file holding the pool had no
test of its own: the only proof that a deadline reached a turn was one
assertion per combinator, each testing the same thing.
The pool now plays the turn. turn(agent, task, { key }) is the whole of the
sequence: it refuses without spawning on a signal already aborted, runs the turn
with the workflow’s signal and deadline, and gives the subagent back in a
finally. hold(agent, { key }) is the other shape a combinator needs - a
conversation of several turns, for the interviewer and for a swarm’s members -
and it returns an id and an ask, not the Subagent: closing stays the
pool’s job, so nothing a combinator receives can be forgotten. release is
private. What was common.ts is three files, one concept each: options.ts,
pool.ts, concurrent.ts.
Two abort checks survive outside the pool, and both are about what happens
before a turn: pair would otherwise make a working copy for a pair that will
never run in it, and interview would spawn a held interviewer it never asks.
The tests moved with the behaviour: the pool’s contract - lifetime by key, the
options on every turn, refusal without a spawn, release on a throw, closeAll
surviving a close that fails - is asserted once in test/pool.test.ts, and the
per-combinator assertions that each proved a forwarded timeoutMs are gone. A
combinator’s tests now say what the combinator decides, not what it relays.
The trail is the pool’s too¶
Playing the turns left each combinator keeping what the turns added up to. Six
of them held a steps array they pushed every turn into, seven kept a
performance.now() of their own, five wrote the same sumUsage(steps, now - startedAt), three derived ok and error from the first failed step, and two
wrapped their progress hook in the same seven-line try/catch. None of that
was the combinator’s to decide, and one of the copies had a hole: turn
refused a signal already aborted before spawning, hold did not, so swarm
held its whole roster and only then looked at the signal - a swarm called off
before it started spawned every member. No swarm test said so; the other eight
combinators each had a cancellation test.
Trail (src/workflows/trail.ts) is the envelope, written once: the results
recorded in order, a clock opened when the trail is, usage() over that clock,
broken() for the first failure. The pool owns one and records every turn on
it - a refused turn included, because a chain that was called off should say so
where the answer would have been. A combinator reads pool.trail.steps and
pool.trail.usage() and keeps nothing beside them. hold refuses the way
turn does: a held subagent is spawned before it is asked anything, so the
check on its first ask would have come after the session it was meant to
spare. A refused hold is addressed by its key, since there is no subagent to be
named after, and every ask of it answers the refusal.
The trail is the pool’s second constructor argument rather than a member the
pool creates, for two callers: pair opens its trail before its working copy,
because the time the copy takes is part of what the pair took, and its pool
cannot exist until the copy does; orchestrate has no pool of its own - the
planner’s, the branches’ and the reducer’s are three - and records the three
workflows’ results on one trail. Making the trail members of the pool would have
left both keeping their own again.
Of the two abort checks that survived outside the pool, interview’s went with
hold refusing. pair’s stays, for the working copy. audit checks before the
turn too, for a different reason: a refused turn would be recorded as an audit
round that never ran. swarm still reads the signal between rounds, because
that is where stoppedBy: "signal" is decided. deliver keeps its own sum: its
usage is the build’s, kept tasks and recorded audits of a resumed run included,
and that is a property of the build’s progress rather than of the turns this run
played.
The progress hooks go through notify in events.ts, the same swallow the bus
gives every reporter: a hook is an observer, and a throwing observer has never
been the workflow’s problem.
The tests moved with the behaviour. The trail’s arithmetic is asserted once in
test/trail.test.ts and its place in the pool - every turn recorded, held turns
too, refusals too, a refused hold without a spawn - in test/pool.test.ts. The
usage sums pair and interview each proved through their own outcome are
gone; orchestrate’s stays, because what it records on its trail is its own
decision. swarm has the cancellation test it was owed.
Every workflow is a Result¶
WorkflowResult was the contract five combinators honoured - chain, loop,
reduce, route, pair - and six did not: fanOut, orchestrate,
deliver, interview, swarm and audit each returned a shape of their own,
usage and ok included but no output, no agent, no steps. The two
places that read every combinator recoupled the gap by hand and did not agree.
The pipeline runner made a Result out of a fan-out by taking the first failed
branch or the last one and replacing its output with every branch’s joined,
made one out of an orchestration by naming the planner and copying its
messages, made a third out of a delivery the same way; the subagent tool
read an orchestration’s planning when nothing else was there. A fix to what
a fan-out amounts to reached one of the two.
The reading now lives with the combinator, and both callers do the same thing
for every kind: keep the result as it came. A fan-out speaks through the branch
that failed, or the last one, with every branch’s output labelled where its own
would be; an orchestration through its synthesis when it has one and its
planner otherwise; a delivery through its planner, over every subtask’s report;
an interview through its last turn, whose output is the brief; a swarm through
the member that failed or the last on the roster, each member’s last word
labelled by the name it posted under; an audit through its last review. ok
keeps its one meaning, every turn ran, and what a workflow says beyond that
stays in a field of its own - approved, converged, plan - which is why a
deliver step keeps the whole delivery beside its Result rather than folding
approved into ok.
joinOutputs moved from the runner into result.ts and learnt to mark a
failure: the runner’s copy printed a failed branch as a heading over nothing,
and a step reading six sections when eight ran would take the silence for
completeness. The runner also records each step’s result on a Trail, so its
usage is the trail’s rather than a sum kept by hand. One pipeline rule stays in
the runner because it is the pipeline’s: a loop that never converged fails
its step, since handing unconverged work to the next step is the silent failure
converged exists to expose.
FanOutResult.results survives beside steps, the same list under two names,
because results is what every reader of a fan-out already calls its branches
in task order and steps is what the contract calls them. InterviewResult.brief
survives beside output for the same reason. Two aliases were judged cheaper
than a rename through every caller and every page.
One shape for where a build stands¶
Removed: deliver, its progress and BuildState went with the linear
pipeline; a flow run’s journal is the one record of where it stands. See
The linear pipeline is removed. The reasons below are why the shape was one while it lasted.
“What a delivery has done so far” was spelled four ways: AuditProgress from
the audit cycle, BuildProgress for the hook and the resume, BuildState on
disk, and DeliverResult at the end. deliver held a fifth in four lets,
filled by a take() that copied the audit’s progress field by field so that
report() could rebuild a BuildProgress and outcome() a DeliverResult.
The disk had drifted from the rest: a saved audit kept the review’s text, its
ok, approved and the fixes, and dropped the verdict, the check as it stood
and what the fixes produced, so a resumed cycle read a thinner history than the
one it had lived; and the review came back as a Result with agent: "auditor" written in, whatever the auditor was called.
BuildProgress is now AuditProgress & { plan }, defined by deliver, which
is the workflow that reports it - resume.ts imports it rather than the other
way round. DeliverResult is that progress as it ended, plus what only the end
can say: the brief, the planning turn, the landings, approved, and the
Result reading. deliver keeps one progress and moves it forward by spread:
the plan once made, the tasks and the check once settled, and after every
audit round whatever the cycle reports - which is why AuditResult now carries
its cycle under progress, the same shape every round reported, so a caller
absorbs the end of the cycle in the same spread as its rounds. take() and the
four variables are gone.
done left the progress. A build’s progress does not know whether the build is
over; the moment it is reported does. So onProgress(progress, done) says both,
toBuildState takes done with the rest of what the state says about the
build, and a resume no longer carries a done nobody read. notify grew
variadic for it, which costs the bus nothing.
The saved round keeps what the live round has: the auditor’s name, the review’s
usage and error, its verdict, the check that stood, and the fixes’ results as
saved tasks. BUILD_STATE_VERSION is 2, because that is what the version is
for: an older file is refused whole rather than read into a shape it does not
fill. The two conversions are written once each, saveTask and loadTask,
shared by the subtasks and the fixes. What a state still drops is the trail -
messages, turns, the review a pair kept, the working copy - and the test that
proves it is now an identity: a full progress through toBuildState and back
is itself, less that trail.
The record states its own terms¶
The record joined the verdict to the ledger, and both callers still did the
same three things around it. They built it alike, reading declaresVerdict off
the agent and wrapping saysWord in a one-line predicate each, with its own
constant. They wrote the same ternary to offer the tool - record.tool ? … : …, cast included - one to the reviewer alone, one to the auditor. And they
handed byTool and open back out to their prompt, where reviewPrompt and
auditPrompt each rendered “Still open, from your earlier rounds:” over
openList and the same fork between calling the tool and answering the word
alone. auditPrompt had grown to seven positional parameters carrying it.
The record was deep on reading a round and shallow on asking for one, which is
why its interface exposed byTool, open and tool raw.
reviewRecord(reviewer, { word, approved?, restored? }) now takes the agent
and the word. Whether it decides by tool is read off the agent here, and a
caller’s own approved stands in for the tool and the word alike, because the
caller’s rule is the nearer one. Two things it renders itself. terms() is
what every round ends on: the open lines by id, then the instruction - a call
to the tool, with the resolved sentence when anything is owed, or the word
alone. offer(others) is what goes through customTools: the tool to this
reviewer and nobody else, others to everyone else, others untouched when
the reviewer decides in prose. The prompts take terms as a string, so they
stay functions of data: reviewPrompt(goal, work, round, terms) and
auditPrompt({ brief, tasks, round, maxAuditRounds, verification, workers, terms, byTool }), the latter keeping byTool because the fix lines go where
the decision goes.
Two changes of behaviour rode along, both towards the rule that an agent gets
what its file names. A pair whose reviewer held the tool offered its worker
nothing, dropping whatever the caller had passed in customTools; and the
audit’s pool dropped the caller’s customTools whatever the auditor held. Both
now pass others through. The prose instruction reads “Answer WORD alone when
you have nothing left to ask for” for the auditor as for the reviewer; what
differed between them was wording, not meaning.
The rules of the ledger are asserted once, in test/review.test.ts, where the
terms and the offer are too. The pair’s “an obligation a round does not name
stays open” and the audit’s “a yes over an open obligation does not approve”
each proved a record rule through a workflow, and are gone; what the two
workflows still assert is that the terms reach their prompt.
Workflows to cover¶
Workflow |
Shape |
Semantics |
Status |
|---|---|---|---|
|
1→1→1 |
output of step n is the input of n+1 |
done |
|
1→N |
N subtasks in parallel, bounded concurrency |
done |
|
1→1 |
iterates until a criterion (judge, test, regex) is met |
done |
|
N→1 |
one agent synthesises a fan-out’s results |
done |
|
user→1 |
an agent questions the user, one question at a time, and writes a brief |
done |
|
1→1 |
a worker and a reviewer discuss until the work is accepted |
removed, a loop of the |
|
brief→? |
plan, a pair per subtask, a check, an audit, fixes |
removed, the |
|
1→? |
an agent decides the split, then delegates (dynamic fan-out) |
done |
|
1→1 |
a classifier agent picks the destination agent |
done |
Rules:
Every workflow is an exported function, not a class. No inheritance, no global registry.
They all accept
{ lifetime, signal, timeoutMs, openInHerdr, onEvent, bus, cwd, sessionDir, exportDir, spawn }- same names, same defaults ("task", then none) - plus whatever is specific to them (concurrencyandfailFastforfanOut,untilandmaxIterationsforloop).spawnis an injectable parameter, never a hard import inside a combinator. That is what makes workflows testable without a network.Cancellation propagates: the
AbortSignalreaches every turn and closes the sessions that were opened.timeoutMsis a per-turn deadline, with no default. pi’s agent loop is awhile (true)(pi-agent-core/dist/agent-loop.js:84) with no step cap: it runs as long as the model keeps requesting tools. A weak model that hallucinates a tool name, gets “unknown tool” back and asks again will loop until something stops it - observed in the wild, 79 calls to a non-existentruntool, ~500k input tokens in a single turn. A signal alone is not enough: something has to fire it. No default value, though - the library does not get to decide that a legitimate task took too long.loop’smaxIterationsdoes have a default (5). That is not inconsistent with the above: an iteration is a discrete, expensive unit with a meaningful small default, whereas any default wall-clock deadline would be arbitrary. The two guards sit at different levels -timeoutMsinside a turn,maxIterationsbetween turns - and “loop forever” must not be reachable by forgetting an argument.Reaching a cap is not success.
loopreportsconvergedseparately fromok:oksays the last turn ran without a model error,convergedsays the work reached the bar. A loop that burns throughmaxIterationswith every turn technically fine isok: true, converged: false, and collapsing those two into one boolean would hide the only thing worth knowing.A failure does not crash the workflow: it becomes a
Resultwithok: false. It is the caller (or an explicitfailFastoption) that decides to stop.A reduction shows its failed branches, it does not drop them. A synthesis built from six branches when two of them crashed, with nothing saying so, is a confident lie - and the caller can no longer tell a thin answer from a thin body of evidence.
formatBrancheslabels them; a caller who really wants only the successes filters the array, which needs no option.reducereturns the branches insteps, followed by the synthesis. The cost of an N→1 is the cost of everything that produced it, and aResult.usageis always one turn - so the total has to be summable fromsteps.Lifetime cannot change the shape of every combinator.
reduceis one agent and one turn:"task"and"workflow"both spawn once. The lifetime test then asserts what is actually observable (the option reaches the spawn, and the subagent is closed either way) rather than inventing a spawn count difference that does not exist.How the decision of a deciding agent is read: a parsed convention. That was
orchestrate’s one open question, and the answer isparsePlan. The alternatives were weighed: a tool call is possible (createAgentSessiontakescustomTools) and would give validated arguments, but it means teachingSessionPortabout tool definitions and betting the combinator on a model that reliably emits tool calls - the weak models this library is run against do not. Structured output is not uniformly available across providers, andprompt()returns text either way. A parsed convention costs one function, works everywhere, and the check that actually matters - is this a real agent name? - is a lookup no schema would have replaced.The parsers are lenient, and only they.
parsePlanandpickDestinationread what a model wrote, not what a caller passed; everywhere else a malformed input is an error. The leniency is not a guess, though: an unrecognised agent name is dropped, never remapped onto a plausible neighbour, and an ambiguous routing answer resolves to nothing rather than to the first match. Silently doing the wrong work is worse than failing.Leniency is decided by real runs, not by taste. Asked for a JSON array, the planner answered with bare objects and no brackets - a green suite and a reasonable-looking prompt had said nothing.
parseJsonPlantherefore collects every{…}block that carriesagentandtask, in order, so an array, a lone object, several objects on their own lines and a fenced block all reduce to the same plan.Routing reads the agents’
description. That field is already mandatory, so routing needs no second vocabulary to maintain - and a vague description produces vague routing that no parser can repair.orchestratecaps the plan (maxTasks, default 8) and fails before spawning. Every subtask is a session and a bill; a plan of two hundred steps must not be reachable by a hallucination, and losing a run costs less than paying for a runaway one. Same reasoning asloop’smaxIterations, one level up.Independence cannot be enforced, only asked for. The planning prompt insists that subtasks run in parallel, and a weak planner still produced a step beginning “review the code identified by the scout”. Nothing in the combinator can check that, and adding a dependency graph would be building
chaina second time. When the work is sequential, usechain.Reading code is not running it.
delivertakes averifyport, and when one is given its verdict is final: no approval makes a failing check a success. This is not a precaution, it is a bug that shipped - in a real run a pair wrote a helper and its tests, the reviewer approved, the auditor approved, and the test file imported./slugify.jsfor a file namedslugify.ts. The suite never even loaded. Both agents had read the code.An agent that decides must see the roster. The planner was given the list of workers and the auditor was not, so it answered
agent: fix the quote- literally the word “agent” - and every fix was dropped as an unknown name. Found by a real run, not by a test.A refusal in prose still has to reach someone. An auditor that explains the fix in English and names nobody is refusing all the same. With exactly one worker the whole review goes to them - there is no ambiguity to resolve. With several, dropping it stays right: guessing who owns a fix is how the wrong file gets rewritten.
No speculative abstraction: a combinator is added when a real example needs it.
reduceis deliberately not chunked: folding branches in batches to fit a context window is a real need when it appears, and until it does it would be a configuration knob nobody asked for.
Reading what a model wrote lives in one file¶
src/text.ts holds truncate, firstLine, scalar, saysWord and
jsonObjects: everything that turns free-form assistant text into something a
workflow can act on. Only truncate is on the public surface, because the
extension needs it and an extension imports from src/index.ts, never from an
internal file.
This reverses a decision. interview.ts carried a comment saying its brace
scanner was kept apart from plan.ts’s on purpose, since “merging them would
mean a generic find-me-some-JSON utility that neither caller could read”. The two
had since become character-identical, and what they share is only the scan -
each caller still keeps its own filter (readStep, the question reader), which
is where the readability actually lives. The same held for the LGTM /
APPROVED / READY matcher, written three times, and for truncate, written
five times and already drifting: one copy trimmed, one did not.
The rule that survives is the one that made the original call defensible: a shared helper takes the part that is identical, never the part that is interpretation.
Writing for a model lives in one place too¶
The other direction had the same history. “Several results, laid out for a
reader” was written six times - the tool’s answer, the runner’s join, the
delegate’s report, reduce’s branches, the audit’s reports, the swarm’s
answer - with four heading grammars (numbered or not, (failed) or - failed,
one blank line after the heading or none) and three spellings of nothing
(unknown error, (nothing), (no output)). Whoever compared a /run
answer with a tool result read two conventions for one fact. Beside it, four
truncations with a marker, two identical but for a word; plural written and
then walked past at four sites, one of them in a file that imported it; and
the list of lifetimes kept in three places, each checking it its own way.
joinOutputs(results, { numbered?, note? }) in result.ts is the one layout:
a heading naming the agent, numbered when the caller says so, a note in
parentheses that defaults to failed on a failure, a blank line, then the
output or (no output) or the error. The two knobs are the two things the six
sites varied for a reason - a reducer refers to branches by number, an audit
says what a review made of each part - and nothing else was. The swarm’s
answer labels by member id through membersOutput, shared with the swarm’s own
Result. head and tail in text.ts are the two cuts, one for what goes
into a prompt and one for a check’s output, and git.ts, worktree.ts and
verify.ts call them. The four counts go through plural. pipeline.ts
reads LIFETIMES from agent.ts, so the tool’s schema, the definition parser
and the pipeline parser name the same three words.
Two visible changes rode along. Every heading is followed by a blank line, as
Markdown wants, where the tool and the delegate had none; and an audit’s failed
report says (failed) beside the name with the error where the output would
be, as every other failed section does, rather than (failed: error) over
(no output).
A result is built in one place, and a double is built whole¶
failed() had six callers and no pendant: the six-field literal of a turn that
ran was spelled out in the core twice, in a workflow, in a resume and three
times in the fake, each free to drift - the close event’s Result carried
messages: [] and a cumulative usage, a shape no ask returns, and nothing
said so. succeeded(agent, output, usage?, messages?) is the pendant, and the
six sites call it. A failed review or a failed fake turn that has text keeps
it, by spreading output over failed(…), because that text is the evidence.
The spawn event was constructed field by field in the core and in three test
files, each with its own launch counter; when order and parentId were
added, all four moved. The core keeps its one, since it is where the event is
born; the reporters’ test now derives its two events from test/fixtures/ picture.ts, which is where the other tests already took them from.
The tests of /build and /run fed runPipeline and interview doubles a
corner of what the real thing returns, and cast the rest away: forty-nine
as never in two files, and a test author who had to know which corner a
command reads. test/fixtures/results.ts builds an InterviewResult, a
DeliverResult and a PipelineRunResult whole, from the few fields a test
cares about, the way fixtures/picture.ts builds a snapshot. The casts are
gone, and a double that stops matching the real shape now fails to compile
rather than passing on a lie. Removed: the file went with the linear
pipeline; no test had used it since the commands took flows.
A verdict is a tool call, and prose is the argument for it¶
A reviewer’s answer carries two things: an argument, which is prose and belongs
in the transcript, and a decision, which is a boolean and does not. saysWord
recovers the second from the first, and how well it does that depends entirely
on the agreement about how to write the word. includes accepted “I cannot say
LGTM yet” as an approval; the whole-line rule that replaced it rejects that one
and still rests on a convention a model is free to miss.
So a reviewer whose tools: names verdict is handed that tool, and its call is
the decision. pair reads the collector behind the tool, never the text beside
it.
Three options were weighed.
Keep parsing prose, more carefully. No new machinery, and the ceiling is the convention: every refinement buys one more phrasing and leaves the next one.
Ask for structured output, a JSON object read by jsonObjects. Cheaper than
a tool, and it still arrives inside the prose channel, so “the model wrote
something that parses” and “the model decided” stay the same event.
A tool call, which is what was taken. It is a discrete event with a schema,
on its own channel, so did it decide and what did it decide are separate
closed questions. It costs the customTools seam in src/session.ts and one
file, src/review/verdict.ts.
Three consequences worth stating plainly.
A reviewer that holds the tool and calls nothing has not approved, and
PairResult.verdict is absent rather than false: a turn that failed to answer
is not a refusal, and guessing which one it was from the prose is the reading
this tool exists to retire.
The tool takes the decision and leaves the argument where it was. What the
worker receives between rounds is the reviewer’s prose, not verdict.remarks:
an agent’s definition disciplines its prose, and a field the model fills a second
time says the same thing worse. remarks is the short form attached to the
decision, and it is what an obligation will be opened from.
And the word survives for every reviewer that holds no tool, because an agent nobody offered one to still has to be able to say yes.
None of this makes a model’s judgement deterministic. It makes reading that judgement deterministic, which is the only part of it that was ever ours.
A decision is not lost to the bookkeeping beside it¶
Reversed, and the measurement is what reversed it. The verdict tool used to
refuse the whole call when resolved named an id nothing had raised, on the
grounds that a closure the ledger would refuse is better refused where the agent
is told and can call again. Told, the agent called again with the same invented
id, was refused again, and answered APPROVED in prose that nothing reads. The
delivery ended unapproved with no fix raised and no reason a user could see,
over an id that closed nothing either way.
So an unknown id is dropped from the verdict and named back in the same result,
beside Recorded: approved. The ledger is exactly as honest as before - an id
nobody raised closes nothing, here or in ledger.close - and what changes is
that the decision the call carried survives the mistake sitting next to it.
The refusal that stays is the one about the decision itself: approved: false
with no remarks and nothing raised is still refused, because there the missing
part is the answer.
Finished is a ledger, not an opinion¶
Measured on the run that shipped the verdict tool: the reviewer called verdict
correctly and approved a function that computes a - b while claiming to add. A
clean channel does nothing about a wrong judgement, so approved stops being
what the reviewer said.
Everything a reviewer raises becomes an obligation in
src/review/ledger.ts, with an id combo assigns and that never changes.
Finished means the reviewer has nothing further to ask and nothing it raised
is still open.
The ledger belongs to this code rather than to the agent. Asking a reviewer to re-emit its remarks each round puts us back to matching one round’s prose against another’s, where “is this the same remark as last time” is a guess. A round is handed the open ids and answers a closed question per id.
Three rules, in code rather than in a prompt:
Only whoever raised an obligation may close it. A worker cannot declare its own work accepted, and
closerefuses withok: falserather than throwing - an agent naming the wrong id is a runtime outcome, not a programming error.An obligation a round does not name stays open. A model that forgets has not approved, and failing closed is the only default that cannot be talked round.
Nothing is rewritten. An obligation keeps the text it was raised with, so a reworded one is a new one.
Closures are applied before anything new is raised, so a round cannot raise and close the same obligation in one call.
An id the ledger has nothing open for is refused by the tool, not silently
by the ledger afterwards. Measured with a small open-weight model: an auditor
with an empty ledger sent resolved: [{ id: "1" }], inventing both the line and
the id format. The ledger refused it and the outcome was right, but nothing said
so anywhere a reader would look. Refusing in the tool tells the agent which ids
it may close and lets it call again inside the same turn, which is the only
moment it can still repair the mistake.
A boolean could only ever say that the work stopped. A ledger says which lines are open and since which round, which is the difference between a pair making progress and a pair that is stuck.
It buys nothing against a reviewer that closes an obligation it should not have. That is still a judgement, about a single sentence the reviewer wrote itself rather than about the whole of the work, and attributable to it.
The auditor in deliver signs the same way, and its fix lines are what go on the
ledger. Its prose is then not read at all: deliver has a concession for an
auditor that refuses in English without naming anyone, and that concession is
for prose-only auditors. Applied to one holding the tool it turned the word
APPROVED, written beside a call that said otherwise, into a fix a coder was
sent away to make. DeliverResult.approved then needs three things: the auditor signed off,
nothing it raised is still open, and the check passed. The check keeps the last
word it already had.
Obligations are the one thing besides the plan that survives a resume. A subtask
that was still being argued over runs again, because nobody signed off on the
tree it left; an obligation that was open is still open, and the resumed run
keeps its id rather than raising a duplicate of it. BuildState.obligations is
optional for a state written before the ledger existed, and an empty ledger is
the honest reading of that rather than a reason to refuse the file.
The record is the one that joins the verdict to the ledger¶
The tool and the ledger were built in two places, pair and deliver, with the
same two callbacks wiring one to the other written out in each; and each round
of each workflow then made the same join by hand - take the last verdict, apply
its closures, raise what it raised, decide said && settled. pair had it in a
helper, deliver inline. The rule that makes the join safe, closures before
raises, was tested through pair and nowhere near deliver.
src/review/review.ts is the record: one per reviewer, both the tool and the
list. reviewRecord(name, { byTool, inProse, restored }) builds the ledger and,
when the reviewer decides by tool, the tool wired to it. record.tool is what
the reviewer is offered, record.open what a round is asked about, record.all
what a result reports, and record.close(review, round) answers the one
question a round has: what was declared, whether the reviewer said yes, whether
that finishes anything, and what was raised. A review that did not run to
completion decided nothing, and the collector is drained regardless so a
verdict left behind by a failed turn cannot be read as the next round’s.
byTool is the caller’s to say, not the record’s to infer: pair lets a
caller’s own approved predicate stand in for the tool whatever the agent
declares, and that is pair’s rule to keep readable in pair.ts.
lastVerdict is gone. It was one line, its only callers were the two joins,
and the rule it carried - the last call wins, because an agent that calls again
has changed its mind - is the record’s to state. verdict.ts and ledger.ts
stay as they are, each with its own rules and its own tests: the record joins
them, it does not absorb them.
The flow grants the verdict, and a definition does not name it¶
The shipped reviewer and auditor named verdict in their tools: and told
their model to call it “when you are given the tool”. Only a verdict: node
gives it, and the runner adds it to its agent’s tools: itself, as it adds
submit for a typed node, so the name in the file granted nothing anywhere.
What it did was send the model after the tool where nothing offers it.
Measured against ilaas/gemma-4-31b: a /step reviewer called verdict three
times, each answered Tool verdict not found, before its prose, and the
reviewer in the tool’s loop did the same beside the LGTM the loop read.
The reviewer was held to two contracts at once, a word for a loop and a call
for a node, and outside a node only the word was ever read.
Three fixes were weighed.
Refuse, before the spawn, an agent that names a tool nobody provides.
Strict, and readable in the file. It would have made the shipped reviewer
unusable in a /step and in a loop, where it runs most, and it puts the
fault on the person running it rather than on the definition.
Give a reviewer outside a node a verdict bound to a throwaway ledger. The
calls would succeed and nothing would read them: a loop stops on a word, so
the tool would answer “Recorded” for a decision no code looks at. The verdict
tool exists so that a decision is read off its own channel, and this one
would have no reader.
The definitions stop naming it, and the flow grants it. Taken. The node
already adds the tool, and the closing part of its turn already says the call
is the decision. The definitions keep the word, LGTM and APPROVED, which is
what a loop’s until reads. Each contract is now stated once, by whoever
reads it: the word by the definition, for anything that matches words, and
the call by the node that reads the call. What an agent can do stays readable:
its file says what it holds everywhere, and a verdict: node says what it
adds, as output: does for submit.
The auditor’s line about putting its fixes in raised moved to the audit
section of the shipped build, the one place it applies. test/agent.test.ts
holds every shipped agent to the rule: neither verdict nor submit in its
tools: or in its prompt. The tool’s until description names the two words,
so a session calling loop knows what the shipped agents answer with. A
user’s agent that still names verdict is not refused; it runs as before, and
Agents says what the name costs.
A review that answers in prose is asked again, and its call ends the turn¶
Once the definitions stopped naming verdict, reviews in real /run build
runs on ilaas/gemma-4-31b ended in prose: in three of four pair reviews
across two tutorial captures the reviewer wrote LGTM, wrote a remark, or
typed the call out as a code block in its text, and never called the tool.
Each review failed schema. pair is on-fail: continue, so the work
landed, but the remark (slugify("hello.world") returning "helloworld")
was never raised, and no later round went after it.
The failure is rare in fresh runs and frequent in some contexts. Fresh runs of
a loop with a ledger, a coder and a verdict: reviewer, and of the shipped
build, called the tool in 109 review and audit turns out of 110. The one that
did not ran out of output tokens. Replaying the four failed turns, each in its
own recorded context and six times over, is what tells the candidates apart:
candidate |
turns that called |
|---|---|
the turn asked again, as it was |
13 of 24 ( |
(a) a closing line saying prose, |
13 of 24 |
(b) the reviewer’s file: “when you are given a tool to record your decision, that call is your answer” |
18 of 24 |
(b) the same, naming |
14 of 24 |
(c) the failed answer kept, then the runner’s retry turn |
24 of 24 |
(a) changes nothing. The closing line is already the last thing before the language line, and a stronger one is read no better.
(b) halves the failures and breaks a rule. A definition that speaks of the
tool, by name or by description, describes something that a /step and a
loop never hold, which is why the definitions stopped naming it.
(c) is taken: retry: 1 on the review and audit nodes of the shipped
build. A turn that ends without the call fails schema, retry: covers
schema, and the retry turn names the failure to the same subagent. The
definitions stay as they are, and the flow file says that the node is asked
twice. When the tutorials were captured again with it, two of the four
reviews answered LGTM in prose first, and both retries came back as the call.
The retry turn exposed a second defect. In 2 of those 24 retries the model
called verdict, read Recorded: not approved., and called it again, more
than 250 times, until the turn was cut. Fresh build runs showed the same loop
once, 59 calls until the turn’s deadline, and the last call, which the record
reads, approved a change 33 of the others had refused. So a verdict: node’s
recorded call now ends the turn (terminate in pi’s tool result). Nothing is
lost: the node’s output is the call, and its prose is never read. With that,
the retries called the tool once each, 24 of 24, in a median of 13 seconds,
and eight fresh runs of the shipped build ended each of their 24 review and
audit turns on a single call. The TypeScript pair keeps a turn going after the call, because it hands the
worker the reviewer’s prose, and that prose mostly comes after the call.
A /step reviewer still calls a verdict tool nobody offered, in four runs
of four with this change and two of two without it, although no file it reads
names the tool. That is the model’s own habit, and there a refused call costs
a line of the display, not a decision.
A turn cut by the output limit fails, and every agent node of build is asked twice¶
Measuring real /run build runs on ilaas/gemma-4-31b turned up two holes.
A turn that ended on the output limit passed. pi ends such a turn with
stopReason: "length", andlastTurnread onlyerrorandabortedas a failure. On a node with neitherverdict:noroutput:, a turn that spent its whole output budget thinking passed asok: truewith an empty answer, and the next node read nothing. Reproduced with a scratch agent directory (PI_CODING_AGENT_DIR, the user’s~/.piuntouched) giving the modelmaxTokens: 200: 200 output tokens,output: "",ok: true. Two other models on 24 tokens passed a sentence cut mid-word the same way.lengthis now a failure inlastTurn, text or not. That is the one place pi’s message shape is read, soSubagent.ask,run(), every combinator and the flow runner see it alike. What is there is kept as the message’s text, but it is not an answer: a cut plan or a cut report handed on as whole is the same silent failure with more words. In a flow it isprovider, the kind for a turn that failed on the provider’s side, soretry:covers it with no new kind inERROR_KINDS: a new kind would be a thirteenth that every condition readingx.error.kindhas to learn, for a failure no flow has been measured treating differently.plangetsretry: 1. 2 of 16 real builds failed there, one onschemaand one on a provider error; both are whatretry:covers, and a plan that fails ends the build before any work.reviewandauditgot theirs for the same reason.So do
locateandreport. Neither failed in those 16 builds, but the first hole closed turns a cut answer, which used to pass, into a failure on exactly those two untyped nodes.reportruns after every patch has landed, so a failure there fails a finished build for want of one more turn.locatefailing costs less, since nothing has run yet, and one retry costs less still. Every agent node ofbuildis now asked at most twice.
Every agent node of the shipped flows is asked twice¶
build alone had retry: 1 on every agent node, while explore, split,
interview and build-attended failed on the first provider error, cut
answer, deadline or missing submit. Each node was weighed on its own, and
none had a reason to go without:
explore’sfindandsplit’sact. Their map hason-fail: continue, so a failed scout or worker did not fail the run, but it reached the answer as a hole: a third ofexplore’s evidence, or a whole task ofsplit’s. The failuresretry:covers are transient, and one more turn costs less than an answer written around a gap. A second failure still reaches the answer as a failed report, as before.split’splan. It is typed, so a plan that forgetssubmitfails onschema, the failurebuild’splanwas measured failing on. It fails the run before any worker starts.explore’s andsplit’sanswer. It runs last, so a failure there throws away every report already paid for, the casebuild’sreportgot its retry for.interview’sask_next. The loop’son-fail: continueabsorbs a failure, but it absorbs it by ending the questions: one prose answer where asubmitwas due would silently handbriefan interview cut short. It hasmemory: flow, so the retry resumes the same interviewer, which keeps what it asked and what it was told. No question is put twice: the card is its own node, afterask_next.interview’sbrief. It is the flow’s output, written once the person has answered every question. Failing there loses their answers, and inbuild-attendedfails the run before the confirm.build-attended’smessage. It runs after the whole build, so a failed commit message fails a finished build. Thecommitnode refusesretry:and names the node writing its message as the one to take it.
Each of these nodes is now asked at most twice, and every bound counts the second turn.
A working copy belongs to the work, not to the subagent¶
deliver pins concurrency to 2 because its workers write to the same tree.
A git worktree each turns that into a question about the tasks rather than about
the filesystem, and pair takes worktree: true to ask for one.
The copy is the pair’s, not each agent’s. A reviewer given its own would be reading the code the worker did not touch, which is the one arrangement that looks right and reviews nothing.
Two consequences that were found by writing it rather than by reasoning about
it. git worktree remove refuses a copy holding changes and knows nothing about
the patch a caller is holding, so patched: true is both our guard and the only
thing that lifts git’s. And pair cannot return from inside its round loop any
more: the copy is released in the finally, and a result built before that
carries no patch.
A copy that cannot be made stops the pair. Carrying on would write into the tree the caller asked to spare, which is the failure the option exists to prevent. And a copy that cannot be released fails it too: the first version dropped that error, so an approved pair whose patch never came back was indistinguishable from one that wrote nothing, while the work sat in a temporary directory nothing named. The path goes in the error.
worktree is an option of pair, not of WorkflowOptions. On the shared type
every workflow accepts it and one honours it, which is a silent no-op for the
rest - fanOut({ worktree: true }) would typecheck and run in the caller’s tree.
The work is committed on the copy’s branch before the copy goes. A first run
left the branches pointing at the base commit, holding nothing, one pair of them
per run: the patch was the only copy of the work and PairResult.worktree named
something empty. A branch that turns out to hold nothing is cleared with
git branch -d, which refuses any that holds something.
Copies of one repository are made and removed one at a time¶
git keeps the list of a repository’s copies under .git/worktrees/ and takes
no lock on it. A worktree add writes the new entry one file at a time, and
add, remove, list or branch -d walking the list meanwhile can read it
half written and die. A remove that takes the last copy can also delete the
directory under an add about to write into it. Two copies made and released
at once are enough, which is exactly what copies: true does. A test caught it
once in 94 suite runs under load, and a stress run of the same pattern failed 8
times in 14,400 copies.
So those four commands wait in a queue per repository (src/git/registry.ts).
It is keyed by the common git directory, not by cwd, because a copy and the
repository it came from share one list. Three alternatives were set aside:
Retrying the command when it fails. The failure shows up as several different messages, and a retry keyed on those messages would hide a real error that happens to look the same.
A lock file shared across processes. One run makes all its copies in one process, and nothing has yet needed two processes to share a repository’s copies. The limit is stated in the guide instead.
Serialising every git command. Only the commands that touch the list race. Queuing
apply,commitordiffwould make parallel branches wait on each other for nothing.
Patches go in one at a time, and nothing is undone¶
Two patches that each apply cleanly on their own can still be wrong together: one renames what the other calls, both add the same helper under two names, or the second simply overlaps the first. Three ways of dealing with that were weighed.
Hand them back and merge nothing. Honest, and it leaves deliver stopping
one step short of where it stops today. It also makes the common case, subtasks
that really were disjoint, cost a human a manual step every time.
Apply them all, then check once. Cheapest, and it answers the wrong question: a red tree after three patches says only that one of them broke it.
One at a time, with the check between them, which is what land does. Which
patch broke the tree is then a fact rather than a bisection, and the cost is one
run of the suite per patch, paid only by callers who gave a verify.
A patch is checked before it is applied, so one that does not fit touches
nothing. --3way is deliberately not used: it writes conflict markers into the
files and calls that success, and a caller left to find markers in a tree it
believed clean is worse off than one told the patch was refused.
Nothing is rolled back. A failure stops the rest where it is and what landed stays landed. Undoing would mean discarding work that was expensive to produce, and every patch is also on a branch, so nothing is lost by leaving the tree readable. The tree must be clean to start with, for the same reason: landing onto somebody else’s changes makes “which patch broke this” unanswerable, which is the one question the whole arrangement exists to answer.
Landing adds no commit and moves no ref. What goes in stays in the working tree,
the way deliver already leaves its work for a human to read.
deliver takes worktree and does both halves: a copy per subtask, then a
landing per batch of them. That is why the two were built in this order -
deliver’s check had nowhere to run while the work sat in copies nothing put
back, so the policy had to exist before the delivery could use the mechanism.
approved gains a third condition there: the auditor signed off, nothing it
raised is open, the check passed, and every patch reached the tree. Work
nobody could land is not delivered, whatever was said about the reports.
A delivery lands more than once: the planned subtasks, then each round of audit fixes. Only the first of those meets a tree it did not write, so the clean-tree precondition is the first caller’s and not every call’s. Measured: with it on every call, a real delivery whose audit asked for one fix had that fix refused with “refusing to land onto a tree that already has changes in it” - changes the delivery had put there itself one step earlier.
It still does not combine with resume. A resumed delivery finds a tree holding
what a previous process landed, which it has no record of, so it cannot tell
that work from somebody else’s. That refusal is right rather than missing.
Reversed for flows: a flow’s journal names every copy it opens and every
patch it lands, so a resume tells its own work from somebody else’s, and takes
back the copies it left open. See
A resume goes as deep as the journal.
How the work reaches the tree is one policy, asked once¶
Removed: settle.ts went with deliver, its one caller; a flow’s copies
are the file’s (copies: true), landed by the runner. See The linear pipeline is removed.
deliver decided its copies in five places: whether to isolate, from the
number of writers; the pre-flight on the tree, only when that decision was its
own, with a refusal in two spellings; the worktree it handed each pair; a
settle that landed a batch or only ran the check; and a list of landings whose
first entry alone had to meet a clean tree, and whose every entry weighed on
approved. Five rules of one concept, between the plan and the audit.
settle.ts is the concept. settling({ cwd, worktree, writers, verify })
decides, pre-flights and refuses; what it returns says whether pairs are
isolated, settles a batch and answers with the tree’s check, and keeps the
landings and whether every patch reached the tree. deliver asks once after
the plan, hands isolate to pair, settles after the subtasks and after each
round of fixes through the audit’s fix, and reads landings and landed for
the result. It names neither land nor landable any more: land.ts stays the
mechanism any caller may use, and this is what a delivery does with it.
The worktree knob does not move. It is pair’s and deliver’s, for the
reason the decision above gives, and pair’s side - one copy for one piece of
work, released in the finally, the path named when it could not be - was
already the shape a single copy wants. What this settles is the batch.
Two git status on the same tree, kept on purpose. A review read the
pre-flight in settling and the clean-tree check land makes on the first
landing as one question asked twice, and proposed that settle own it once
and land take the answer. They are the same question at two moments. The
pre-flight answers before any work, so that no subtask is paid for whose patch
cannot come home; the landing-time check answers on the tree as it stands then,
minutes later. Nothing of combo’s writes into that tree in between - the pairs
write in copies - but the person whose tree it is may, while /build runs,
and a patch landed onto their edits would make “which patch broke this”
unanswerable, which is the one question land exists to answer. The first
check is a warning the second can only be anticipated by; removing the second
would trade the guard for one git status. The comment now sits at the call,
so the next reader does not have to rediscover it.
settling keeps returning a GitResult. Its refusal is what git said about the
tree, with both ways out appended; a ninth two-armed outcome type for the same
fact would be one more shape to learn, not one less.
timeoutMs has no default in the library, deliberately: it cannot know how long
a task should take. A command can, and /interview has to. The interviewer
reads the repository between questions, pi’s agent loop has no step cap, and the
person waiting for the next question cannot tell a slow turn from a stuck one.
Five minutes is what NEXT.md measures these models at; 120s fails roughly half
the turns, so a shorter one would cut work that was going to finish.
The interview writes its transcript too, into the same runs/<timestamp>/ the
pipeline uses, and that folder is now made before it rather than after. A
failed interview used to leave nothing behind, which is the one moment the only
question worth asking is what was actually sent - a report of [node9] prompt blocked from a guard sitting in front of the provider had no record to check it
against. The failure names the folder.
The interview is also watched like everything else. It had a fixed
interviewing… and no reporter, so its first turn - 35 seconds and seven file
reads, measured on this repository - looked exactly like a turn that had hung.
Display is an observer and unplugging it changes no result, which is why adding
it here costs nothing and why leaving it out cost the only thing it could: the
user’s confidence that anything was happening.
The same place had --model checked before the interview and then not used by
it, so the interviewer ran on whatever ~/.pi/agent/settings.json named. That is
the hole invariant 5 exists to close, one command away from where it was closed
everywhere else.
The interview answers in the language it was asked in¶
A question the user reads less precisely is one they answer less precisely, and the specification is the artefact they are handed to correct before anything is built on it. Both come back in the language of the request.
It does not contradict the English-everywhere rule: that rule governs what is written into the repository, and none of this is. The specification does travel on to the planner, the coder, the reviewer and the auditor, whose own prompts stay English. A model takes a request in any language without trouble, and the person signing off on the spec reading it exactly is worth more than a uniform pipeline.
Two things are exempted by name, and both would break silently otherwise.
parseQuestion reads the question by its JSON keys, so a translated
options is a question nobody can display. And the loop ends on READY: a model
told to write French writes PRÊT, which is an interview that never finishes and
burns every one of its questions first.
This was the first place output left English, and for a while the only one -
every subagent answers that way now.
The per-turn reminder stays: it is the one prompt that names its own two
exemptions, and an interview that loses READY costs six questions before
anybody notices.
A subagent may have subagents¶
An agent whose tools: names subagent is handed delegateTool(), and can
split its task across children of its own. examples/14-delegation-tree.ts is
the case it was built for: one explorer that cannot read a large repository in a
turn, three scouts that each read a part, one note back.
This is the second exception to invariant 5, and the only one besides
situate(). Granting it is not inheritance: the tool comes from combo rather
than from the user’s machine, the roster is the one the caller passed, and an
agent that does not name the tool in its own definition cannot have it. What an
agent can do stays readable in its file, which is the part of that invariant
that was ever load-bearing. It is still an exception to the letter, which is why
AGENTS.md names it.
The tool is built outside spawn and handed over through
SpawnOptions.customTools, the seam the verdict tool already uses. spawn never
learns what a roster is, and the recursion stays in one file: a child that
declares the tool is handed one built at depth + 1.
How wide, and how deep, are decided by different people. The roster and the
bound are the caller’s: what an agent may reach, and how far a tree may grow,
are facts about the run. concurrency: is the agent’s own, read from its
frontmatter, because how many pieces a task is worth splitting into follows from
how the agent was told to think about it. An explorer asked for two to four
tasks wants three in flight, and that belongs beside the instruction that asked
for them rather than at every call site.
A child that delegates in turn is read the same way, from its own file.
The depth guard ships with the feature. Delegation that can go on forever is a bill discovered afterwards. Two levels by default - the session, a child, a grandchild - which is where a split stops paying, because a grandchild rarely knows enough about the whole to split anything usefully.
At the bound the tool is still handed over and refuses when called, saying how
deep it is and how deep it may go. Withholding it would leave a model calling a
tool that does not exist, getting “unknown tool” back and trying again, which is
the runaway turn timeoutMs exists to survive rather than one to cause.
The bound travels in a closure. Nothing reads the environment for it: an ambient variable is how the model hole in invariant 5 existed, and that mistake is not worth making twice.
Delegation reaches pi the way it reached a script¶
The subagent tool wires delegateTool for any agent whose definition names
it, and for nobody else. No flag turns it on: a call that enabled delegation
would be the caller granting a capability the agent’s file does not admit to,
which is the whole of invariant 5 read backwards. maxDepth only tightens the
bound the library already has.
The children inherit the call’s terms - model, deadline, export directory,
signal - and not its lifetime: a delegated child is disposable, whatever the
parent was asked to be. orchestrate gets it too, through the same
customTools, so a planner’s worker that declares the tool can split its share.
The drawing follows parentId rather than the spawn order, everywhere at once:
the dots above the prompt, the collapsed tool row, the expanded view. A
WidgetRow carries a depth and the terminal applies the indent - the
collector lays out and never draws, the same split the rest of the display
already keeps. A run with no delegation renders exactly as it did, which is the
property the tests pin.
A fan-out reads in the order it was launched¶
Three scouts launched together drew as scout#2, scout#1, scout#3, and stayed
that way for the whole run. The collector kept arrival order and the comment
above it claimed launch order, so the defect was one line of documentation away
from being invisible.
spawn() takes the id synchronously and emits the spawn event only after
await createSession(), because the event carries the model pi resolved. Rows
therefore land in the order sessions came up, which is a property of the
provider and not of the run.
The fix is a number on the event: nextSubagentId hands out the id and the
launch order together, since they are one fact - the moment the subagent was
asked for - and a second counter kept elsewhere would drift the day one of the
two calls moved. The collector sorts on it and keeps it to itself: where a row
is drawn is the display’s business, and SubagentSnapshot stays about the
subagent.
Two alternatives were worse. Sorting by id breaks a chain, where
planner#1, coder#1, reviewer#1 is chronological and alphabetical order is
nonsense. Emitting the event before the session exists means emitting it without
the model, and the model on the spawn event is what makes a run say on its face
what it ran on.
What a delegated run costs is a tree¶
A delegation with no tree-shaped measurement is a cost discovered on the invoice: two children and their six grandchildren read as eight peers, and the agent that caused the bill reads as the cheapest row in it.
The link is the parent’s id, on the spawn event. Not a name, which two
explorers running at once already share. Not a lookup into the core either -
invariant 4 stands, a reporter observes and never queries, the same reason
openInHerdr travels on the event.
The id is minted by spawn, so a tool that will spawn children cannot be
built before the subagent that holds it exists. Hence SpawnOptions.customTools
accepting a function of the id to come, next to the list it already took. The
alternatives were worse: generating the id outside spawn scatters the one
place a subagent is named, and a mutable box filled in after the fact is a
lifetime bug waiting for a second caller. The list form stays because most tools
a verdict, a check - have no use for an id, and paying for delegation everywhere would be the tax invariant 11 exists to refuse.
The measurement stays a flat list carrying a link, rather than nesting.
usage.json keeps subagents flat with a parentId on each row, ordered so a
child follows its parent; summaryTable indents. Nesting the document would
force every reader to walk a tree to sum it, for a total that is a sum over
every row either way. The tree is one pass away for whoever wants one -
treeOrder is that pass - and no reader written against the flat shape breaks.
Two properties are pinned by tests because losing either would be silent: nothing is ever dropped - a subagent whose parent is not in the list reads as a root, and even a pair pointing at each other is still reported - and the total is the whole tree, failed children included.
A subagent may name skills¶
An agent’s skills: is an allowlist, read exactly like tools:. What it names
is resolved before pi is opened and handed to the resource loader; what it does
not name is absent, whatever the machine holds.
This is the third exception to invariant 5, and the widest one: a name
resolves against the repository’s .pi/skills/ and the user’s
~/.pi/agent/skills/, so two machines can disagree about what a name means.
That was the choice, and it is a trade rather than an oversight. The reason to
take it: a skill is the unit people already have - they write them, share them,
and hold directories full of them - and a library that refused to look there
would be answered by pasting a skill’s text into a prompt, which is the same
dependency with none of the versioning. The reason it is survivable: the name
is still written in the definition, so what an agent can do is still readable in
its file, and agents/<name>/skills/ is searched first, so anything that must
be reproducible ships beside its definition and wins the name.
Three ways a declared skill could have been dropped in silence, and none of them is:
A name matching nothing throws at spawn, naming the three directories. Prose that quietly lacks a step is the expensive failure, not a stopped run.
A skill needs
read: pi omits the whole section from a system prompt whose toolset cannot open a file. Handing the tool over instead was rejected - widening an allowlist to satisfy another field is exactly the silent grant invariant 7 exists against.disable-model-invocationis refused rather than honoured. pi keeps such a skill out of the prompt entirely, and combo has no/skill:for the model to reach it with, so accepting one would offer a capability that cannot arrive.
Nothing is loaded eagerly. pi advertises a name, a description and a path; the
model opens SKILL.md itself with read. A skill costs a line of prompt until
it is used.
Pipelines: a workflow written down¶
Removed: the linear format, its parser, its runner and the shipped
pipelines/ are gone, and a file left in an old pipelines/ directory is
refused with pipeline-format-removed. See The linear pipeline is removed. What follows is kept for
its reasons, several of which the flow format took over: a broken file is
refused, everything is resolved before the first spawn, what a turn is handed
arrives under headings of its own, and an agent does not write the file.
src/pipeline/pipeline.ts parsed one, src/pipeline/load.ts found it, and
src/pipeline/run.ts walked it. /build ran one.
Reversed: a branch no longer makes a run a TypeScript workflow. A pipeline was linear on purpose, and “the moment a run needs a branch, it is a TypeScript workflow” was the rule. The flow format replaces it with a closed language that has branches and loops; see Flows: a closed language. The linear format keeps its rules for as long as it ships.
/buildhas no built-in behaviour any more, it has a default file.DEFAULT_BUILD_PIPELINEis a pipeline like any other, parsed by the same parser and run by the same runner. Had the command kept a hard-coded path “for the simple case”, that path and the pipeline path would have drifted within two changes, and the file would have become the untested one.Only the middle is a pipeline (the two stops have since gone, see
/buildasks nothing). The interview and the commit stay stops of the command: a question card owns the terminal, and “the agent writes the message, our code makes the commit” is a boundary a file must not be able to move. A pipeline describes work, not acts on the world - which is also whyrunPipelinetakes aVerifyport and never builds one from theverifyfield itself. Naming a command and running it are two decisions, and the second belongs to whoever owns the working tree.An agent does not write pipelines. It was considered and dropped: what a generated pipeline buys is a reviewable artefact, and
orchestratealready gives that with a parser and a cap. A second, larger place where a model decides the shape of a run is more surface for the same benefit. A user writes the file; the file is data; the run is ours.A broken
build.mdis refused, never silently replaced by the default. The whole point offindPipelinereporting a parse error is that a file sitting right there and quietly not being used is the failure nobody detects.Everything is resolved before the interview. Parsing, shape checks and every agent name, so a typo costs a second rather than a conversation and three steps of real work. That is the same reasoning as
orchestratevalidating a plan before spawning, one level up.Resuming is keyed by step id.
BuildState.stepis optional: a state written before pipelines existed has none, and a single-delivery pipeline has nothing to disambiguate. It earns its place the day a pipeline delivers twice, where handing the second delivery the first one’s approved subtasks would resume the wrong work.A
loopthat never converges fails its pipeline. Passing unconverged work to the next step is exactly the silent successconvergedexists to expose; in code the caller reads the flag, and in a file there is nobody to read it.reducefolds the step before it. That is what the linear rule buys: no templating, no${{ steps.x.output }}, and the one N-to-1 case that matters still works. Areducewith nothing to fold fails without spawning. It is handed those branches once:reduceformats them itself fromresults, so passing the previous output as text too printed every report twice - and the synthesiser duly reported “duplicate reports, verbatim duplicates”.The request reaches every step, not only the first. A step that sees only the previous output cannot tell what the run was for. Found by a real run of
explore: the synthesiser answered “there is no question asked in the prompt”, because there was not - the user’s question had been overwritten by the fan-out’s output. The same bug silently starved the shippedbuildpipeline, whose delivery step saw the scout’s report and never the brief. The dataflow is therefore two named sections,## Requestand## Output of step <id>, and not one anonymous blob: a model asked to answer a question it cannot distinguish from the evidence answers about the evidence.A subagent is told where it is. One line appended to its system prompt by
situate(). It sits oddly beside “a subagent inherits nothing from the user’s environment”, and it is not the same thing: the working directory is not inherited context, it is the ground every tool call stands on. A scout that was not told calledls /Users/loic/gouarin/…- a name with a dot turned into a slash - got “no such path”, and gave up without trying a relative one. One branch of three, spent on a fabricated path.Reversed:
/runand/buildare one command./run buildstarts the shipped build, since the interview and the commit are nodes of a flow now; see/runruns flows, and/buildis/run build. The reason below is why there were two./runexists because/builddelivers a change. An interview settles what “done” means and a commit stop protects history; a pipeline that only reads needs neither, and putting one through/buildmeans being interviewed about a request that wants no decision and then told there is nothing to commit./runis the pipeline and its answer, nothing around it - and it is lighter, not safer: what a step writes is still written, because what an agent may do is its toolset, never the command that started it.A finished
/runleaves its answer in the conversation, not in the prompt editor. It still does, for every flow, and says how the run ended under it; see/runruns flows. The editor is right for/interview- a brief is read, edited and sent - and wrong for an exploration, which is read and then asked about: putting it where the user types means they have to send their own report back before the model knows anything about it. It is a custom message and not an assistant one because pi has no door for the latter:sendMessage(custom, in context),sendUserMessage(a user message, always triggers a turn) andappendEntry(drawn, invisible to the model) are all there is, andconvertToLlmturns a custom message into the user role. Hence the framing line naming the pipeline: unattributed findings in a user slot read as an instruction./agentsanswers the same question/pipelinesdoes, for the roster, and it is grouped by source rather than sorted by name because the question behind it is nearly always a scope: the agent is there, and the call that could not find it was loading somewhere else. A source that contributed nothing still names the directory it read, since “where do I put mine” is the other half of the same question. Its first run against this repository foundexplorershipped with no symlink into.pi/agents/, which a test now pins.Replaced:
/flowslists what/pipelineslisted, for flows, and names what is left in an oldpipelines/directory as refused; see The package exports the flow API, and/flowsreplaces/pipelines. The reason below is why/flowsexists too./pipelineswas there because the error message was not enough. A pipeline of one repository is invisible from another - by design - and the failure reported where pipelines live without saying what had been loaded, with no way to ask. Found by running it in a scratch directory, not by reading the code. The listing shows the broken files beside the good ones: a file that does not parse is the most likely reason anyone is looking.The extension does bring its own agents and pipelines, at the lowest priority. This reverses the rule written one commit earlier (“an extension never brings its own”), and the reversal is the honest one: 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 the old rule was really protecting is not silently losing a name, and precedence protects that directly: shipped, then the user’s, then the repository’s, so a
scout.mdof your own replaces ours without removing anything.builtinis off by default in the library: a script asking for the user’s agents must not be handed ours as well. Found the hard way -/build --pipeline explorein a scratch directory found nothing at all, because the definitions only existed in this repository.One default, and it is a file.
DEFAULT_BUILD_PIPELINElived exactly as long as it took to shippipelines/build.md: a default written in TypeScript and a default written in Markdown would have differed within two changes, which is the drift the constant was introduced to prevent in the first place. The shipped file names noverify, deliberately - imposingnpm teston a project that has none is worse than asking, and/buildasks when the pipeline is silent. It no longer asks:--checknames the command instead, see/buildasks nothing.Replaced: a flow run paints its plan, through the same
liveRun; see/runruns flows./buildand/runpaint the same run the same way, through oneliveRuninextension/ui/run.ts: two call sites, two timers and two ways of clearing a widget is exactly how the one nobody is watching that day drifts.
Flows: a closed language¶
A flow is a task graph written in YAML + Markdown: branches, parallel forks,
bounded loops, human nodes, sub-flows. It is built in src/flow/ beside the
linear pipeline, and the package exports it from the switch on, see The
package exports the flow API, and /flows replaces
/pipelines.
The line was drawn in the wrong place. What protected a run was never “no branch”, it was “our runner decides what runs next”. The linear format kept that guarantee only by hiding the real shape of
build(a loop, a coder/reviewer pair, a check, an audit) insidedeliver, a combinator the file called without showing. A closed language keeps the guarantee and puts the shape in the file. What follows from it: validation, a rendering and a dry run exist only for what is written as data.Closed, not general. Every construct comes from a closed set, every loop and every
maphas a bound written in the file, and every condition terminates and gives the same answer for the same values, so the worst case is known before the first spawn. A new key has to pass the same test.An agent still never writes one. Nothing in the format stops it, which is why the rule stays in the invariant rather than in the parser.
A condition is CEL, cut down¶
src/flow/condition/ parses, type-checks and evaluates the expressions a
choice case and a loop read.
A subset of CEL syntax, parsed by us. CEL is the one condition language whose specification guarantees termination and determinism, and the project takes no dependency without discussion. Every expression the module accepts is valid CEL, so a real CEL library could replace it without breaking a file. The subset only removes productions from CEL’s grammar, it never adds one, so precedence is CEL’s:
!a == bis(!a) == b.What it leaves out, and why. No string functions: a node that decides declares an enum, and nothing is read out of prose. No arithmetic: a count is what
maxis for. No ternary: branching is whatchoiceis for. No clock and no randomness. What is left is literals, addresses, comparisons,&& || !,inon a list,size(),has(), and theall/existsmacros.Checked whole before the first spawn, and enums are strict. Every address exists and is typed, the result is a boolean, and compared sides agree. A string literal compared with an enum must be one of its values:
status == "aproved"would otherwise befalseon every visit and spin its loop to the cap. Problems are collected rather than thrown at the first, and a part already refused is read no further, so one typo is one problem.A condition that cannot be evaluated fails, never reads as
false. A node that failed has nooutput, an optional field can be absent, andpreviousis empty on a first iteration; each of these would otherwise read as “not yet”.&&,||,allandexistsfollow CEL’s rule for errors, which goes further than short-circuit: the side that decides wins over a side that errs, whichever comes first, soaudit.output.approved && audit.okisfalsefor a failed audit just asaudit.ok && audit.output.approvedis. A module that only short-circuited would disagree with the CEL library meant to be able to replace it.A name read by a condition has no dash. CEL reads
ask-next.outputasask - next.output, so a node id a condition reads is letters, digits and_, and the dash is refused where it is written, with the name to use.
A schema is written short, or as a JSON Schema subset¶
src/flow/schema.ts reads what a node declares after output: (and a flow
after input:) into the one type model conditions are checked against.
The short notation is YAML read as a type.
{ subtasks: [{ text: string }] }is already a mapping holding a list holding a mapping once YAML has parsed it, so the notation needs no parser of its own: a string is a type name or an enum (scout | reviewer), a one-element list is a list, a mapping is an object whose?-suffixed keys are optional.Questionis the one named type, the shape anaskcard draws.The long notation is for descriptions.
{ json-schema: ... }takestype,properties,required,items,enumanddescription, and refuses every other keyword by name. A model filling a value reads the descriptions; nothing else the short form lacks has been needed. Both land in the same model, so whatever reads a type never asks which notation wrote it.Nothing reads as “any”. An unknown type name, an empty object, a list of two schemas and a keyword outside the six are refused with the path inside the schema, all at once. A value whose shape a flow cannot name is text, and only an agent reads text.
A field name is a CEL identifier. An address splits on dots and CEL reads a dash as subtraction, so
next-stepwould be a field no condition could read. It is refused where the schema declares it.
A flow is refused whole, and each mistake once¶
src/flow/file.ts reads a flow’s text into nodes and src/flow/check.ts
resolves its names against a catalogue; checkFlow is the one door, and a
CheckedFlow is the one thing the runner will take.
A fault is data with a stable code.
{ code, file, at, message }, never an exception, so every fault of a file comes back at once and a caller formats them. Tests assert codes, not wording, anddocs/guide/flows.mdexplains each code in a table that a test holds toFAULT_CODES. There are no warnings: what deserves flagging deserves refusing.One mistake is one fault. A node that is refused is left out of its sequence and its id remembered, so a read of it, a section for it and a condition on it say nothing more. A broken
input:is treated the same way. Faults come back in file order (the flow’s keys, then each node with its own section, then the body), whatever order the checks ran in.A key valid elsewhere is still unknown here.
max:on anagentnode is refused with the keys the kind has; a typo is offered the nearest key within two edits.An id is a CEL identifier, everywhere. Letters, digits and
_, and neither an address word nor a word CEL reserves. A node id with a dash would be readable byreads:and not by a condition, which is two rules for one name; one rule means no flow ever holds a node its conditions cannot name. The message offersask_nextforask-next.A flow is found by its file name, and its
name:must agree. A file that does not parse still has a name then, so it is reported as broken rather than as unknown; an author asking forbuildand told “unknown flow” whilebuild.mdsits right there is the failure this prevents.An agent’s text is its own type.
textis what an agent with nooutput:wrote:reads:hands it on whole, and a condition refuses it. The alternative, typing itstring, would letplan.output == "done"read a decision out of prose.An address is typed by the condition checker.
plan.output.firstis a CEL field selection, soreads:,agent-from:and a condition share one parser and one checker and cannot disagree on what an address names.A
##inside a fenced code block is prose. A section may show the Markdown it asks for, and the linear format’s reader would have cut it there.
A block hands on what its branches ended with¶
src/flow/blocks.ts reads choice, parallel and map, and
src/flow/check-blocks.ts checks them in lexical scope (src/flow/scope.ts).
A block’s nested nodes are readable inside it and gone after it. What leaves a block is its own output, so a node never reads into a sibling block and nothing can be addressed that only a run could resolve. Inside a
map,itemis the current item, and a nestedmap’sitemhides the outer one.A
choicenames the case that ran by its position.caseis"1","2"… or"default", an enum a condition can compare strictly. Cases have no names of their own: adding one would be a second id for the same thing.outputis typed only when every case that runs a node ends on the same type, since which one ran is only known at run time, and it is optional when a case runs nothing.A
parallelhas at least two branches, and a case runs a node. Aparallelof one is a sequence, and a case with nothing to run is whatdefault: []is for.A literal
map:list is strings. That is what the shipped flows need; anything typed comes from an address, where a schema declares it.The copies rule reads the agents’ files. Branches that run together (a
parallel, or amapwithconcurrencyabove 1) needcopies: trueas soon as one of them holds an agent withwrite,edit,bashorsubagent. It can refuse a flow whose agent would never actually write, and that is the side to err on: the other side is two branches editing one tree.A refused node refuses itself, not the block around it. The block is still checked, but its output cannot be typed with a node missing, so nothing that reads the block is reported again, and neither are the nodes read under the refused one.
A loop reads its body one iteration back¶
src/flow/loop.ts reads a loop, and src/flow/check-loop.ts checks it.
untilandmaxare both required. A loop with no condition would repeat a body a fixed number of times, which no flow needs; one with no cap is an unbounded cycle, which a flow cannot write.The body is checked twice, the first time in silence. Inside it,
<loop>.previous.<node>is a body node one iteration back, so the body’s output types are needed before the body is checked. A node’s output type never depends on what it reads, so the silent pass cannot disagree with the one that reports.A carry’s type is what both of its sides share.
firstis read before the loop andnextafter each iteration; only the fields both have, with the same name and type, are readable, sobuildcarries the plan’stextand then the ledger’s, and reading the ledger’sidis refused. Sides with nothing in common are refused once, ascarry-mismatch.A ledger is named after the node that opens it.
ledger: deliverondeliver, and averdict: deliverinside it writes there. Amapmay keep one too, one per item. Averdict:node’s output is the verdict,{ approved, remarks? }, so anoutput:beside it is refused.
A flow’s catalogue keeps the agent files that are not agents¶
src/flow/catalogue.ts reads flows and agents from the package, the user’s
directory and the repository, least specific first, as agents and pipelines
are. src/flow/agents.ts resolves the names a flow gives.
A broken agent file is kept, with its cause.
loadAgentsstill drops it in silence, which is pi’s behaviour and what a TypeScript caller has always had. A flow is checked before it runs, and “unknown agent” about a file that is right there sends its author looking in the wrong place.A broken file wins its name like a valid one. A repository’s
scout.mdthat stopped parsing shadows the user’sscout, and the flow is refused with the path. Falling back to the user’s would run an agent the repository did not mean, and nobody would be told.A broken file is named by its
name:when it has one. Agents are asked for byname:, not by file name, soterse.mdholdingname: scoutand nodescription:is thescouta flow meant. Only a file whose YAML does not parse falls back to its file name.Everything spawn refuses about skills, the flow stage refuses first. That is
skills:withoutread, a skill found nowhere, and a skill set todisable-model-invocation, the last one included because a checked flow should never throw at spawn.findSkillsinsrc/skills.tsis the one lookup: spawn throws its first problem, and a flow reports all of them. The throws stay for TypeScript workflows, which have no validation before they run.The catalogue carries its
cwd. A skill resolves from the repository a run starts in, so the catalogue is loaded for one directory and says which.
The runner composes every turn, and reads a typed value from a tool call¶
src/flow/run/ walks a checked flow: runFlow, and dryRunFlow on scripted
sessions. It walks every kind of node. It first threw before the first spawn
on copies: true, until the git port came (see below).
submitis added to the agent’stools:for a typed node.tools:is an allowlist that covers the tools combo offers, so without it the tool built fromoutput:would never be enabled. It is granted by the flow file, which asked for a typed value, and it hands a value back without acting on anything; an untyped node’s agent gets exactly its own tools.A call off the schema is refused back to the model. The turn goes on, the model can call again, and the last call accepted is the answer; the node fails
schemaonly when the turn ends with none. Theverdicttool already works this way, and a turn thrown away over a fixable call costs a whole retry. A value that is not an object travels as{ value }, since a tool’s parameters are an object.Nodes sharing a subagent declare one
output:. A subagent’s tools are fixed when it is spawned, so two typed nodes resuming one subagent throughmemory:with different schemas cannot both have theirsubmit. The flow stage refuses it asmemory-output-mismatch. A text node beside a typed one is fine.Our code classifies a failure, not a message. Each attempt gets a deadline signal of its own, and a failed attempt is
stoppedwhen the run’s signal or the subagent’s stop fired,timeoutwhen its deadline did, andproviderotherwise. The dry run gives each attempt a deadline its scripted session fires itself, so a scripted timeout goes through the same abort with no clock to wait on.A retry on the same subagent is asked the failure, not the turn again. The turn is already in its history; it is told what failed, and a typed node is reminded to call
submit. A fresh subagent, after a timeout with nomemory:, is asked the whole turn.A read typed
stringgoes as it is.input: stringis what a person typed, and a JSON string with its quotes and escaped newlines would change how a model reads it. Text goes as it is, every other value as JSON. An optional field left out keeps its heading, as an empty text does.agent-from:that cannot be read fails withcondition. A value that picks an agent is read like an address in a condition, and a condition that meets a failed node or an absent field fails with that kind.The runner does not write the language line.
Subagent.askcloses every turn with it already, so a flow’s turn gets it last like any other.A visit event carries no subagent id. A visit is a node’s, and a
choicehas no subagent, sopicture, herdr and the mirror skip them (isVisit).visit_endcarries the agent a visit ran and its model, which the journal will record, soSubagentnow exposes the model pi resolved.The input and the kinds are checked before the first spawn, by a throw. An input off
input:is the caller’s mistake; the run stage will refuse it as a fault once it exists.A dry run’s script is keyed by node address.
gate/look, as a fault’satnames it, never a bare id; the refusal offers the address when a key is an id. An array is always a list of answers, since an answer can itself be a list, and one value answers every attempt. A script’s faults are a closed set of their own,ANSWER_CODES, apart from the flow’s.
Blocks join what their branches ended with, and a ledger is a scope’s¶
parallel, map and loop run in src/flow/run/blocks.ts and loop.ts,
and the ledgers and verdict: nodes go through the review record.
A block names the first branch in order that failed of its own. Branches finish in any order, and the failure a flow reports must not depend on it. A
cancelledbranch is named only when every failure was one, which happens when the cut came from further out.on-fail: continueon aparallelormapends itok: true. Its output keeps each failed branch as{ ok: false, error }, the way a loop under the same key endsok: true, converged: false. Without it, the output of a failed block could never be read, and “failed branches are kept” would say nothing.A
parallelbranch opens a memory scope of its own, like amapitem. A subagent takes one turn at a time, so two branches running at once cannot share one. Branches running together that resume an outer scope’s subagent take turns on it instead of failing: its history is still one conversation.A
fail-fastcut is a signal of the block’s, carrying its reason. Our code readscancelledoff that signal, likestoppedoff the run’s, rather than off a message. A branch not started runs no visit: its entry iscancelledat the path of its first node.copies: truewas refused at run time until thegitport existed. A runner that ran the branches in one tree instead would have made thecopies-neededfault a lie. The refusal went with the commit node, below.A
verdict:node is a review record handed the scope’s ledger.reviewRecordtakes aledgerread at every use, so the three rules and the terms a reviewer is asked on stay written once. It is read at every use because a subagent amemory:scope keeps can outlive the ledger it first wrote to. The runner addsverdictto the agent’stools:, as it addssubmit;approvedis the record’s own (it said yes, and nothing is left open); and a turn with no call failsschema, like a typed node’s.<scope>.ledgeris read when the address is. A verdict earlier in the same iteration changes the open obligations, and a snapshot taken when the scope opened would hand the next node a stale list.A condition, a carry or a
map-fromthat cannot be read fails withcondition. It is the kind an address that meets a failed node or an absent field already has.A dry-run key is walked against the tree. An exact visit path numbers every loop iteration and map item, names every branch, and stays within each bound. A list is refused as
answer-past-maxwhen it holds more answers than the node can be asked for in its enclosing path:1 + retrytimes themaxof every loop around it. Each attempt tells the script its node’s address, so a path is never parsed back into one.A subagent’s
spawnevent carries itsvisit. That is the one link the live view needs between a plan line and the subagents that ran it. A scope’s subagent names the first visit that asked for it.
A check runs what was read before the run, and the run stage reads it¶
A check node names a script of the project; checkRun is the run stage,
and runFlow takes the CheckedRun it returns in place of a flow and a
cwd option.
The
checkport sits besideVerifyinsrc/verify.ts.CheckScripttakes the script’s content, a directory and a bound;Verifytakes nothing, and the linear pipeline still calls it. Both are the project checking itself, so they share a file, andVerifygoes when the pipeline does.The content runs as
bash -c <content> <path>.checkRunreads each script once, and theCheckedRunholds what it read, so an agent editing the file mid-run changes nothing and no file is read twice.$0is the path, so a script that finds its siblings bydirname "$0"still does. Its stdin is closed: nobody can type into a check, and one that waits for input ends.The script leads a process group, killed whenever it ends. A timeout or a stop that killed only
bashwould leave a test runner’s workers holding the pipes, and the check would not end until they did; killing the group at a normal exit too clears what a script left in the background.The report is a rolling window over both streams, in arrival order. The output of a check is not bounded, and 8000 bytes of it is all that is kept, so no more than twice that is ever held.
tailtakes how much was already let go, and its mark counts the whole.A check’s bound is its own. The run’s
timeoutMsand the flow’stimeout:bound agent turns: a slow provider, which the operator sees and the author cannot. How long a project’s suite takes is the project’s, and a--timeoutraised for a slow model should not let a hung suite hang the run.retry:on a check is refused with its reason, not as an unknown key.retry-refusedsays to raisetimeout:or make the check stable; a plain unknown key would offer the kind’s keys to someone who meant something.RETRY_REFUSEDholds one reason per kind, socommitandflowadd theirs.The path is checked twice. An absolute path, or one leaving the repository, is a flow-stage
key-type, since no project can make it right. Whether the file is there is the run stage’s, against thecwdit is given, which the launch sets to the repository root.A missing port or script is one fault. Two nodes naming one missing script, or a flow of checks launched with no
checkport, is one mistake, reported at the first node that needs it.The port says
stopped, the runner reads the signals.CheckScriptknows nothing offail-fast, so a stop and a cut are read off the run’s and the block’s signals, as for an agent visit.A dry run answers a check by the same keys as an agent turn. An answer is
{ passed, report }or{ fail: "unavailable" | "timeout" }, checked before the start, and a check no answer covers isunscripted. It takes aCheckedFlow, so it reads no script.checkRuntakessomebodyTherealready. The launch states it once, and theCheckedRunholds it, so theasknode reads it rather than changing every caller ofcheckRun.
A commit is our act, and copies start from the tree as it stands¶
The commit node, the diff address and copies: true reach git through one
port, gitPort() in src/git/port.ts, built on the rest of src/git/. The
runner never runs git itself.
checkRunis async. Whethercwdis in a repository is a question for git, so the run stage asks the port it was handed rather than guessing from a.giton disk, and that is a process.The port holds no state; the run’s branch lives in the run. A port can serve several runs, and a branch belongs to one. The first commit opens
combo/<slug of input>(a typed input is slugged from its JSON), suffixed while the name is taken, and every later commit first checksHEADis still on it: committing onto a branch somebody switched to is the one thing a branch of its own is there to prevent. It opens even when the tree is clean, sobranchalways names the run’s.A commit’s message is an earlier node’s output. A text, or a
stringfield of a typed one.input,itemanddiffare refused where they are written: a message is what an agent node wrote, and anaskcan sit between the two. An empty or absent message failsempty-messagebefore git is asked anything, so nothing is opened for a commit that cannot happen.diffgoes through an index of its own. A copy of the repository’s index in a temporary file, with every change added, so untracked files show and the person’sgit statusdoes not change;git add -Non the real index would have marked each one as added. It is a text, so no condition reads it, and a node that cannot have it failsunavailable.A copy starts from a snapshot of the tree, not from
HEAD. A commit of the tree as it stands, which no ref points to. FromHEAD, the second iteration of a loop around acopies: trueblock would not see what the first landed, and its patches would conflict with it on the way back.Each branch’s entry says whether its patch landed.
landed, andrefusedwith git’s reason on the patch that stopped the landing; the branches after it readlanded: false. A patch that could not be taken stops the landing like one that does not apply, and a branch that changed nothing counts as landed. It is a value, like a red check: thecheckafter the block, or a condition, decides what to do about it.A stopped run lands nothing. Its copies are still released, which commits each one’s work on the copy’s own branch before removing it, so what a branch wrote is kept and nothing half-finished reaches the run’s tree.
A commit writes, and copies are no way out for it. Beside branches running at once,
copies-neededoffers to commit after the block (orconcurrency: 1on amap), since a commit inside a copy is refused ascommit-in-copies.A memory scope used inside a copy opens inside it. A subagent works in the tree it was spawned in, so one kept by
flow, or by a block around the copies, would work in a tree that is not its branch’s, and outlive the copy it was spawned in.memory-outside-copiesrefuses it.A dry run makes no copy and runs no git. A commit is answered by the script,
{ committed, sha?, branch }or{ fail: "unavailable" }; an empty message comes from the node that writes it.diffreads as an empty text, and every branch of a copies block reads as landed.
An ask says what not answering gives, and a model’s labels compare to nothing¶
The ask node puts a question through the ask port, the same AskUser the
interview uses. Nothing of the extension is wired to it yet.
The port grows an optional second argument, not a second port.
Askingsays the card’s form, the reads shown above the question, the visit asking, the label of “enough” orfalse, and a signal that takes the card down. An existingAskUserignores it and keeps working.undefinedstays “the person declined”: “that’s enough” where it is offered, the stop key where it is not.Declining a card with no “enough” stops the whole run. The card owns
escwhile it is up, so that key press is the run’s stop, and it ends the runstoppedthe waystopSwitch.all()does.on-fail: continuedoes not catch it. Ending only the node would turn the stop key into a way of failing one question.Literal options make a closed card.
answeris then a strict enum of the labels, so the card offers no typed answer beside them: a typed one would be a value off its own type.customstays in the output for one shape across choice cards, and isfalsethere.ask-from:is a choice card. AQuestioncarries its options, sooptions:andconfirm:beside it are refused, andenough:is refused on a yes or no and on a free text, which are always answered.A model’s labels are marked on the type, not on the node. The answer of an
ask-from:is astringwithfree: true, and the condition checker refuses a literal on the other side of==,!=,inand the ordered comparisons, ascondition-free-string. One flag on the existing type model travels through every address and field the checker already follows. A free text’s answer is a plainstring, as the format says: a person typed it.default:is a literal of the node’s form. A label of its options on a literal card, any text afterask-from:(itscustomis worked out at run time against the question),trueorfalseon a yes or no, and a text on a free text.A
timeout:starts when the card is shown. A card waiting its turn in the queue is not a person failing to answer, as an agent turn waiting for a shared subagent is not a slow one.No
askport is nobody there, whatever the launch says. The run stage refuses the flow, asunattended-ask, when anaskwith neitherdefault:norenough:could be reached. At run time, nobody there takes the same rule as a timeout.An
askmay readdiff. “Commit these changes?” wants the changes above it, so the read goes through the code an agent’s does and needs thegitport the same way. What a turn shows and what a card shows are one function,showRead, and differ only in the fence around JSON.A dry run answers an ask with its output.
{ fail: "nobody" }and{ fail: "timeout" }take the node’s own path, itsdefault:orenough:included, and{ fail: "stopped" }is the card declined. Each is accepted only where the node’s keys allow it, and a choice card’s answer is held to what a card can give:answered: falseonly withenough:, ananswerwhenever it is answered.
A call is checked once, and only input crosses it¶
A flow node runs another flow of the catalogue as one node. Nothing of the
extension is wired to it yet.
A callee is checked on its own, once per check. Its faults stay in its file, and the caller gets one
broken-flownaming the callee’s file and its first fault./flowslists the callee with its own faults, so copying them into every caller would report each one twice, at addresses of another file. A callee called from two nodes is one checked flow, attached to both.A cycle is found from the files, before the callee is checked. The call graph is read from what the files write, behind a
choicetoo, and a call that leads back to the flow being checked is refused ascall-cycle, with the path from that flow. The callee is not checked, so the check always ends and no depth constant is needed. A cycle that does not pass through the flow being checked makes the callee broken, which is what the caller is told.Only
inputgoes in, and only the last root node comes out. A callee is checked in a fresh root scope and run in a freshflowmemory scope, opened by the visit of the call and closed in afinallywhen it ends. A callee takingstringtakes any value: a string as it is, anything else as JSON, so an enum’s value goes in as the word it is. A typed input takes the same type and nothing else.input: diffreads the visit’s tree, and needs thegitport like any read ofdiff.A callee’s run is the run’s own, one call further down. It keeps the run’s ports, signal, stop and event stream, so there is one queue of cards and one run branch. What changes is the stack of flows
model:andtimeout:are read from, callee first, and the memory scopes it shares.The world is told a node by its address through the calls. A
visit_start’snode, an attempt’s deadline and a dry run’s keys usespec/round/look, not the callee’s ownround/look. A flow called from two places has two addresses, and whatever keys a node by its address has to tell them apart. The rules about copies, commits, checks and unattended questions read the same addresses, so their faults land in the caller’s file at the call path.A dry run’s key on a call and a key under it are refused together, as
answer-flow-overlap, even when they would name different visits. Telling visits apart would need the loop iterations resolved against each other, and a script that does both for one address is almost always a slip.
A run keeps what its check read, and writes each fact once¶
A run given a run directory keeps a snapshot there and appends a journal, which a resume reads back (below); nothing of the extension uses them yet.
The journal is a port, written by the runner. A real run appends to
journal.jsonlin its run directory, a run given none writes nowhere, and a dry run keeps the entries in an array. A reporter never writes it, so unplugging every reporter leaves it the same. Each append is synchronous: the fact is on disk before the run goes on, and two branches never interleave a line.A visit’s entry is its
visit_end, written before it is told. One object goes to the journal, then to the event stream, so a reader folding the two never sees an event the journal lacks.Only the last line can be torn. Whatever follows the last newline is ignored. Any other line that is not an entry throws: that file was not written by a run, and guessing past it would resume a different run.
A ledger writes its own obligations down. The ledger a loop or a
mapitem opens is wrapped to append each raise and each close it accepted, keyed by the visit whose scope keeps it (fix,work[2]). The review record and theverdicttool are unchanged, and a refused close writes nothing.Every fact is keyed by the visit it belongs to. A
carrynames the iteration that reads it (fix#2). Amap’s list is written for a literal list too, so everymapis restored the same way. A copy is written when it is made, with its directory and git branch, and its landing once the block landed; a dry run writes both with no directory. The run’s end is whatrunFlowreturned, written down.What validation read is on the checked flow.
sourcesholds the flow file, every file it reaches, and each agent it names with the skills they resolved to. Each checked flow composes its own from its callees’, so a callee’s is right on its own too.checkRunalready held each check script’s content. The snapshot copies these two and nothing else.An agent is kept as it was parsed, a skill as its files. Reading an agent’s file again for the snapshot could see a file changed since the check, so the parsed definition is stored. Validation holds only a skill’s metadata, so its directory is copied, since the model may open what sits beside
SKILL.md.Read back, an agent lives in the run directory. Its
filePathbecomes<runDir>/agents/<name>.md, so its ownskills/directory, looked up first, is the copy. This holds for the check and for the spawn, and it is the one difference between a check of the snapshot and the first check. A flow file keeps its own path, which a resume needs to say what changed on disk.A run directory holds one run.
runFlowrefuses a directory that already holds a snapshot, and checks the input before writing anything, so a refused input leaves no directory behind. The settings kept are what the run started with:cwd,somebodyThere,model,timeoutMs.
A resume goes as deep as the journal, and replays only what did not end¶
resumeFlow(runDir, ...) carries a run on from its snapshot and journal, and
resumePoint(checked, journal) is the reading both it and a dry run’s from:
take, so a resume is tested offline through the same door.
A visit that ended with a value survives; nothing else does.
ok, or failed underon-fail: continue: the flow read either as a value and went on. A visit stopped or cut byfail-fasthad no end of its own, and one a failure travelled up through ended by the run’s failure, so both run again. That one rule is the failure chain, and its freshretry:budget.Structural visits are walked again, their children skipped. The walk itself decides, visit by visit: a surviving visit hands back what it ended with and is neither told nor written again. A loop, a
map, achoiceor a call that did not end is entered again, and what it reads comes back from the survivors, so a condition decides the same. Acarryand amap’s list are taken from the journal rather than read again, and not written twice.resumePoint’sfromis only a sentence for whoever asked.A failure the flow decided is refused. A cap,
give-up, a condition that could not be read, a list pastmax:and a commit with no message are decided from values that survive, so replaying them decides the same. A provider, a timeout, a schema, nobody answering and a person’s stop are worth another attempt.An obligation is written with the visit that decided it. A verdict visit cut after its decision but before its end runs again, and its obligations would be raised twice.
obligation_raisedandobligation_closedname theirvisit, and a ledger is restored from the entries whose visit survives.A copy is kept with its base, and taken back only while its work is not in the tree.
copy_openedrecords the commit the copy started from, so a resume can take its patch against the same one. An open copy still there carries on; a copy gone or moved, or one whose patch never landed, is forgotten withcopy_lost, written in the journal so a later resume forgets the same; a copy that landed put its work in the tree, so its branch’s visits survive and what is left runs in a fresh copy. A stop releases every copy and lands none, so after Ctrl+C acopies: trueblock starts its branches over: their work is on their git branches, not lost.Only
modelandinputare refused by name. They are what a new run is for.timeoutMsreplaces the run level.portsandsomebodyThereare where the resume runs, like the tree at a start, and the run stage holds the snapshot to them: somebody at the keyboard may resume a run started unattended.The disk is compared for flow files only. A flow file differing from the snapshot is named in
changed. An agent is kept parsed and a script by its content, and the run uses those; the one line is about the file a person edits and would expect to see run.The result’s
usageis what this resume spent. A visit that survives costs nothing now, and adding what the journal says it cost would count a cut visit’s partial cost nowhere and the rest twice across resumes. The journal holds every visit’s own, for whoever sums them.The lock is a file made with
wx. It holds the pid and the host. A process of this host is asked with signal 0, and one we may not signal is alive.runFlowtakes it too, after the snapshot, which is what refuses a directory holding a run.A stale lock is replaced under
lock.json.takeover. Removing it and making it again let two resumes that both read it as stale both take it: the second removed the lock the first had just made. The takeover file is made withwxtoo, so one taker at a time gets through. That taker reads the lock again, since another may have taken it over in between, and replaces it with a rename, so no plainwxfinds it missing. A taker that finds the takeover file refuses, with the pid in it when that process lives, or its path when it died there. The rename is atomic on a local filesystem, and a run directory is expected to be on one.takeLocktakes the read as a parameter, which is how the test puts a second taker between two steps of the first.
Bounds are a worst case, and a rendering draws a checked flow only¶
checkFlow computes a flow’s bounds, and planOf and mermaidOf take what it
returned, so a broken file is never drawn: its faults are what is shown.
The bounds belong to the checked flow. They are computed once, from the resolved tree, and the plan,
/flowsand the dry run read the same figures. A callee’s ownboundsare its figures as a root; under a call, its nodes are counted in the caller’s, by their address through the call, since a callee with notimeout:takes its caller’s.The time is the sum of the bounded waits. Each turn, check and ask runs to its bound, branches running together take the longest one, and a
mapruns in waves ofconcurrency. A shared subagent or a queue of cards can make branches wait for each other beyond that, and the guide says so: a figure that added every branch would be true and useless for amapof ten run ten at a time.An ask with no
timeout:is a flag, not infinity.waitssays a person’s answer is part of the time, and the rest of the figure still says what the machine can take. One unbounded question would otherwise hide every other bound of the flow.A
choiceis its worst case, turns and time apart. Which case runs is decided at run time; each figure is the worst any case reaches, even when no single case reaches both.The plan is data, and text is one reading of it. Each line carries its kind, id and the visit path it stands for,
#nand[i]where a run will write numbers, so a live run fills the plan instead of drawing a second picture. A call is one line: its callee’s plan is the callee’s own.One turn bound, read in one place. The default of 30 minutes and the order node, nearest flow, default moved beside the bounds, and the runner reads the same function, so what the plan says a turn may take is what the run gives it.
Mermaid’s ids are numbered, not the file’s. A node named
endwould close a subgraph, and a branch name could collide with a node id. Numbering in drawing order keeps the text the same for the same flow, and every label carries the id. What Mermaid or its HTML would read as markup in a label is written as an entity code.The reference always has an index.
docs/reference/flows/index.mdis written even while no flow ships, saying so, so the navigation entry exists before the first flow does and the staleness test covers the directory from the start. It draws the package’s flows against the package’s agents only, files named from the repository root, so a page reads the same on every machine.A plain
mermaidfence.myst_fence_as_directivemakes MyST read it as the directive, so the same file is drawn by GitHub and by the site.
The live view folds the plan by the state of each visit¶
livePlan(checked, journal, events) is one pure fold beside picture.ts,
which is unchanged, as is tree.ts. The picture is still what a TypeScript
workflow with no plan draws, and what usage.json and the tool’s result
read.
The journal is the earlier lives and the events are this one. A journal entry is a
visit_endwritten down, so one fold reads both. A visit’s last end is the one drawn, whichever life wrote it, and acopy_lostforgets what lay under its branch, as a resume does. Fed the full journal and this life’s events together, a visit would count twice.A life is told apart by its
life_start. At first the journal wrote no mark when a life started, and a killed life was told from the next only when that one came in as events. The measurement of lives added the mark, and the view reads it, so the journal alone tells them apart.Folding follows state, and so does the glyph. Running is expanded, ended is one line, not visited is the plan line, so the first frame is the plan. A visit begun and not ended while nothing runs, which is what a kill leaves, is expanded under
○rather than●. A branch, an item or an iteration readsworkingwhile its block runs, which keeps the moment between two of its visits from flickering to○.A
choice’s cases are named, not counted.○ case 1, defaultand○ not taken: defaultuse the plan’s own case names. A count would disagree with the plan’schoice of 1 case, which leaves out the default.The counts are over the frame, the cost over every life. A visit a resume ran again counts once, as it last ended, so
failedmatches the✗a reader can see. The cost adds up every life’s, since each one was paid. A life that wrote itsrun_endtakes the total from there. A killed one adds up its outermost ended visits, whose time overstates branches that ran together: no clock saw that life end.Only a loop is named
not converged. Only a loop endsconverged: falseor failsunconverged, since a failure that travels up becomeschild. The summary finds them without looking up the node.The glyphs are the TUI’s.
statusIcondecides●,✓and✗for a subagent’s row and for a visit’s line alike, and the plan’s○is shared withshowPlan.–is the view’s own. An ended line names the agent its visit ran, and a running one names its subagents, so the journal alone and the stream alone draw the same last frame.herdr is left as it is. A split per subagent named by agent and scope, with none for
check,askorcommit, belongs with the switch to flows, which wires the view into the extension. Until then nothing reads the view, and herdr opens its splits as for any run.The width is the caller’s.
showLiveandshowSummarytake it with no default, liketruncate, and cut a line while keeping its indent.
The shipped flows are the pipelines’ work, written as flows¶
flows/ holds build, explore, split, interview and build-attended,
symlinked into .pi/flows/ like the agents and pipelines, and listed in the
package’s files. Nothing runs them yet. Each is checked against the shipped
agents by npm test, drawn in docs/reference/flows/, and walked with
dryRunFlow. Their first drafts predate rules the validator now holds, and
writing them for real moved them in the places below.
The attended build is
build-attended. It sorts besidebuild, and names its one difference in the word the run stage already uses: somebody is there. A flow’s name is a file name, not an id, so the dash costs nothing.Ids are CEL identifiers.
ask-nextbecameask_next, since a condition reads it and a dash is subtraction to CEL.A whole output is read by its bare id.
input: specandcommit: message, as the guide writes them, where the drafts hadspec.outputandmessage.output: both check, and one spelling reads better than two.The interviewer submits a question or none. Its output is
{ question?: Question }rather than{ ready, question? }. One optional field cannot contradict itself, whereready: falsewith no question was a card with nothing on it. The choice asks when there is a question, and the loop ends when there is none or the card was answered “enough”.Six questions end the interview, not the run. The loop has
on-fail: continue, so reaching its cap ends itconverged: falseand the brief is still written, as the interview combinator wrote one atmaxQuestions. Failing the run would throw six answers away.The brief shares the interviewer’s subagent with no
output:. It needs the conversation, which onlymemory: flowkeeps. The subagent carries thesubmittoolask_nextdeclared, so the brief’s prose says the turn calls no tool.A pair that reaches its cap goes on to the audit.
pairhason-fail: continue, and the audit readswork, where that pair showsconverged: false. The delivery handed the auditor a subtask “reviewed, NOT approved” the same way. Failing the build at the first stubborn review would leave the auditor, the one reader of the whole, no say. A coder that fails past its retry is absorbed the same way, and shows as a failed item.The audit reads
workin place ofplan. From the second round the subtasks are what the first audit raised, which the plan never held.workis what this round did, each item with its task.The coder and the reviewer read
item.text, notitem. The subtask arrives as text under its own heading rather than as JSON, and from the second round an item is an obligation,{ id, text }.The reviewer reads
diff. Inside a copy,diffis the pair’s own change, so the review weighs the change itself, not only the coder’s summary of it.A failed branch of
exploreorsplitis a failed report.on-fail: continuesits on the agent node inside themap, not on themap, so the synthesiser reads a failed branch as{ ok: false, error }, as its prompt expects. A plan pastmax:still fails the run: that is the flow’s bound, not a missing report.A disagreement between reports is the synthesiser’s to settle. Its definition says to name one and say which report the code supports, and it holds
readandgrepfor that.explore’s andsplit’sanswersaid to name it “rather than picking one”, so one turn carried both rules and the model chose which to obey. The flows no longer mention disagreements: the rule lives in the definition, which reads whole on its own and reaches every flow and workflow that uses the agent.The split planner’s prose names the two workers.
orchestratehanded the planner the workers’ descriptions; a flow hands it only itsreads:, so the section says what a scout and a reviewer do.Prose names the heading each read arrives under. A turn with several reads holds several sections, and “the task below” pointed at none of them.
The words the pipelines asked for are gone. “The reviewer approves with APPROVED” and
LGTMwere how prose was read; a verdict is a tool call now.The shipped
buildnames a script, not a command. The pipeline shipped no check, sincenpm testimposed on a project that has none is worse than asking. A path imposes nothing that runs: each project writes its own.pi/checks/test.sh, and one that has none is refused at the run stage, before the first turn, withcheck-script-missingnaming the path. An unattended build needs something to read “the tests pass” from, and the audit alone is an opinion. This repository’s own runsnpm test, and a test holds the shippedbuildto it through the run stage.buildends with a report, not with its loop. A run’s answer is its last root node’s output, anddeliver’s is the loop’s whole record as JSON: long, hard to read, and read in full by the main session’s model. Areportturn after it writes a few lines of prose from the request, the diff, the last round’s pairs and the audit: what was done, in which files, and what is left. It is thesynthesiser, which already merges several reports into one answer and has no tool that writes, so no new agent. The price is one more turn per build: 5s and 2.7k tokens of a 1m12s, 47k-token run onilaas/gemma-4-31b. It runs only when the build passed; a build that fails has no report, and its end line says where and why. It reads the two fields of the loop it needs, the pairs and the audit, rather than the whole record. The end line loses itsconverged, which only a loop gives, and which told nobody anything: a build that passes has converged.build-attendedhands the report to the committer, so what the build left undone reaches the commit body and the turn is not spent for nothing.
A flow run measures itself in its run directory¶
One directory holds a flow run’s state and its exports. The runner writes
the transcripts, since a subagent exports as it closes; usage.json is
written by a measuredRun subscribed to the run, which the runner never
opens.
The measurement stands beside
runFlow, not inside it.runFlowandresumeFlowgiven arunDirleave the snapshot, the journal and the transcripts. AmeasuredRunopened on the same directory writesusage.json, andevents.jsonlwithrecord: true. Unplugged, the run and its resume are unchanged. An experiment’s cell runs a flow with its own directory and its ownonEventand gets the visits with nothing added, and the one writer ofusage.jsonstays one. The command that will run flows owns the parent session, so it is the one to measure.A taken name takes the first free
~n. It is not the life’s number. A subagent that a timeout replaced in the same visit is a second file in the same place, as a later life’s is. The name is chosen at the spawn, against the disk and the names this life already handed out, so nothing already written is overwritten, and a run never resumed keeps clean names.main.jsonlandevents.jsonltake the same rule.A delegate’s transcript is placed by the flow, not by
delegateTool. The runner hands thesubagenttool to an agent naming it, which the flow runner did not do before, with a spawn that puts each child in<parent>.children/beside its parent’s files, theirs under them. A tool run through the extension keeps its flat layout. The roster is the agents the flow names, which the snapshot keeps, so a resume offers the same ones.A
visit_endsays what it was. It carriesnode,kindandsubagent. A life killed before itsusage.jsonis rebuilt from journal entries alone, which had no node or kind. And a subagent a scope keeps names only its first visit on itsspawn, so itsvisitscome from the ends.Plan order is computed from the ends. Each visit comes before the visits it holds, and visits under one holder come in the order they ended. A life folded from the stream and one rebuilt from the journal come out the same way, and only branches running together are in end order rather than in the order the file writes them.
The lives are read from the run directory. This life is the journal’s last
life_starttaken after the measurement opened, so a measurement whose run never started does not count one. Theusage.jsonan earlier life wrote gives the lives it lists. Any life between it and this one is rebuilt from the journal and markedpartial: itsrun_end’s usage when it wrote one, else what its outermost ended visits cost. A life endsinterruptedwhen it wrote no end or was stopped.totalis the sum of the lives, wall time included, and so is the top-levelwallMs. With no journal there are no lives, andvisitsandnodesstand alone.Each life counts what was measured of it. This life’s usage is its subagents’, delegates included. A rebuilt one’s is its visits’, which hold no delegate’s turns, since a visit’s cost is the delta of its own subagent’s session. The difference is written down rather than filled in.
measure/reads the journal past the flow’s door.flow/index.tsre-exports the runner, which spawns, and a subagent exports throughmeasure/: going through the door would close a cycle.lives.tssits inmeasure/, and the live view reads it throughmeasure/’s door.
The package exports the flow API, and /flows replaces /pipelines¶
The first visible step of the switch: src/index.ts exports flows, and pi
lists and plans them. At the time nothing ran a flow from pi, and /build and
/run still ran pipelines.
The root exports the door, minus what nobody outside calls. Every function a script or the extension calls goes out, grouped the way a run goes: finding and checking a flow, the ports, running and resuming, drawing. The types those functions take and return go with them, the checked nodes and
Conditionincluded, sinceCheckedFlow.nodesnames them. The values only the validator and the runner call stay inflow/index.ts:compileCondition,evaluateCondition,readSchema,showTypeand the code and kind lists (FAULT_CODES,CONDITION_CODES,ANSWER_CODES,ERROR_KINDS,STOPS) with the value typesCHECK,VERDICT,LEDGERandQUESTION. A caller matching a fault reads itsFaultCodetype. The ports arebashCheckandgitPort, andAskingandShownjoinAskUser, whose signature names them.A catalogue’s flow file says whose it is.
FoundFlowis a file with itssource, as anAgenthas one, so/flowsgives the origin without guessing it back from a path. The snapshot keeps it with the file.Only the user’s and the repository’s
pipelines/are read, never the package’s.removedPipelinesrefuses each file there withpipeline-format-removed, whose message names theflows/directory beside it and links to From pipelines to flows. The package’s own pipelines go with the linear format, and a fault only we can fix is noise for whoever reads it. It is a function besideloadFlowCatalogueand not a field of the catalogue: a flow is never checked against these files, and a snapshot has none.One line per flow, by name, and a refused file beside the one it would lose its name to. Each line has the name, the source, the bound
showBoundwrites and the description, in aligned columns; a broken flow or a leftover pipeline saysbroken, its faults under it asfile at: message..pi/pipelines/build.mdsorts under thebuildflow, which is how the shadowing is seen. A count opens the listing, because pi prefixes a warning withWarning:and would push the first row out of its columns./flows <name>ends with what is left under that name. A person who asks forbuildwhile an oldbuild.mdsits in.pi/pipelines/most likely meant that file, so its fault follows the plan.A line is cut to the terminal, and goes on under itself. pi’s notification wraps at the terminal’s edge back to column one, which broke every column of the listing and every level of the plan in the first real run. Each line carries the column its text starts at: a description goes on under the description, a plan line under its text, cut after a whole
·fact when one fits. The width isprocess.stdout.columns, the terminal pi draws in; with none, nothing is cut. The working directory is left out of paths and the home directory written~, since the checked flow holds absolute ones.
/run runs flows, and /build is /run build¶
The second step of the switch. /run <flow> <input> checks the flow at both
stages, runs it in runs/<timestamp>/ and leaves its answer in the
conversation; /run resume carries one on; /build, its flags and
build.json go from the extension. /step and the subagent tool still take
pipelines.
One command, and the file says the rest. The interview and the commit were what
/builddid beyond/run, and both are nodes of a flow now. So/runtakes--modeland--timeoutand nothing else: which model you hold keys for and how long you will wait are yours, what the work is, its checks and its copies are the file’s.--check,--worktreeand--pipelinehave no successor on the line.The run stage runs with the terminal’s ports. The card is the
askport even with nobody there:somebodyThereis what keeps a run from showing it, and a refusal then says “launched with nobody there” rather than blaming a missing port.bashCheckandgitPortare the other two. The refusal outside a repository that/buildmade itself comes from the run stage now, naming the node that needs git, so a flow that needs none, such asexplore, runs anywhere.Every run has a run directory, and is measured there. The live view is a
measuredRunon the run directory, with this session’s JSONL, sousage.jsonlands beside the journal and a resume adds its life to it.The answer is the last root node’s output, then one line on how it ended, then the last frame. The line is
ok, withconvergedwhen the last node is a loop, and the run directory; a failure says where and why, and what/run resumewould do or why it cannot. The model reads the answer and the line. The frame is in the message’s details, drawn by the renderer at the terminal’s width and never sent to the model, since a column of glyphs costs its context and says nothing the line does not. A typed output is shown as JSON. The message keeps its oldcustomType, so sessions that hold one still draw it.The widget draws the plan, and pi’s own cap would cut it. pi shows at most ten lines of a widget given as lines, and the plan of
buildis taller than that while one pair works. The widget is a component instead, painted at the width pi gives it, and the plan takes sixteen rows at most: cut above and below what runs now, each cut saying how many lines it holds, since the finished lines are the ones to lose first. A running visit’s subagents hang under it with what they are doing, from the pieces the plain widget is built of, and the visit line no longer names them twice. Paths leave out the working directory and the package, so a shipped agent readsagents/coder.md./run resumepicks the newest run that can go on.latestResumablereads each run directory underruns/newest first, keeps those whose snapshot was taken in this directory, and takes the firstresumePointaccepts. When none would, it says why the newest cannot, since that is the one somebody means. It names the run and the visit before it starts, so the line is there while the first turn is not. A flow namedresumewould be shadowed by it, soreserved-namerefuses the file.Flags may end the line too, for
/run. A known flag written after the text used to be read as text, and ran three scouts on the wrong model while one of them grepped the repository for the model’s name. A line does not end on--model <pattern>as prose, soparseFlagsreads--modeland--timeoutthere too; in the middle of the text they stay text. An input written as one quoted string is the text inside, and a request ending on its own?is not given a second stop in the framing line.herdr names a flow’s split by agent and home. A spawn event carries the subagent’s home, its memory scope’s path or its visit’s, and the split is
coder @ deliver#2/work[1]/pair: a flow’s ids count spawns across the whole run, andcoder#3says nothing of which pair it codes for. Acheck, anaskor acommitspawns nothing, so it opens no split.
/step and the subagent tool take flows¶
The third step of the switch. A stage of /step is a flow or an agent, and
the subagent tool runs a flow by name. Nothing in the extension reads a
linear pipeline any more.
One launch, three callers.
/run, a flow stage of/stepand the tool check a flow at both stages, hold it to the terminal and run it under the plan’s live view, measured in its run directory. That iscommands/launch.ts:launchablereturns the checked run or the stage that refused it with its faults, andlaunchruns it. How a refusal is worded and what becomes of the result stays with each caller, since a command notifies, a step records and the tool answers a model.The question card is shown during the model’s turn.
askcards go throughctx.uifrom the tool’sexecute, as pi’s ownquestion.tsandquestionnaire.tsexamples do, and somebody is there only in pi’s terminal,ctx.mode === "tui". A call with nobody there gets the run stage’s refusal, before anything is spawned. Checked in a real pi before it was written down: the card came up while the tool’s row said0/1 done,enteranswered it and the loop went on to its nextask_next, andescon the card was the card’s “that’s enough”, not pi’s interrupt: the brief was written and the turn went on. A typed answer throughOther…went to the card’s text box, not to pi’s editor, and reached the brief.RPC mode is nobody there. It was first read off
ctx.hasUI, which RPC mode sets too, since its dialogs go to the client. The card is drawn withcustom(), which returnsundefinedthere, and a card nobody saw read as declined: inpi --mode rpca tool call on a flow whoseaskhasdefault: truefailed at once withstopped: the run was stopped at this question. Now theasktakes itsdefault:, itsenough:or fails withnobody, as inpi -p./runand/interviewread the same answer. Putting the card through RPC’sselectandinputinstead is a second card, and waits for a client that needs it.The tool runs one call at a time. Two
subagentcalls in one message ran side by side, and a flow’s cards, its plan above the prompt and its stop key are the terminal’s, one of each: measured in RPC mode, two flow calls started together and one died on the other’s run directory. The tool saysexecutionMode: "sequential", pi’s own knob, rather than a queue of our own. pi then runs every call of that message in order, other tools included; a model that wants subagents side by side hasparallel.The widget stops offering
escwhile a card is up. The first frame of a card mid-turn readesc stops everythingright aboveesc That's enough, and the card holds the key. The hint comes back when the card goes.flowtakestask,model,timeoutMs,scopeandherdrAll, and refuses the rest by name. The file says what runs, sosteps,until,candidatesand the like would be ignored, and a model that believed they were used reads a wrong answer as a right one.lifetime,maxDepth,openInHerdrandexportgo with them: memory is the file’s, a flow run always has its run directory, and a field that changes nothing is a question the refusal answers once.modeis taken when it saysflow.The tool’s flows follow
scope, like its agents. A flow names agents and project scripts, so a repository’s.pi/flows/is read only when the call asks forprojectorboth; the package’s flows and the user’s are always there. The commands readboth, as they always have.The model reads the output and the line on how the run ended. The line names the run directory, and on a failure what
/run resume <run directory>would do. The tool does not resume: a resume is a decision about a run that stopped, taken by whoever reads why it stopped. The row draws the line and the last frame, the one/rundraws under its answer.A flow stage runs in the step’s folder. It is the run’s directory, so a stage that stopped can be carried on with
/run resume <folder>, and the step says so where an agent’s failure says where its transcripts are. The chain does not learn what a resume finishes: the answer lands in the conversation, as/run’s does, and a step recorded behind the chain’s back would be the kind of state the relay exists to keep in view.Flows first, then agents, and a broken flow is refused. A file of that name that does not check is the likeliest thing the person meant, so it is not fallen past to an agent of the same name.
--agentstill forces the agent./step --worktreegoes. Whether a flow’s workers get copies is the file’s,copies: trueon a block, as it is for/run.The relay writes a step’s input itself.
## Request, then## Output of step <id>, as before, but no longer through the pipeline’sstepInput, so the linear pipeline can go without touching the extension. A step’s kind isflownow; an entry an older session wrote aspipelinestill draws.
The linear pipeline is removed¶
The fourth step of the switch. src/pipeline/, pipelines/ and its links in
.pi/pipelines/, docs/guide/pipelines.md and their tests are gone, and the
package’s files no longer lists pipelines. A pipeline written down is a
flow now, and From pipelines to flows is where the
old format is still described, beside the flows that replaced it.
deliver,pairandauditretire, withsettleandresume. They were pipelines written in TypeScript, and keeping them beside thebuildflow would be two implementations of one shape, of which only the flow has a journal, restored copies and a script as its check. Nothing else used them:orchestrate,planandswarmstand on the pool and the parser, and the flow runner onreview/,git/andmapConcurrent, which all stay. The generic combinators stay public.examples/13-concurrent-writers.tsis rewritten, not kept onpair. It was the one caller left, and what it shows, two writers in two copies and their patches landed one at a time, isscratchWorktree,runandland, the primitives a flow’scopies: truestands on. It loses the reviewer, which thebuildflow has.VerifyandcommandVerifiergo, andlandlosesverify. Its own header saidVerifystayed for the linear pipeline only, and after it no caller passed one. A flow checks the tree with achecknode after the block, not between two patches.The review record’s public types narrow to
ObligationandClosure. Those are what aJournalEntrynames.Verdict,Ledger,ReviewRecordand the rest were named bypair’s andaudit’s results, and a type no public signature names is off the list./flowskeeps its scan of oldpipelines/directories, with no parser.removedPipelinesreads file names, never a file’s content, so nothing of the linear format is needed to refuse it.definitionDirstakes no package directory for a kind the package does not ship, rather than one that must be switched off.examples/11-build.tsruns the shippedbuildflow.checkFlow, thencheckRunwithbashCheck,gitPortand nobody there, thenrunFlowin a run directory of the target repository. It needs the target’s own.pi/checks/test.sh, and is refused before any model runs without it.The tutorials keep their frames until they are recaptured. A frame is captured, never composed, so each page that shows the linear format says so in a note, and its prose stops describing it as current.
A chain walked by hand¶
/run explore … put its answer in the conversation, and the session picked it
up and started orchestrating: every later command was chosen against a
conclusion the main window had already drawn. That is the documented behaviour
of /run working exactly as decided - an exploration is read and then asked
about - and it is the wrong default for the other use, where the main window is
a console and the chain is explorer → planner → coder → reviewer advanced one
step at a time. So a knob, not a reversal: /step, /chain, /quote.
The output goes to a relay, not to the conversation.
appendEntrydraws the step in the transcript and keeps it out of the model’s context, and the text waits in module state for the next command./quoteis the single door into the conversation, taken on purpose, with the same framing/runuses - pi turns a custom message into a user message, and an unattributed report in that slot reads as an instruction.A
Resultcrosses, never a context. Which is the lifetime rule already: persistent subagents do not share history, you passResults. Each/stepopens a subagent and closes it, so there is no live session to own between two commands and nothing to leak if pi is quit in the middle. Carrying a conversation would also have made--modelper step meaningless.Replaced: the relay writes the same two sections itself, and a flow stage reads them as its
input; see/stepand thesubagenttool take flows. A step is handedstepInput, the same three sections a pipeline’s steps get. A chain walked by hand and the same chain written down then send the model byte-for-byte the same thing, which is the only way the two can be compared. A first step carries nothing and is passed through verbatim, as/runpasses its request: a lone instruction under a## Requestheading is noise.Replaced: a step is a flow or an agent, flows first, a flow stage in a run directory of its own; see
/stepand thesubagenttool take flows. A step may be a pipeline or an agent, resolved in that order, becauseexplore- a fan-out and a synthesis - is a perfectly good stage of a chain and so is a loneplanner. A name held by both runs the pipeline and says so, and--agentis there so that the collision is not a dead end. A name held by neither is one message, not two listings.A failed step leaves the chain untouched. It produced no material, and recording it would hand the next agent an error message as its input. The export stays on disk and the same command can be retried on another model, which is the whole reason a step is typed rather than walked.
One folder for the chain, one subfolder per step. A chain walked by hand is still a run, and leaves the same trace as one walked by
/run. What/chaintotals is the sum of the steps’ own wall time, not the age of the chain: most of a hand-walked chain is spent waiting for a human, and counting that as work would be an estimate./quoteand not/share, which is what it was called until a real pi said so at startup:shareis one of pi’s own twenty-three built-in slash commands, and an extension command of that name is dropped from its own autocomplete. No test here could have caught it - the fakepia test hands the extension has no built-ins to collide with.quoteis also the better word: what lands in the conversation is a quotation, attributed and read as one.
Measurements: time and tokens per subagent¶
Nothing is estimated, nothing is recomputed by hand: pi already exposes the numbers, we collect and attribute them. The only things the library adds are time (pi does not measure it) and aggregation per subagent.
type Usage = {
// time - measured here, on a monotonic clock (performance.now()), not Date.now()
wallMs: number; // from spawn to close (includes waiting between two asks)
busyMs: number; // time actually spent working (sum of the asks)
turns: number;
// tokens & cost - reported by pi, never reconstructed
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextTokens?: number; // current context size (persistent agent)
};
Where the numbers come from:
session.getSessionStats()→{ tokens: { input, output, cacheRead, cacheWrite, total }, cost, contextUsage, userMessages, assistantMessages, toolCalls, … }. This is the source of truth for tokens and cost.session.getContextUsage()→ context occupancy, to display for persistent agents.Time is measured around each
session.prompt():busyMsis the sum of theaskcalls,wallMsruns fromspawntoclose. On a"task"agent the two are nearly equal; on a"workflow"agent the gap between them is the interesting information (waiting time vs useful time).
Rules:
getSessionStats()is cumulative over the session. A turn’s usage is therefore the difference between two snapshots, taken before and afterprompt(). That is what gives bothsubagent.usage(since spawn) andresult.usage(this turn) without ever recounting a token.Counters are clamped at
0: compaction can walk the totals backwards, and a negative usage means nothing.A fan-out aggregates, it does not average: total tokens, total cost,
wallMs= duration of the fan-out (not the sum of the branches),busyMs= sum of the branches. The ratio of the two gives the real parallelism - that is what we want to see.A failure counts too. A subagent that crashed after 12k tokens cost 12k tokens; its
Usageis filled in even whenok: false.A refused turn does not. Asking a subagent that has already been stopped returns without reaching the session, so
turnsstays where it was: there was no request and no answer, and a turn nobody made is the one kind of number this section exists to keep out. A failure that ran still counts, which is the line between the two.Never estimate tokens by counting characters. If the provider does not report them, the field is
0and we say so.
Usage does its own arithmetic¶
sumUsage existed, and four places did not use it. The subagent’s cumulative
usage added a turn field by field; usage.json flattened its total into a
record by naming the fields, leaving two out; the experiment report summed
those records key by key and turned them back into a Usage by naming the
fields again; and the hand-walked chain summed its steps in a reduce of its
own. A tenth field on Usage would have needed six edits and would have
vanished from the experiment table without a test saying so, and the clamp at
zero lived in deltaUsage alone.
The arithmetic is usage.ts’s: deltaUsage for a turn, accumulate for a
subagent’s life - the one rule sumUsage does not have, that a context is a
level replaced by the latest reading rather than a counter summed - and
sumUsage for several subagents. Nothing outside the file names the field
list. usage.json’s total is a Usage with the subagent count beside it, and
its wallMs is the run’s; the experiment summary is a sumUsage over the
cells with their wall times added, because they ran one after another, and its
own wallMs field went into the total rather than standing beside it as a
second copy. asUsage is gone with the record it read back.
Session export¶
Two formats, two uses, both provided by pi (AgentSession):
await session.exportToHtml(outputPath?); // → path of the HTML file, readable/shareable
session.exportToJsonl(outputPath?); // → JSONL of the current branch, replayable
Implemented in src/measure/export.ts, wired into spawn and every workflow
through exportDir.
An export covering the parent session and all its subagents. An orchestration export that lost the subagents’ work would be useless. What lands in
runs/<timestamp>/: one<agent>-<n>.html/.jsonlpair per subagent,main.jsonlfor the parent, and ausage.json.main.htmlis not there, and will not be. pi’s HTML renderer is a method ofAgentSession; an extension only ever gets aReadonlySessionManager(ctx.sessionManager), andexportFromFileis not re-exported from the package root - theexportsmap blocks a deep import. So we copy the parent’s JSONL, which pi’s ownpi --export <file>turns into the same HTML on demand. Writing our own HTML would break “we reimplement nothing”.main.jsonlis written from what pi holds when pi has no file yet. pi creates a session’s file with its first assistant message, so the first command typed in a fresh pi runs in a session whose file does not exist, and--no-sessionnever writes one. Copying the path alone leftmain.jsonlout of those runs, seen in real ones. A run is handed the session itself (MainSession, the part ofctx.sessionManagerit reads) rather than its path, and reads it when the run ends. pi’s file is copied when it exists; otherwise the header and the entries pi holds are written one JSON object per line, which is all a session file is. Nothing is rendered and nothing is added, so this is not a second JSONL writer. Waiting for the file was the other way, and a parent that never replies never writes one.exportDirimplies a session directory,<exportDir>/.sessions.SessionManager.inMemory()persists nothing, and pi answers a request to export one withCannot export in-memory session to HTML. Asking for an export is asking for the session to be kept long enough to export it - one decision, not two.sessionDirstays available for anyone who wants the working files elsewhere. Neither is a default: with noexportDir, a subagent leaves nothing behind, not in~/.piand not in the working directory.JSONL and HTML are attempted separately. An in-memory session still yields its transcript even though pi refuses to render its page; losing both because one is impossible would be a poor trade.
Export can be triggered at any time, not only at the end of a workflow:
subagent.export(dir)works on any live subagent.close()exports first and disposes after, so the workflow’sfinally- cancellation included - is already the “export what was done” path. What is written on the interrupted path is what makes this feature worth having.An export never throws. Every failure is a string in
SessionExport.error. An export is an observer of the run, and an observer that takes the workflow down with it is a bug - most of all when it runs on the way out of a crash.usage.jsonis the only artefact we produce ourselves; we reimplement neither pi’s HTML nor its JSONL. It is built from the sameTuiSnapshotthe TUI draws (usageReport(snapshot, wallMs)) - one collected state, two consumers - and it carries what pi cannot: time, attribution per subagent, andparallelism(busy over wall).Not to be confused with
pi --export <file>(CLI, on an existing session file): useful when debugging, and the way to rendermain.jsonl.runs/gets a.gitignoreof*, written once. The exports land inside the repository the run is working on, and git has no business with them. Nobody had hit that here because this repository ignoresruns/in its own.gitignore: the defect only shows in somebody else’s. Two ways it shows, both measured in a real run. A delivery givenworktree: truecannot put its subtasks’ patches back, becauselandrefuses a tree that is not clean andruns/alone makes it unclean. And/buildcommits withgit add -A, which sweeps a run’s transcripts into the user’s history.Relaxing the landing was the other candidate and a test refused it: a delivery lands twice, and the second time it has to recognise the work the first one left, which is precisely untracked files. So the export gets out of git’s way instead of git being told to look away. A
.gitignorealready inruns/is left alone - the directory is the user’s the moment they have said anything about it.A run directory is suffixed
-2,-3when its second is taken, and created exclusively. A timestamp to the second alone gave twosubagenttool calls started in the same second one directory, and the second flow died onEEXISToversnapshot.json: a run directory holds one run. A suffix only on collision keeps the common name as it was, and reads like the run’s branch does. Finer timestamps or a random part make a collision rarer, not impossible, and the names harder to read. The names sort by start with a numeric collation (newestRunFirst), which is how/run resumefinds the newest.
Display: herdr if present, pi TUI otherwise¶
One event stream, several reporters. The core emits:
type SubagentEvent =
| { type: "spawn"; id: string; agent: string; lifetime: Lifetime }
| { type: "status"; id: string; status: "working" | "idle" | "blocked" | "done"; task?: string }
| { type: "text"; id: string; delta: string }
| { type: "tool"; id: string; name: string; args: unknown }
| { type: "usage"; id: string; usage: Usage }
| { type: "close"; id: string; result: Result };
A listener that throws is swallowed: a broken reporter must never take a workflow down with it.
The task rides on the "working" transition, not on spawn: at spawn time
nobody knows yet what the subagent will be asked, and a persistent subagent is
asked several different things over its life. A reporter has no other way to
learn it - and until it did, every collapsed row in the TUI showed a blank task.
herdr reporter (the default when available)¶
Implemented in src/reporters/herdr.ts, transport in herdr-client.ts.
Verified against herdr 0.9.0, protocol 22, by node scripts/check-herdr.ts and
by a run with panes open.
The key idea, because it is not the obvious one. A herdr pane cannot host
an in-process subagent: there is no process and no TTY to attach. So the pane
does not host the subagent - it displays a stream we write. We append to a
file and open a pane running tail -n +1 -f on it, which takes three calls
because herdr has no single call that does all three:
pane.split { direction: "right", target_pane_id: ours, focus: false } // → result.pane.pane_id
pane.rename { pane_id, label: "reviewer#2" }
pane.send_input { pane_id, text: "exec tail -n +1 -f '<log>'", keys: ["enter"] }
target_pane_id is ours, from HERDR_PANE_ID: with none, herdr splits whatever
pane is focused, which can belong to another client.
exec, so the pane is the stream - closing it closes what it follows - and
the clear that puts the name at the top is written into the log file rather than
run as a command, because the shell is still starting up and writes over
anything printed before it has finished. Measured, as a zsh history warning
sitting on top of a member’s first turn.
This also settles the ownership question: a pane carries exactly one agent
/ agent_status, and the main pane’s already belongs to herdr’s own pi
integration (source herdr:pi, installed at
~/.pi/agent/extensions/herdr-agent-state.ts - read it, it is the reference
implementation). The panes we open are ours, so we report on those and never
fight for the main one.
Detection: HERDR_ENV === "1" and HERDR_SOCKET_PATH and
HERDR_PANE_ID. All three, or nothing. Missing them is the normal case outside
herdr, so we fall back silently: never an error, never a noisy warning.
Transport: unix socket, newline-delimited JSON, one connection per request,
resolve on the first data, then destroy(). First attempt at 500 ms, one
retry at 1500 ms, then give up quietly. Envelope { id, method, params }.
Points that cost time to discover:
PaneAgentStatehas nodone- it isidle | working | blocked | unknown, even though the eventAgentStatusenum does havedone. Our"done"maps toidle, andpane.release_agentis what actually retires the agent.pane.splitanswers with thepane_id(result.pane.pane_id), which is the only way to later name, run in, report on, release and close that pane.agent.startis not that call, whatever its name suggests: it starts a recognised agent in a pane that already exists.pane.splitis what makes panes. See the reversal below.Release before close. Closing first leaves herdr holding an agent on a pane that no longer exists.
Every promise chain ends in a
catch. A try/catch around the listener is not enough: an unhandled rejection escapes it entirely and takes the process down. Found by a test, not by reading.seqis a monotonic ordering field; seed it from the clock like the pi integration does, so two processes reporting on one pane do not collide.Useful CLI equivalents when debugging:
herdr pane list,herdr agent list,herdr pane read <pane_id> --source visible. The read sources arevisible | recent | recent_unwrapped | detection, with an underscore - the CLI’s own--source recent-unwrappedis spelt the other way and the socket refuses it.
openInHerdr is opt-in, per subagent, exactly like lifetime: a fan-out of
twenty branches must not carpet the screen unless someone asked. The other
regime - watch everything - belongs to the reporter, not to the core:
createHerdrReporter({ all: true }), toggled for a session with /herdr on.
It was also seeded by a COMBO_HERDR environment variable, which is gone: the
same rule that removed COMBO_MODEL applies to display. Configuration is an
argument or a command, never ambient state. Who gets a pane is a display decision,
and the workflow runs identically either way; putting it on the spawn would have
meant threading a flag through every call site to change what a terminal shows. It travels on
the spawn event rather than being read back from the core - a reporter is a
pure observer, it never queries anything.
The split closes automatically when the subagent closes, after the final usage line is written. No orphan panes after a fan-out.
A board gets a pane, and it is not an agent. A pane per member shows each
one working and none of them talking: what a member said is in its own pane, and
who it was addressing is only legible where all of them are. So the first thing
anybody says opens one more pane, named board, carrying the exchange in order,
and each member’s pane keeps its own half of it without repeating its own name.
Nobody works in that pane, so no agent state is ever reported on it and nothing
is released when it closes - a pane that never had an agent has none to give
back, which is now what finish checks rather than assumes. It opens only when
the run is watched at all, and closes with the last member.
Reversed: agent.start was never the call that opens a pane. Under herdr
0.7.3 it took an argv and a split and did open one. By 0.9.0 it takes a
kind and a pane_id and starts a recognised agent in an existing pane, so
every request combo sent was answered invalid_request. Nothing said so: a
reporter must never throw, so the refusal was swallowed, paneIdOf gave
undefined, and every call after it was skipped by design. /herdr on warned
only about herdr being absent, which it was not. The suite stayed green over a
feature that opened nothing at all, because every test of it asserted on combo’s
own idea of the request.
The guard for it is not another test. node scripts/check-herdr.ts runs the
reporter through a subagent’s whole life against a recording transport, then
holds every request it made to herdr api schema --json. Because it validates
what the code sends rather than a copy of it, it cannot drift from the code the
way example payloads would; run against the shape that shipped, it names both
missing fields. scripts/drive-pi.py plays the same part for pi: a check a fake
cannot perform, run by hand.
/herdr on asks herdr, and says what it heard. Being inside herdr was the
only question it used to ask, and it is the wrong one on its own: a herdr that
answers invalid_request to everything is indistinguishable from a working one
until a run opens no panes. Nothing in the reporter may say so, because a
reporter that warns is a participant, so the command says it instead.
The probe is harmless because of the pane it names. pane.split sent with a
target_pane_id no herdr can have separates the two answers: invalid_request
when the request itself is wrong, pane_not_found when it was understood and
only that pane was missing. Neither opens anything, and if some later herdr
splits anyway, the probe closes what it got. It asks about pane.split alone,
because nothing else runs without the pane id it returns;
scripts/check-herdr.ts covers the other five, against the schema rather than a
live server. The params come from one function (splitParams), so a probe
cannot go on answering yes about a request the reporter no longer makes.
The preference is set whatever the answer. What a user wants for the session is
not herdr’s to decide, and a /herdr on that silently declined to be on would
be a second way to be surprised by an empty screen.
Two tests asserted that the suite runs outside herdr. It does not, for anyone
developing this in the window it is written for: both passed for the wrong
reason there, and one of them opened a pane in the terminal running npm test.
test/fixtures/no-herdr.ts takes the markers out of the environment for the
length of a body, so the suite answers the same either way.
The wording of those lines lives in src/reporters/traffic.ts and nowhere else.
The console and a herdr pane are read side by side when a run is compared
against another, and a difference in how they say it would read as a difference
in what happened.
Reversed: a subagent’s pane hosts a client, not a file. The tail -f
above was the right answer to a pane that cannot host a subagent, and the
wrong shape for two things asked of it since: to read like pi, and to take a
word for the subagent. A text file can do neither. The split now runs
pane/main.ts, a client of the mirror, so what it draws is pi’s chat and what
is typed into it reaches the turn in flight. The three herdr calls are the
same three, with a different command typed into the shell; the file, the
one-line tool summary and the usage line the reporter used to write are gone
from it, because the pane draws them from the session itself. Text, tool and
usage events still flow on the bus for every other reporter, and the herdr
reporter reads only spawn, status and close from it: what herdr must be
told, and nothing the pane already knows.
The command names the binary this process runs on (process.execPath) and
the client where the package keeps it, both quoted, because the split’s shell
may find another node or none, and a pane opening on a usage error says
nothing about why. When this process is not node at all, the shell’s node is
the one candidate left.
The board keeps its file. Nobody works in it and nothing is typed to it, and
its lines are the console’s, which is the property worth keeping there. A
member’s own pane no longer repeats its half of the traffic: the tool calls
it made are drawn as tool calls, which is how pi would show them. The console
keeps the ⌨ line for a steer, and traffic.ts does not carry it, because
the pane draws a steer as the user message it becomes and two displays of one
run must not say it twice.
A reporter that nobody subscribes reports nothing. onEvent takes a single
listener, so watching in the TUI and in herdr means composing them - use
combineReporters(collector.reporter, createHerdrReporter()), which drops the
undefined that createHerdrReporter returns outside herdr. The extension once
passed openInHerdr all the way to the spawn event with nobody listening, and
nothing failed: no split, no error, no clue.
The mirror: a live session on a socket, and what a typed word may do¶
A pane cannot host an in-process subagent, and the tail -f above shows one
without letting anybody speak to it. What a pane can host is a client: the
mirror (src/mirror.ts, wire in mirror-wire.ts) registers every live
subagent by id on one unix socket per process, replays session.messages to
whoever attaches, then forwards pi’s own session events as they come. pi’s own
chat components are written against exactly those events, so a client built
from them draws the session the way pi would.
The mirror is not a reporter. A reporter reads the stream and never reaches
the session; this one takes steer and abort from the socket and applies
them. It is a port of the core, beside ask.ts and verify.ts, and it is
reached only by the act that opens a pane. Registration is a map entry, so
every subagent registers and no socket exists until something asks for its
path: the suite spawns hundreds of fakes and listens on nothing.
Nothing typed while idle is queued. Probed, on two pi generations with
the same answer: a steer queued on an idle session is delivered with the
next prompt() and answered in place of it (user SECOND → user STEERED → assistant STEERED); a followUp queued while idle is answered inside the
next prompt(), after the task, so lastAssistantText reads the person’s
exchange back as the workflow’s result. Either silently changes a turn the
workflow believes it composed. So a steer is accepted only while
session.isStreaming, refused otherwise with a reason the pane can print, and
followUp is not on SessionPort at all. Mid-turn a steer lands after the
tool call in flight and the transcript ask returns carries it, which is the
feature.
A steer is an event. { type: "steer", id, text } goes on the bus when
one went through, so events.jsonl, the console and the pane say a person
spoke and where. A run somebody steered is not the run they would have got by
watching, and the record is what tells the two apart afterwards. The line is
traffic.ts’s (⌨ scout#1 ← …), for the reason every line there is: two
displays of one run must say the same thing.
The socket path does not ride on the spawn event. The plan had it
there, beside openInHerdr; it is a process-wide fact and not a subagent’s,
so mirrorSocket() answers it once and the same path serves everyone alive.
A reporter asking a module for a path is not a reporter querying the run.
attached names the pi package this process resolved
(import.meta.resolve), because the events are that pi’s and the components
reading them have to be too - the version trap above, seen from the other side.
The pane is pi’s chat, fed by the mirror¶
pane/ is the client a herdr split runs: a TUI over a ProcessTerminal,
a chat of UserMessageComponent, AssistantMessageComponent and
ToolExecutionComponent, an Editor, two lines of footer. The chat runs the
switch pi’s own interactive mode runs over the same events, which is why it
looks like pi: there was no drawing to invent, only a session to be fed.
It sits at the top level like extension/, because it is pi UI code and
imports @earendil-works/pi-tui, which src/ never does: the TUI collector
stays free of it so a snapshot can be tested without a terminal, and so does
this, on render(width) of the components themselves.
It imports pi statically, from the same tree as src/. The plan had it
import the package from the URL attached carries, so the components would be
the version that produced the events; but pane/ and src/ resolve the bare
specifier the same way from the same checkout, so the URL adds nothing here and
a dynamic import would cost the types. The URL stays on the wire as a fact a
client may compare against its own resolution, and nothing reads it yet.
pi’s editor theme is not exported. getEditorTheme() lives in pi’s theme
module and index.ts does not re-export it, and theme itself is not exported
either, so the pane’s editor border is plain dim rather than the theme’s
borderMuted. The select list inside it is the theme’s, through
getSelectListTheme(), which is exported.
Verified in a pty against a real subagent: the task, the thinking, each read
in its box, the answer; a line typed one second into the turn shown as a user
box, the model’s answer to it below, and Result.output reading STEERED on
the library’s side; a line typed after the close answered done - nobody is listening. The frame was drawn with scripts/frame.py, which is the check no
fake can do.
pi TUI reporter (always available)¶
Implemented across src/reporters/tui.ts and extension/index.ts.
The split that makes it testable. tui.ts collects - it turns the event
stream into a TuiSnapshot and formats strings, with no pi-tui import. The
extension draws - it maps tool arguments onto combinators and builds
components. Collection is therefore tested by inspecting a snapshot, never by
scraping a terminal, and the same state would feed a web view or an export
without touching a component.
The rendering is still tested, though: test/extension.test.ts captures the
registered tool, calls renderCall / renderResult with a full Theme, and
reads the component’s own render(width). That catches the failure that
actually bites - a renderer that throws makes pi fall back to its default
rendering silently, and nobody notices until the demo.
Theme.fg throws on an unknown colour, so a partial stub fails on the first
unusual colour rather than on a real defect: test/fixtures/theme.ts builds a
complete one. Do not reach for pi’s internal theme singleton - it is not
exported from the package root.
The extension registers the tool with renderCall / renderResult (see
docs/extensions.md, Custom Rendering) and composes with
@earendil-works/pi-tui (Container, Text, Markdown, Spacer).
Load it with pi -e extension (the flag accepts a directory), or pi install ./extension to add it to settings.
Display specification - this is the “Claude Code” bar we are aiming at:
A dot per subagent above the prompt, via
ctx.ui.setWidget(key, lines)(aboveEditoris the default placement).●while it works,✓when it succeeded,✗when it failed, coloured by status; then a dimmed line with model, tokens and time. This is the Claude Code shape, and it is the live view - the tool row below holds the record.The widget disappears as soon as the work ends, in a
finallyso a thrown workflow does not leave a dead row of dots above the prompt.widgetRows()says what each line is and applies no colour, so the layout is testable without a terminal; the extension paints it.Events alone are not enough to keep a clock.
usage.busyMsonly lands when a turn ends, so a widget reading it would show0.0sfor the whole wait and then jump to the total.SubagentSnapshot.startedAtgives a live figure, and the extension repaints on a 250 ms tick - a subagent thinking for twenty seconds emits nothing, and a frozen clock reads as a hung agent.
Collapsed tool row, compact, one line per subagent: status icon (
⏳/✓/✗), agent name, truncated task, last tool called.Streaming: you see tool calls arrive, not an opaque spinner. Handle
isPartial, callcontext.invalidate()sparingly.In parallel, everything advances at once:
2/3 done, 1 running.Expanded view (
app.tools.expand): full task, all tool calls formatted ($ cmd,read ~/path:1-10,grep /pat/ in ~/path), final output rendered as Markdown, usage per subagent.Usage line:
3 turns 12.4s ↑12k ↓2.1k R8k $0.0412 ctx:34k model- and, for a persistent agent, the cumulative usage since its spawn.End-of-workflow summary: a table with one line per subagent (time, turns, tokens, cost), a total at the bottom, and the export directory path if an export was requested.
Use
keyHint("app.tools.expand", …)rather than hard-coding “Ctrl+O”: the user’s key configuration must be respected.Reuse
context.lastComponentinstead of rebuilding the tree every frame.
A figure on a row is one somebody measured, and a row fits its terminal¶
Recapturing the tutorials in a real pi showed rows that said more than was known, or took more room than they had. What each one does now:
The summary’s time is the clock’s, handed in. It added up the visits that had ended, so it read 20s at 39s elapsed and jumped when a long visit ended.
livePlantakeselapsedMs, this life’s time by the caller’s clock, and still reads none of its own: the same arguments draw the same frame, and the extension hands inelapsedMs()of the view at each paint.A line counts what every life spent there. After a resume, a loop’s
visit_endholds the work of the life that wrote it, and the root lines did not add up to the summary. Each life keeps the visits it ended, and a line adds, for every life, the outermost of them at or under its path. Acopy_lostor a visit run again forgets how it ended, never what it cost.No token figure before a turn has ended. pi’s counters are read when a turn ends, so a subagent in its first turn read
↑0 ↓0for all of it, which looks measured.showTokensgives nothing whileturnsis0, and↑0 ↓0after a turn, when the zero was read. It is one rule for the widget, the flow rows,formatUsageand the summary line, and it replaced the list of node kinds that had no tokens: acheck, anaskor acommitruns no turn.No cost figure when none was reported. The widget and the flow rows already left a zero cost out;
formatUsageprinted$0.0000, which reads as free.showCostleaves it out everywhere, and a table column, which cannot leave a cell empty, saysnot reported.A refused file reads as a warning, and only it.
/flowsnotified the whole listing as a warning as soon as one file was refused. It notifies atinfonow, and colours each line itself, since pi colours a notification whole: a refused entry and its faults in the warning colour, the rest as a listing.The tool row names what the mode runs. It named the first of
flow,agent,stepsandcandidatesthat was set, so a loop readloop coderorloop coder → reviewerdepending on whether the model had also passedagent.runsWhoreads the field the mode reads.A notification is fitted before pi draws it. pi wraps a line where the room runs out, and a path is one long word. Every refusal goes through the fitting
/flowsalready used, nowextension/notice.ts: paths named from the working directory, a cut before a path, and theError:orWarning:pi writes before the first line counted. A hang that leaves fewer than 24 columns goes on four columns in, so a description at 80 columns is not a word per line down the right of the terminal.A painted line is cut in columns, last. A subagent’s row under a deep visit took two columns more than it had when its activity filled the room, because the two spaces before an empty detail sat before a colour code, where
trimEndcannot see them, and pi exited onRendered line exceeds terminal width. The parts are joined only when there is something to join, and every linepaintFlowgives is cut bytruncateToWidth, which counts what the terminal counts: colour codes, tabs and wide characters.A call that came back an error says so. A call pi refused,
Tool write not found, was listed like one that ran. pi’stool_execution_endcarriesisError, andsession.tsreads it into atool_errorevent with the first line pi said; the call is drawn✗ write notes.txt · Tool write not found. pi does not say whether a call was refused or ran and failed, so neither do we: the words are pi’s.
One picture of the run, and readers that do not fold¶
The TUI collector held the fold of the event stream - who spawned, under whom,
doing what, at what cost - and it was not the only one. The console kept a
depths map of its own to indent a delegated subagent, the extension rebuilt
counts and a hand-summed total from the subagents pi handed back in a tool
result, and setTask sat on the collector’s interface for a caller that no
longer existed, its TSDoc saying the core did not emit the task when status
had carried it for some time. A fix to the fold - the launch order of a
fan-out - reached the widget and nothing else.
The fold is one module now, picture.ts, named for what it is rather than for
the first thing that read it: createRunPicture folds, RunSnapshot is what
comes out, and snapshotFrom derives the counts and the total from a list of
subagents so the extension gets the same picture back from a serialised
result. Depth is a fact of the fold, decided when a subagent spawns from the
parent the picture had seen, and stored on the snapshot; treeOrder only puts
children after their parents. The console reads the depth from a picture of its
own instead of keeping one, setTask is gone, and of(id) takes its place on
the interface - a read where there was a write-in side door. Formatting stays
in tui.ts, with nothing to hold.
What did not move, and why: the herdr reporter keeps a map of the panes it opened, which is effect state rather than a second copy of the run; the mirror relays a session’s events to a socket and holds nothing; and the pane client lives in another process, fed by the wire, with three variables. None of them re-derives what the picture knows.
The stream on disk¶
recordReporter(file) appends every event as one JSON line. It is a reporter
like the others - the run is identical without it - and it earns its place
because pi’s per-subagent JSONL cannot show the interleaving, nor carry a
timestamp pi does not have.
Verbatim, no filtering, no knob. A recorder that edits its own record is worse than a large file, and the question it will be asked is the one nobody planned in advance.
appendFileSync, not a write stream. One syscall per event is the cost; the run worth reading afterwards is the interrupted one, and a buffered stream loses its tail exactly then. Same trade as the export.Every experiment cell gets one, at
events.jsonl, with no option to turn it off. A cell whose stream was not kept can only be re-run, and a matrix is expensive. The day someone needs it off is the day the knob is justified.
A measured run is the library’s¶
The extension’s live view and an experiment’s cell assembled the same thing by
hand: a picture, a composition of listeners on it, a clock, and at the end
usageReport(snapshot, wallMs) written with writeUsageReport. The view added
the parent session’s copy and swallowed a failed write; the cell added a
recorder and let a failed write throw. Two adapters at one seam with no name,
each free to drift from the other, and the guide’s own example of an export
was a third copy.
measuredRun({ dir?, record?, listeners?, mainSessionFile? }) in
src/measure/measured.ts is the seam: onEvent to subscribe, the picture,
elapsedMs(), and finish(), which writes usage.json with the time measured
and hands the report back, never throwing, because an export is an observer of
the run. The view is a measured run with a terminal on top and takes its dir
up front rather than at stop(), since watched knows it from the start. The
cell is a measured run with record: true, which is where events.jsonl
comes from now. A script that wants usage.json is one with nothing added,
and that is what the export guide shows.
run-ui.ts was four concepts at 224 lines: the view, the report, the herdr
session switch and the widget’s paint. The report is the library’s now, the
switch is herdr-switch.ts because the command sets it and the view reads it
and neither should reach into the other for a boolean, and the counter that
gives escape to a question card is asking.ts for the same reason: the card
sets it, the stop key reads it, and ask-ui.ts importing from stop.ts for
it made the card depend on the stop registry.
The assembly is asserted once, in test/measured.test.ts. The tool’s two
export tests that proved the parent session’s copy and its absence through the
tool are gone; the tool’s tests still say where it exports and that it exports
nothing unasked, and the experiment’s still say each cell gets its own
directory and its own stream.
How a subagent stands is read once¶
The glyph that says how a subagent stands was decided in five places. The
widget rows folded ok and status into ● / ✓ / ✗; a helper beside them
folded the same two into ⏳ / ✓ / ✗ for a collapsed text line; the tool’s
card and its expanded view each wrote ok === false ? ✗ : ✓; the widget’s
painter mapped a status to a colour; and the console wrote ✓ on every
close, whatever the result said. That last one is the bug the close event’s
own comment records having fixed once, for every reporter that read the event
it survived in the console because the console had a copy of the rule. The text line, meanwhile, had no caller in production and a thorough test, which is this repository’s own trap in miniature.
standingOf(snapshot) is the one fold: the status, with a failure outranking
it. statusIcon(standing) and statusColour(standing) are the two tables read
off it, and every reader - the widget rows, the card, the summary table, the
painter, the console - draws what they give it. ● while it lives, because
that is what the widget drew and what the guide shows; the ⏳ the display
specification above kept for a collapsed row went with the row nobody drew.
The console keeps ⏳ on its spawn line, which says something else: born, not
yet working.
One lookup for a pipeline¶
Removed with the linear format; checkFlow is the one lookup of a flow.
See The linear pipeline is removed.
The rule that a broken file is refused rather than silently replaced was
implemented three times, with three message shapes: in findPipeline, which
the extension never reached for a broken file because two of its callers
checked first; in choosePipeline, with a command: prefix its unknown-name
sibling never had; and in resolveTarget, which searched the catalogue itself
rather than call findPipeline, because it needed “absent” to fall through
to an agent instead of throwing. The catalogue was loaded with the same three
flags at three sites, where the roster had loadRoster and the paragraph
saying why.
lookupPipeline(catalogue, name) answers a pipeline or undefined, and throws
on a broken file of that name: absent is a thing a caller may act on, a file
that is right there and does not parse is not, and a caller that falls back on
undefined must never fall back past one. findPipeline is the lookup plus
“unknown name is an error, with what there is instead”. loadCatalogue is
loadRoster’s sibling. choosePipeline is one line; resolveTarget calls the
lookup and keeps its own question, which was only ever “pipeline or agent”;
listPipelines loads through the same door. One message, the library’s, for
every command: the run: and step: prefixes went, as the unknown-name
message had never carried one.
Stopping a run, and one subagent of it¶
A run already obeys a signal, and that is all it took to call the whole thing
off - as long as somebody held one. Two things were missing, and they are
different: a signal cannot single a branch out, because every subagent under
a workflow shares it and a combinator hands out no handles; and a command had
no signal at all, because pi’s ctx.signal is the agent run’s, which is
undefined while no turn is in flight. So esc during a subagent tool call
already stopped everything, and esc during /run stopped nothing at all.
stopSwitch (src/stop.ts) is the pair that fixes both: a signal to give the
workflow, and a spawn to give it too. The handles are registered on the way
out of that spawn, which is the only place they all pass - delegated children
included. liveRun builds one per run and hands the two to the call sites, so
the tool, /run, /build and /step are stoppable by the same act.
Stopping is a Subagent method, not a second signal. stop() aborts the
turn in flight and refuses every later one with "stopped". One-way, because
that is what a person pressing a key means, and distinct from close(): the
session is still there to be exported, and whoever opened it still closes it.
The label matters more than it looks - a deadline, a cancelled run and a person
call for different reactions, and before this they all read aborted.
Three ways in, because no single one reaches every case:
escstops everything, and is listened to rather than consumed. pi binds it toapp.interrupt; swallowing it would stop the subagents and leave the turn running, which hands the model a wall ofstoppedresults and every freedom to delegate again. Not consuming it means one key with one meaning: inside a turn the turn goes too, and during a command pi’s own handler finds nothing to abort and ours does the work.ctrl+↑↓select andctrl+delstops the selection. The list is already on screen, so it is the list a key moves through; the three are bound to nothing in pi and are read only while a run is live, throughctx.ui.onTerminalInput. Registeringescapeas an extension shortcut was the other option and is a trap: pi checks extension shortcuts before its own keybindings and swallows the key whatever the handler does, so it would break interrupt, autocomplete cancellation and clearing the editor for the whole session./stop [<id>|all]names one, which no key can. It is not enough on its own: pi executes an extension command immediately during a turn, but processes no submission at all while a slash command of its own is awaiting - measured, typing/stop allduring/runran nothing until the run was over. That is the whole reasonctrl+delexists.
Stopping one branch is not stopping the run. The branch comes back as a
failed Result and the workflow decides: a fanOut branch dies alone, a
pipeline step that fails ends the pipeline. Saying more than that in the
message would be a promise the extension is not in a position to keep.
A question card owns esc while it is up. The card had said “esc build
with what you have” since before a run could be stopped from the keyboard, and
the two meanings collided the first time somebody pressed it: measured on
/build, esc on the first card ended with interview failed: stopped. The
card resolved to a submit, the listener stopped every subagent of the run, and
the interviewer that had to write the brief was one of them. So whileAsking
in extension/commands/stop.ts holds the stop for the length of a question,
free-text box included, and escape falls through to pi as before. Held rather
than dropped, because the run’s subagents are idle while a question waits: there
is nothing running that the key would have been pressed to call off. The other
way round - making the card’s esc a cancel, so that both meanings agree - was
rejected, because it throws the answers away, and the reflex to escape out of a
dialog is exactly the moment those answers are worth keeping.
A flow’s card is the interview’s card, told more. createAskUi reads the
Asking a flow’s ask hands it, and an AskUser called without one draws the
interview’s card as before. What the card draws around the answer is
extension/ui/card.ts, the same for every form: the header and the visit, the
reads under their names, the question and the help line. What takes the answer
is a list or a text box, and ask.ts picks it from the form.
A flow’s text box is drawn in the card, not by pi’s
input. pi’s box shows a title and nothing else, so the reads the person is answering about would disappear the moment they choseOther…. In the card they stay above the question. pi’scustomtakes no signal, so the card calls its owndonewhenAsking.signalaborts, and pi puts its editor back as it does for any answer.escin the text box behindOther…goes back to the options. On the interview’s card it still submits, as it always did. On a flow’s card with no “enough”, declining is the run’s stop, and a key pressed to leave a text box is not a request to end the run. An empty answer goes back too: it is not a typed answer.The help line says what
escdoes on this card. It gives the label of “enough” when the card offers one, and “stop the run” when it does not. A card that said “esc cancel” while the key stopped the run would be the collision above, again.The card is laid out like pi’s own dialogs. The list sits one column in and blank lines separate the parts, where it used to be flush with the border and packed. When the help line does not fit, each key gets its own line: at 44 columns it had wrapped as
esc stop the/run. The interview’s card gets the same layout. Only its looks change, not what it does.
One floor under the commands¶
Five commands launch work - /build, /run, /step, /swarm, /interview -
and each had written the same shape by hand: choose between the double and the
real thing at every use (deps.runPipeline ?? runPipeline, thirty-two times in
ten files), wrap the pre-spawn checks in the same try/catch that turns a
thrown explanation into a refusal (six copies), then liveRun, a footer status,
and a try/finally that stops the view and writes usage.json (five copies,
one of them clearing a status the stop had already cleared). command.ts was
called the floor they stood on and held the parser, refuse and the roster; the
order things happen in was still every command’s own, and so was every way of
getting it slightly wrong.
The floor is three pieces now, each deep and composable, and deliberately not
one template function: the five commands have different shapes - three stops
for /build, an editor after the work for /interview - and a function taking
parse, target, work and report as parameters would be a configuration engine
with five callers. resolved(deps) applies the defaults once and hands back
doubles that are all present, treating a key holding undefined as unsaid for
the same reason override() does in pipeline-run.ts. checked(ctx, fn) runs
what must pass before a spawn and gives undefined back on a thrown
explanation, the shape every refusal already had. watched(ctx, deps, { status, dir, work }) puts the dots up, says what is running, hands the work its live
run, and takes everything down in a finally with the time this command
measured - one clock, which is also why a run’s usage.json now carries the
command’s wall time rather than the workflow’s own.
Two moves came with it. command.ts was mixing four concepts at 253 lines, so
what a command reaches for is deps.ts and what was typed is flags.ts;
BuildDeps is CommandDeps, since it never was about one command. And
pipelineVerifier left run-ui.ts: three callers, none of which paints.
checkModel stays in each command’s checked block rather than in watched:
/build has to refuse a mistyped model before the interview, and the interview
runs before anything is watched. What a command checks is its own; that it
checks before it spawns is the floor’s.
The contract of the floor is asserted once, in test/command.test.ts - a
thrown work still throws after the clean-up, the footer and the widget are
cleared whatever happened, usage.json lands in the folder given - and the two
commands that each proved the widget goes when the run ends lost those tests.
Their own tests say what each command decides.
The tool and the committer stand on it too¶
The floor was built for five commands and two launch sites stayed beside it.
The subagent tool opened its own liveRun, kept its own clock and wrote its
own finally, because it varies four things about the view - a second
reporter, herdrAll, the parent session’s file, a progress line streamed on
every change - and watched let none of them through. And /build’s committer
ran through deps.run with ctx.signal, which the section above says is
undefined during a command: the one subagent of a build that neither esc
nor /stop could reach, with no dots to say it was working. Its footer status
was set and cleared by hand around it.
Watched takes live, the slice of LiveRunOptions a caller may vary, and
status became optional because the tool has no footer to write to; who stands
on the floor is a Watcher, a ui and a signal, which pi’s command context
and the tool’s deps both are. The live run keeps the clock - elapsedMs(), and
stop(dir) no longer takes a wall time it was handed - so the tool reads the
run’s time off the view it ran under rather than keeping a second one. The
committer runs under watched with the run’s signal, spawn and onEvent,
which is what puts it within reach of /stop and on the widget.
The tool’s arguments were declared twice: a typebox Schema in index.ts for
pi, and a hand-kept Params in execute.ts for the code, each with its own
description of the same twenty-one fields. Schema lives in execute.ts and
Params is Static<typeof Schema>. Three of its fields are enums now -
mode, lifetime, scope - so what the code accepts is what the model is
told: asLifetime silently took "session", which the description never
named, and asScope took anything and fell back in silence; pi refuses what
the schema does not name, and the two validators are gone. What the model
sends is extension/params.ts, what the tool does stays execute.ts, and the
switch over the mode is perform(), apart from the wiring: the one file was
mixing three things at 345 lines.
The tool’s widget test went the way the commands’ did: the floor’s contract is asserted once, and the tool’s tests say what the tool decides. The committer has the test it was owed - the run’s signal, not pi’s.
The relay owns the entry a step leaves¶
/step and /swarm both end a stage the same way: a numbered subfolder of the
chain for the transcripts, the step recorded in the relay, an entry appended to
the transcript. Each had written the folder name and the entry by hand, and
/swarm imported STEP_ENTRY and its type from /step’s file to do it - one
command depending on another to know what a step of the chain looks like in the
session. The two folders were also named differently: /step after the id,
/swarm after the agent, so a second swarm of the same member got a folder that
did not say which step it was.
Both now belong to relay.ts, which already owned the chain: stepDir(relay, id) names the folder, STEP_ENTRY and entryOf(step) say what the transcript
gets, and recordStep takes the id the step ran under rather than minting one
afterwards, so the folder and the entry cannot disagree. The doors a command has
into the session - SendMessage, AppendEntry, and the PipelineDeps and
StepDeps that carry them - are in deps.ts with everything else a command
reaches for. What one command file still imports from another is the design
and not a leftover: /build opens with /interview’s function, and /quote
sends the message /run sends.
The relay owns the life of a step¶
Owning the vocabulary left the sequence to the commands, and /step and
/swarm still spelled it out alike, eight moves each: continue or start the
chain, mint the id, name the folder, run under the dots, refuse on failure,
record, append the entry, notify. The entry went through
injected.appendEntry?.(…) - the door read off the raw dependencies, where
resolved() never looked, so a caller that forgot it got a step recorded in
the relay and never drawn, in silence, the very failure resolved() guards
against for every other dependency. stepAnswer and pipelineAnswer were one
framing in two files, each justified by the same paragraph about pi’s
user-role slot.
beginStep(name, runDir) opens or continues the chain and names the id and
the folder before anything runs; finishStep(begun, outcome, appendEntry)
records under that id and appends the entry in the same call, so “recorded but
never drawn” cannot be written. What the commands keep is what each decides:
the flags, the target, the refusal’s wording, the notification. The doors are
required on PipelineDeps and StepDeps rather than resolved to a
default: a door that quietly did nothing would be the failure the type exists
to prevent, so pi’s is bound once in sessionDoors and a test hands a
recorder. framed(what, asked, output) is the one framing, in the relay, and
the pipeline’s answer is a one-line caller of it.
The mechanism is asserted once, in test/relay.test.ts: a begun step opens or
continues the chain, a finished one is recorded under the id it began with and
its entry goes through the door. The two commands’ tests keep saying what each
sends and what each draws, because that is the property they exist for.
pi comes in through one door, and nothing casts it on the way¶
Every command handler took pi’s ExtensionCommandContext and handed it on as unknown as CommandCtx, eleven times, and /stop did the same into a slice of
its own. Written when pi’s context and ours did not line up, the casts had
outlived that: an assignment compiles without them today. What they still did
was keep TypeScript from checking that pi’s context has what a command reads,
which is the one check a fake cannot make - and the five slices of pi’s UI the
extension read were declared in five files, notify three times over,
consistent with one another by luck rather than by construction.
extension/pi.ts is where pi comes in, and the only file under extension/
that names pi’s context types. Ui is the slice of ctx.ui the extension
reads, declared once; CommandCtx is what a command is handed; RunUi,
KeyUi, AskUi and StopCtx are picked from Ui, narrow so a test’s double
stays small, and never redeclared. PiApi is the slice of pi’s API the
extension registers through, and the test’s fake is typed as it rather than
cast as never, so a method pi renames fails at compile time. The two wirings
only the door knows - what the tool body is handed, mainSessionFile read off
pi’s session manager included, and how a command reaches the session - are two
functions, toolDeps and sessionDoors, and they are tested.
A handler is written against CommandCtx. pi hands it the whole context, and
TypeScript checks at every registerCommand and at the tool’s execute that
the whole has what the slice reads. That is the door, and it needs no adapter
function: an identity typed on both sides would be a pass-through, and the
check it would carry already happens at the assignment. The one cast left is in
the test that drives a registered handler with the fake, standing in for the
members pi has and the extension never reads.
The audit cycle is the audit’s¶
Removed: audit went with deliver; the audit is a node of the build
flow, inside its deliver loop. See The linear pipeline is removed.
auditOnce took ten fields and did three things: a pool, a prompt, a turn.
Everything that made a round of audit a cycle - asking, closing the round in
the record, deriving the fixes and attaching the standing check to each, running
them, settling the tree, deciding whether another round was worth paying for -
was fifty lines of deliver.ts, and twenty of deliver’s tests were about
those lines and nothing else in a delivery. The two pure helpers the cycle used,
fixesFrom and withCheck, were tested; the caller that had to call them in the
right order with the right check was not, on its own.
audit(options) in audit.ts is the cycle. It builds the review record with
what a previous run raised, spawns a fresh auditor per round whatever the caller
runs with, reads the verdict, derives and dresses the fixes, and applies the two
rules that end a cycle early: a yes with nothing owed and no failing check
standing, or a round that asked for nothing and closed nothing. What it does not
know is how a fix reaches the tree. It is handed fix(fixes), which runs them
and says what the check is afterwards, and deliver hands it the one that runs
a pair and settles the copies - which keeps everything about working copies in
deliver, where the next decision moves it. deliver reads as plan, work,
audit, settle.
The move surfaced a double count. A fix’s result was appended to tasks so the
next auditor would read it, and the delivery’s usage summed tasks and then
every round’s results again. A delivery with a fix reported the fix’s tokens
twice, and the one usage test had no fix in it. The total is planning, the tasks
as audited last - fixes included, once - and each round’s review.
auditOnce is not exported any more: one turn without a record or a stopping
rule is not an audit in this repository’s sense, and it had one caller.
Asking the user, and touching the world¶
Two ports, one rule: the agents produce text, our code performs the act.
src/ask.ts-AskUser, one question at a time. The pi implementation is a select card (extension/ui/ask.ts), an example uses readline, the tests use a scripted array. Returningundefinedis the submit, not a cancel: what was already answered still counts, and the brief is still written.escmaps to it for the same reason.src/verify.ts-Verify, a command we run withexecFileand no shell. Its output is evidence the agents read and cannot argue with.src/git/git.ts- the git a pipeline may do, as functions. There is nopush, noreset, norebase, no--force, and no shell: arguments are arrays and the commit message is piped togit commit -F -, so a message containingrm -rf /is committed rather than executed.
Why the committer has no bash. It was the obvious design - give the agent
git and tell it what not to do - and it is exactly what “a prompt is not a
permission boundary” forbids. The agent writes the message, which is what a
model is for; the branch and the commit are ours. Adding a subcommand is a
decision someone takes in a diff, not an argument a model produces at runtime.
Why the interactive flows are commands, not tools. Reversed: a
question card is shown during a model’s turn. pi’s own examples call
ctx.ui.custom() from a tool’s execute, and the subagent tool now puts a
flow’s ask cards through ctx.ui while the turn waits; a real pi showed
the card answered, and esc on it meaning the card’s own entry. See
/step and the subagent tool take
flows. What follows is the reason
there were none. A question card owns the terminal until it is answered, and
nobody can answer a question asked inside a model’s turn. /interview and /build are therefore pi.registerCommand, and
/build stops exactly twice: the brief before any work starts, the commit before
anything reaches history. A refusal at either stop leaves everything where it is
the brief in the editor, the work in the working tree. Nothing is undone on the user’s behalf. Both stops have since gone: see
/buildasks nothing.
/build asks nothing¶
Replaced: /build is /run build. The shipped build flow still asks
nothing, commits nothing and needs a git repository, now because its copies
need one and the run stage says so; the check is a script the file names,
.pi/checks/test.sh, and no longer a flag. See /run runs flows, and
/build is /run build.
This reverses a decision. /build stopped twice, on the brief and on the
commit, and asked for a check when the pipeline named none. Each question made
sense on its own. Together they meant a build could not run without somebody
in front of it, and a build is the work you most want to leave running.
The request is the brief. The interview is
/interview, a command of its own, and what it hands back is text to give/build. Opening every build with it questioned people who had typed a precise request, and would question nobody in a run left alone.Nothing is committed. The work stays in the working tree, and
git diffis the report. A commit is a decision about the work, taken by whoever read it.extension/commands/commit.tswent, and the commands’Gitport shrank toisRepository. The committer agent andsrc/git/git.tsstay in the library, where a script can still use them.The check is a flag or a file.
--check "npm test"for one run,verify:for every run of that pipeline, and the flag wins. With neither, no check runs. The flag is split on whitespace and on nothing else, which is not the small shell theverify:list exists to avoid: no quote,&&or$is interpreted, and every word is an argument. A double-quoted value is the one thing the flag parser learnt for it.Resuming says what it picked up. Typing
/build resumeis already the answer, so “Carry on?” became a line.A git repository is still required, for a new reason: nobody watches the run write, and git is how its work gets read and, if need be, undone.
/interviewparses its own flags. It borrowed/build’s parser for--questions, a flag/buildno longer has.
A check the auditor contradicts goes out with the fix¶
Removed with deliver: in the build flow the auditor reads the check’s
report among its reads:, and the round ends only on
tests.output.passed && audit.output.approved, a condition our code reads. See The linear pipeline is removed.
The auditor is handed the check’s command and its output, and it can still
write the opposite. Measured on a delivery that worked: four green tests, then
"Test file has a syntax error causing failure." and a fix raised for it, and a
round spent rewriting a file that was fine. Audit 2 approved. Every honest
signal was there and none of them stopped the round.
Three ways out, and the middle one is the one taken.
Drop the fix. It needs us to read the auditor’s prose for a claim about the check, and a false positive there throws away a real remark. Guessing at meaning is what the verdict tool exists to avoid.
Send the evidence with the work. Every audit fix carries one line saying the check passes on the tree it is about to change, so a worker sent after a failure that is not there settles it by reading instead of by rewriting. The remark still reaches the worker exactly as the auditor wrote it, which is what keeps a real fix from being lost to a heuristic.
Say nothing and pay the round. That is what the measurement cost, and the round is not the whole of it: the rewrite lands in the tree and the next audit reads a file nobody meant to change.
The audit prompt now states the passing case with the same force as the failing one, which was already there: a fix is asked for because the code is wrong, not because something fails. That sentence is not the mechanism, though. Invariant 7 holds here as everywhere - the prompt asks, and the evidence travelling with the fix is what a worker cannot talk itself out of.
A failing check is not attached. It is what the fix is for, and the suite says so the moment the worker runs it.
Resuming a build¶
Replaced: /run resume carries on a flow run from its journal, and
/build resume, build.json and deliver’s own resume are gone, removed
with the linear pipeline (see The linear pipeline is
removed). See A resume goes as deep as the
journal.
A delivery is long, it costs money and it writes to a working tree. deliver
therefore takes onProgress and resume, and
src/workflows/deliver/resume.ts turns the one into the other through
runs/<timestamp>/build.json.
Only what was approved survives. A subtask still being argued over left the tree in a state nobody signed off on, so it runs again. Approval is the only claim from a previous life worth trusting. Reversed for flows: every visit that ended survives, and a resume starts at the first that did not. The next review visit judges the tree anyway, so the sign-off still happens; see A resume goes as deep as the journal.
The plan is reused, never re-made. Re-planning would re-split work that is already half done on disk, and the plan was paid for.
Nothing of the conversation is saved. Agents are stored by name and resolved again;
Result.messagesare dropped. A resumed build re-reads the code rather than replaying a transcript - which is also what keeps the file small enough to write after every step.A state whose agents no longer exist is refused whole. Dropping the steps that no longer resolve would silently drop work.
The audit rounds already spent are spent. Resuming continues the cycle, it does not restart it.
onProgressis a reporting hook, so a listener that throws is swallowed - the same rule as the event bus.
The shape of a message is read where the pi API lives¶
Two readers in subagent.ts knew what a pi message looks like: one walked the
content parts of the last assistant message for its text, the other read its
stopReason and errorMessage for the failure a turn can end on without
throwing - the trap AGENTS.md lists. The event subscription beside them cast
a SessionEvent to reach assistantMessageEvent, toolName and args, though
session.ts declares that very union. The fake session then had to reproduce
the same shape for those readers to read. Three places knew pi’s shape; the
invariant names one.
lastTurn(messages) and streamed(event) are the two readings, in
session.ts: what the last turn said and how it ended, and what a streamed
event means to a listener - a piece of the answer, a tool being called, or
nothing. subagent.ts reads a text, an error and two kinds of event, and
holds no cast. The casts themselves did not go: the union keeps a { type: string } member so that pi’s own listener type satisfies the port, and that
member is what stops narrowing; they sit where the shape is known, with the
reason. The fake still builds pi-shaped messages, because it stands in for pi,
and the two readings are asserted against that shape once, in
test/session.test.ts.
The port itself did not grow a method. createDefaultSession hands back pi’s
AgentSession as it is, which satisfies the port structurally; a method of our
own would have meant a wrapper proxying every member for the sake of one.
The pi API: what you need to know¶
Which pi matters is the one the code runs inside, not the one in
node_modules. An extension is loaded into pi’s own process, so it resolves
pi’s own copy of the package. Homebrew ships 0.80.6; npm is on 0.80.10; and
those two disagree on the model API - 0.80.7 replaced AuthStorage +
ModelRegistry with a single ModelRuntime. A pi “patch” release can break the
API.
src/session.ts therefore supports both, choosing 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. See buildRegistry, which is exported precisely
so the choice is testable.
0.86 moved three more things, and the same rule answers all three. TUI was
the class pi-tui exported through 0.80.x; it is the interface now, and the
implementations are TuiMainScreen, drawing inline where TUI drew, and
TuiAltScreen. pane/tui.ts takes whichever is exported. ResourceLoader grew
getSystemPromptSource and getAppendSystemPromptSources, which only pi’s
interactive mode calls: a subagent never noticed, and nothing but the typecheck
did. And ToolExecutionComponent, handed no definition, stopped recognising
pi’s own tools by name, so the pane drew a box of JSON where it had drawn
grep /x/ in a.ts. That third one is not adapted but removed:
pane/renderers.ts names the definitions through create*ToolDefinition, which
both versions export, because a fallback that has moved once is not a thing to
lean on twice.
Standing still is not free either. 0.80.10 pins an undici and a
brace-expansion with advisories against them, and the newer pi is the only
place they are fixed, so a lockfile left alone is a lockfile that starts failing
npm audit.
This is the failure mode to remember: 158 tests were green while the extension
died on undefined.create() in a real pi, because every test injects a fake
SessionPort and none of them ever 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.
Local reference docs: node_modules/@earendil-works/pi-coding-agent/docs/
(read sdk.md, extensions.md, tui.md), examples in examples/sdk/ and
examples/extensions/ - in particular examples/extensions/subagent/, which we
take inspiration from but do not copy: it spawns one process per subagent
and therefore has no notion of lifetime.
Creating an isolated session (see src/session.ts, the only place the pi API
lives):
import { ModelRuntime, SessionManager, createAgentSession } from "@earendil-works/pi-coding-agent";
const modelRuntime = await ModelRuntime.create(); // replaces AuthStorage + ModelRegistry
const { session } = await createAgentSession({
cwd,
modelRuntime,
model: resolveCliModel({ cliProvider, cliModel, modelRuntime }).model,
tools: agent.tools, // ["read", "grep", "find", "ls"] …
resourceLoader: new StaticResourceLoader(agent.systemPrompt),
sessionManager: SessionManager.inMemory(cwd),
});
await session.prompt(task);
const messages = session.messages;
session.dispose(); // ← always, in a finally
Points to watch:
Built-in tools:
read,bash,edit,write,grep,find,ls(default:read,bash,edit,write).noTools: "all"disables everything.A read-only subagent is
tools: ["read", "grep", "find", "ls"]. That is the recommended default for exploration agents. The allowlist is genuinely enforced by pi - verified: a scout session exposes exactly those four tools. A weak model will still emit calls to tools it does not have (edit,run); they fail, and the model may retry them in a loop. That is an argument forloop’smaxIterations, not for loosening the allowlist.A prompt is not a permission boundary.
examples/04-loop.tsused to hand thecoderits fulledit/writetoolset and merely ask it not to change anything. It editedsrc/usage.tsanyway, twice, in a plain demo run. If a subagent must not write, takewriteandeditaway from it - do not ask it nicely. This applies with force to anything shipped in the repository: an example must not be able to rewrite the repository it ships in.The system prompt goes through the
resourceLoader, not through asystemPromptfield oncreateAgentSession. We supply our own (StaticResourceLoader):DefaultResourceLoaderrequirescwdandagentDir, re-reads the disk on every spawn, and loads extensions, skills and project trust - non-deterministic context a subagent does not need.session.prompt()does not accept anAbortSignal.PromptOptionsonly holdsexpandPromptTemplates,images,streamingBehavior,source,preflightResult. Cancellation goes throughsession.abort(): we bridge the signal by hand, and remove the listener after each turn.Messages are read from
session.messages;session.agent.state.messagesis an internal detail.A turn can fail without throwing: look at the last assistant message’s
stopReason("error","aborted", and"length"for the output limit).Not every provider reports tokens. Several return a
usagethat is already zero at the source;getSessionStats()then sums zeros. We display0, we never estimate it. And what a provider reports changes: one that reported nothing at all now reports tokens, so a provider’s counters are worth re-measuring rather than reading off a list.session.subscribe(…)is the source of everySubagentEvent:message_update/text_delta,tool_execution_start,turn_end,agent_end. Never log directly from the core.session.prompt()in series on one session is a persistent subagent. That is literally the whole implementation oflifetime: "workflow".session.compact()exists: it is the way out for a persistent agent whose context is swelling.Measurements and export are
AgentSessionmethods:getSessionStats(),getContextUsage(),exportToHtml(),exportToJsonl(). Call them beforedispose().session.dispose()releases the session; an undisposed session leaks.
Defining an agent¶
Markdown + frontmatter, compatible with pi’s convention
(~/.pi/agent/agents/*.md, .pi/agents/*.md):
---
name: reviewer
description: Reviews code and returns actionable remarks
tools: read, grep, find, ls
skills: diffing # our own extension: an allowlist, like tools
model: anthropic/claude-sonnet-5
lifetime: workflow # our own extension: default lifetime
---
You review the code produced and return at most 5 remarks…
nameanddescriptionare mandatory; a file without them is ignored silently (pi’s behaviour, we keep it).toolsandskillsare read the same way: a comma-separated line or a YAML sequence, and a field with nothing after it names nothing rather than something empty.lifetimeandopenInHerdrin the frontmatter are only defaults: an explicit call always wins. DeclaringopenInHerdr: trueon an agent is often what you want - a scout is worth watching whoever calls it.Project agents (
.pi/agents/) are repository-controlled content: loaded only on explicit request (scope: "project" | "both"), never by default. Do not relax that rule “to keep things simple”. This repository’s own demo agents live inagents/and are symlinked into.pi/agents/, so the extension can find them - with an explicit scope, like anyone else’s.An empty agent list is almost always a scope problem, not a typo, so
findAgentsays so. Given the old “Loaded agents: none”, a model concluded the repository had no agent definitions at all and started offering to write some. An error message is read by an LLM as often as by a human now; it has to name the real cause.Agents are rediscovered on every call (hot editing works).
Every subagent answers in the language it was given¶
Asked in French where the wall time is measured, /step scout answered in
English. No definition asks for English; they inherit it from the prompt around
them, which is English like everything written into this repository. The person
who asked then reads their own repository through a translation they never
asked for.
So the instruction is standing, in src/language.ts, appended to every system
prompt beside situate(). One place, and it reaches an agent a user wrote
without ever thinking about the question - which a line added to the nine
definitions here would not.
It is said twice, and the second time is where it lands. The system prompt
was enough for a single task and stopped being enough as soon as a combinator
framed one: a swarm tells each member which round it is, what is new on the
board and what is still free to take, and all of that arrives in the same
message as the goal, which a model weighs far above anything standing behind
it. So ask() closes every turn with the rule again, in one sentence - short,
because repeating three would spend a paragraph of context saying what one line
says. Measured on ilaas/gpt-oss-120b, a French question put to a swarm of two
over two rounds, counting the board posts that came back in French:
standing rule alone |
with the closing line |
|
|---|---|---|
the wording that disowned only the prompt |
3 of 18 |
9 of 35 |
the wording that disowns the framing too |
5 of 19 |
62 of 89 |
Neither half carries it, which is why neither can be dropped as a tidy-up. ask() is the one funnel every turn
of every workflow goes through, so a combinator cannot forget it and a workflow
somebody else writes gets it for free; the event stream still reports the task
its caller wrote, since a card drawing the line back would show a reader
something they did not write.
It points at the work, not at the prompt. The first wording said “the language of the task you are given”, and measured on a small open-weight model that loses the case worth having: a reviewer whose French goal is wrapped in English scaffolding (“Review this work.”, “It was asked to:”) answered in English, because most of the task really was English. Naming the material instead - the request, the specification, the report of another agent - and saying outright that these instructions never decide it, turned the same run French.
And it names no language. The wording that fixed the reviewer did it with an example: if the work you were handed is in French, answer in French. The next English question came back in French. In a standing instruction an example reads as the target, and this one is in front of the model on every turn of every agent, so the rule now says that no example in it decides the language either. Three turns settle it, and they are the check to re-run on the day the sentence is touched: an English task answers English, a French task answers French, and French work inside English scaffolding answers French.
What must not move is exempted by shape. A model writing French writes
PRÊT for READY and RAS for LGTM: a loop that never converges, a review
nobody can parse, and no error anywhere. The library could list its own
sentinels, but a workflow somebody else writes has its own, so the rule is
written as a shape: a word you were told to answer with, a JSON key, an agent
name, an identifier, a path, anything quoted from code. Measured, on the same
model: a French task asking for LGTM alone returns LGTM; the router answers
scout; the planner answers valid JSON whose task strings are French, which
is right, since they are the work the next agent reads.
The interview keeps its own per-turn reminder. It is the one prompt that can name its exemptions exactly, and it pays the most for losing them.
The model: an explicit knob at every level¶
For a long time the model was the one thing invariant 5 did not cover, and it
was measured: agents with no model: ran on the operator’s
~/.pi/agent/settings.json defaults (a model the caller had never named, 402s
inside a session started with a different provider, thinkingLevel: high nobody
asked for), while the caller’s --provider/--model never reached a
subagent. An experiment that pinned its repository with a tag while leaving
the model floating was measuring the operator.
The fix is one option, model, at every level, with the nearest override
winning - the same “an explicit call wins” rule as lifetime:
the run-time argument:
SpawnOptions.model,WorkflowOptions.model, the tool’smodelparam,--modelon/runand/step;the flow or pipeline file’s top-level
model:;the agent’s frontmatter
model:;pi’s own settings, as the last resort - only when nothing was set anywhere.
It is an override, not a default: the knob exists to run one workflow
against different LLMs, and a frontmatter model surviving a sweep would make
the experiment measure a mixture. Precedence is resolved once, in spawn(),
so an injected fake session observes the effective pattern.
What a shipped agent declares: nothing¶
None of the nine agents in agents/ carries a model:, and that is a decision
rather than an omission. A definition that ships in a package must not choose its
user’s provider: pinning one would override the settings they already made and
break outright for anyone holding no key for it. So a shipped agent falls through
to step 4, which is what makes pi -e extension work on a machine we know
nothing about.
The cost is real and worth stating, because it is invisible: a run with no
--model measures the operator. Verified on 2026-08-01 - /run explore with
nothing specified put all four subagents on a model named nowhere in this
repository, read from ~/.pi/agent/settings.json. An earlier run picked up a
thinkingLevel: high nobody asked for the same way.
Which is why the fix is a habit, not a file: anything whose numbers will be
compared names its model. experiment() takes models and refuses to guess,
--model exists on /run and /step, the examples take it in argv, and a
pipeline that belongs to one repository may pin its own. An agent you write for
your machine is welcome to declare one - it is only what ships that must not.
Three deliberate refusals, so nobody “fixes” them later:
No environment variable is read by the library, or anywhere else. An ambient variable is how this hole existed; the examples take
--modelin argv instead, and the oldCOMBO_MODELis gone.The parent session’s model is never inherited by a subagent, even though the extension can see it. A subagent floating with 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.
No per-step pipeline
model:until a real pipeline needs one: agent frontmatter already covers “this role runs on X”.
An unresolvable pattern still throws at spawn (a workflow on the wrong model
costs more than a lost run). The commands validate --model with
checkModel() before the interview, in the same early block as
checkPipelineAgents - a typo costs a second, not a conversation. Like
buildRegistry, checkModel touches the real pi module: only a run inside a
real pi proves it end to end.
Experiments: comparing models on the same work¶
The model knob only pays off when something uses it to compare. experiment is
that something: M models × N repetitions of one workflow, each cell in its own
directory with its own measurements, and one table at the end.
It is a function, not a combinator. It returns no Result and composes with
nothing, because it is a harness placed above a workflow - one that could be
nested inside a workflow would be measuring itself. Everything else here is
nestable on purpose; this one is deliberately not. It is also not a pipeline
kind: a pipeline is data a model must not be able to author, and a matrix over
models is code the operator writes.
A cell is handed a ready-made WorkflowOptions and the contract is to spread
it. That is what puts every subagent on the cell’s model, in the cell’s export
directory, and under the cell’s collector - a callback that rebuilds those by
hand silently measures something else. It is also why flows need no support
of their own: runFlow takes the same model, signal, timeoutMs, spawn
and onEvent, so run: (cell) => runFlow(checked, input, { ...cell.options, runDir: cell.dir }) is the whole integration, as runPipeline was before it.
Three rules the arithmetic depends on:
Sequential by default.
concurrencyis 1 unless asked otherwise: two cells racing for the same machine measure the contention, not the models.Sums are stored, means are displayed.
experiment.jsoncarries totals only; the mean wall and mean cost are derived when the table is rendered. Averaging averages is how a study starts lying about itself.A failed cell stays in the report, with its usage - a callback that threw included. It spent tokens before it broke, and dropping it would turn “two models out of three answered” into a clean comparison of the survivors. Same reason
loopreportsconvergedapart fromok.
Flag columns are the union of the outcome keys actually seen, so a study
comparing converged gets a converged column with nothing configured. error
never becomes one: a column of distinct sentences compares nothing.
A board, if there is to be one, is append-only and stamps its own names¶
Everything else here passes Results between subagents that never meet. A board
is the other arrangement, and it is worth having only if a run that used one can
be read back afterwards exactly as it happened. That is what fixes its shape
before anything is built on top of it.
Nothing is rewritten and nothing is deleted. A member can add to the record, and that is the whole of what it can do to it. The record is the only thing that says what happened, so a run able to edit it is a run that cannot be investigated.
from is stamped by the board, never carried in the draft. The caller says
who is posting. Draft has no such field, and a model that sends one anyway is
not believed - there is a test for exactly that, and it is the load-bearing one
of the file. Identity that can be claimed in a parameter is what makes a medium
one where any member can speak as any other, and no prompt repairs that
afterwards. The same rule as “only whoever raised an obligation may close it”.
All three caps have defaults: 200 posts, 2000 characters each, 50 per
member. Not because those numbers are right, but because a medium with no cap is
bounded by the deadline and nothing else, which is the same reason
maxIterations defaults to 5.
Three smaller things, each one a fork that could have gone the other way:
A refusal is
{ ok: false, error }, the shapeLedger.closealready returns, rather than a post-or-error union a caller can mistake for a post.tonames a member, not a topic. A board that cannot tell the two apart cannot refuse an address that reaches nobody, andkindalready carries what a topic would have. A board told no members accepts any address: refusing one it cannot check would be guessing.A reader is not handed its own posts. It wrote them, they are already in its context, and the point of the cursor is to spend as little of that as possible.
Reading is announced as loudly as posting, and that came out of running it
rather than out of designing it. Three members dividing one job posted their
claims 1.5, 3.1 and 3.5 seconds in, so the later two could have read the earlier
ones before choosing - and nothing in the record said whether they had. Posts
answer who said what. An investigation asks who knew what, knowing comes from
being handed something, so being handed something is an event: read carries
the ids delivered and how many were left waiting. The empty read is recorded
too, being the only thing that settles what a member could not have known.
The medium announces, whoever acts on it¶
The tool announced. A member’s post, its read and its take went onto the bus
from board-tool.ts, and the decision above said reading was announced as
loudly as posting. The swarm, meanwhile, read the board itself at the top of
every round to hand each member what it had not seen, and took back what a
member that had stopped was holding, and neither act was on the bus: the
handout that fed every round was in nobody’s record, and the console, the board
pane and events.jsonl showed the half of the traffic that went through a
tool. The code was behind the decision, not against it.
src/board/announced.ts wraps the medium: announcedBoard and
announcedClaims return the same Board and the same Claims with every act
on the bus - an
accepted post, every read with the ids it handed and how many it left waiting,
every grant and refusal, and one release per key for a member that is gone. The
swarm wraps what it was given or what it made, once, and hands the wrapped
medium to the tool and to itself. The tool announces nothing any more.
Wrapped at the swarm rather than built in: a caller hands the swarm a board to
read afterwards, and /swarm --claim builds its claims with no bus in reach.
A bus baked into createBoard would have left both silent. board.ts and
claims.ts stay the pure data their headers promise.
Paging moved into the board with it. since(reader, cursor, limit) hands back
a page, says how many wait, and moves the cursor past what it handed and never
past what it merely looked at - which the tool used to do on its own after the
fact. A read event has to say what was handed over, and only the one that
decides the page can say it.
A member has one cursor, whoever hands it posts¶
The swarm hands each member what is new at the top of its turn, and the
member’s read hands it what is new since its last read. Each kept its own
cursor, so a read after the handout gave the same posts back. In a real pi
(/swarm --agent debater, qwen-3.6-35b-instruct), a member handed one post
read the board at once and got that post again. In round four, a read
returned posts from rounds two and three. Weak models read on nearly every
turn, so they paid for nearly every post twice, against what the board header
and the debater’s definition promise.
createReader(board, id) now holds the member’s one cursor. The swarm makes one
per member and gives the same reader to the handout and to the member’s tool.
A tool built with no reader keeps its own, as before, because outside a swarm
nobody else hands that member anything.
One result a turn, when a turn is a vote¶
16-debate.ts on gemma-4-31b, twice. Both times one member, in the first round
and while the other two were still working, read an empty board, posted its
vote, read again, and did that seven times, then eleven, in one turn. Each post
was worded a little differently, so the refusal of an exact repeat did not
catch any of them. The next round the other two were handed nine and eleven
posts, most of them that one vote, and both came round to it. A weak model
counts heads, and the flood gave it heads to count.
SwarmOptions.resultsPerTurn caps the results one member posts in one turn.
turnBoard wraps the board the member’s tool posts to, and the swarm starts the
count over before each ask. The refusal tells the member to end its turn: what
the others post reaches it at the top of the next one. The task says the same
thing from the first round on, so there is no reason to wait.
The cap is an option, not a default. 15-swarm.ts posts one result per file
described, several in a turn when a member holds several files, and a cap of
one would refuse work that is right. /swarm --until agree sets it to 1, and
so does the debate: there a result is a vote, and the same vote again is not an
argument. A tell is never capped, because that is how a member thinks aloud
without answering anyone. The board’s own caps still bound it.
latestVotes reads only results now, the kind VOTE_INSTRUCTION asks for. A
tell answering somebody’s vote quotes it on its first line often enough, and
it would have counted as the quoter’s own. A cap on results would also be
worthless if a vote could still arrive as a tell.
A member’s failed turn is asked again once¶
A member whose turn failed dropped out of the swarm for good. /swarm --agent debater --until agree on qwen-3.6-35b-instruct: one debater’s first turn was cut
by the output limit, in a loop repeating one phrase. It dropped out holding no
vote, so agreed(3) could not fire, and the three rounds left ran for nothing.
A failed turn is now asked again the next round, and two failed turns in a row take the member out. The shipped flows follow the same rule: every agent node is asked at most twice. Asked again in the same round, the member would see the same board it failed on. The next round hands it what the others said since, and the round cap still bounds the whole.
An answer names the post it answers, in public¶
Debaters on gemma-4-31b wrote To p2: and To the Rust advocate (p2): at the
top of their tells. They were answering an argument, not writing to a person,
and the board had no way to say so. to would have been worse: it is mail, and
the third debater would never have seen the exchange.
A draft now takes re, the id of the post it answers. The board refuses an id
that is not on it, as it refuses a to that reaches nobody, and records the
answered post’s author beside the id. It stamps that name, like from, so the
answer’s author never supplies it. An answer is read by everybody.
boardLines(posts, reader) puts what answers the reader first, under
Answering you:: handed ten posts, a weak model reads the first few, and those
should be the ones that are its business. The handout and the tool’s read both
use it.
re is optional. A post that answers nobody is how a member thinks aloud, and
a debate needs that as much as it needs replies. The refusal of an exact repeat
counts re, so the same “I disagree” under two posts is two answers.
A member does not post the same thing twice¶
/swarm --agent debater --until agree in a real pi, on
qwen-3.6-35b-instruct: one member posted the same vote, word for word, six
times in a single turn. Its definition says to post one result a turn, and a
prompt is not a boundary. Every copy took a slot on each reader’s page and told
nobody anything.
So the board refuses a post whose kind, reader and trimmed text match one the same member already made, and the refusal names the first one’s id. Only an exact match is refused. Deciding that two phrasings say the same thing is a judgement, and the board does not make judgements. The same words from another member, under another kind or to another reader are a different post: a vote repeated by a second member is the agreement a debate is looking for.
A claim is granted, never announced¶
A board lets a member say what it is taking, and that turned out not to be enough in the first run that recorded reading. Three members read within 123ms of each other, each was handed nothing because nobody had posted yet, and all three then claimed the same file. Announcing into a medium that was empty when you looked is a race, and no prompt repairs a race.
So claims.ts arbitrates: first to ask holds it, and everyone else is refused
and told who holds it. The refusal is the useful half: “src/parser.ts is held
by scout#3 - ask scout#3, or take something else” turns contention into somebody
to talk to, where a silent loss turns it into two members doing one job.
The keys are a list, not free text, and that is the part the same run
argued for: one file came back as console.ts, src/reporters/console.ts and
I will handle src/reporters/console.ts. A lease keyed on what a model writes
would have granted all three and arbitrated nothing. A caller that knows what
there is to claim passes the list, and a key that is not on it is refused with
the list - the discipline delegateTool already applies to an agent name. A
caller that cannot enumerate the work passes nothing and gets the weaker
behaviour, which is honest rather than convenient.
How much one member may hold is a knob, and the number came from a run. With nothing bounding it, one member of three took all six keys and the other two spent their turns being refused. Three arms, two repetitions each, same model, same goal:
most held by one member |
grants |
mean wall |
↑input |
|
|---|---|---|---|---|
nothing |
3, 4 |
10.5 |
41.2s |
394k |
a rule in the prompt |
4, 4 |
11.0 |
43.2s |
434k |
|
1, 1 |
14.5 |
53.3s |
646k |
The middle row is the one worth keeping. “Take one thing at a time, and release it before you take another”, written into the member’s own definition, changed nothing at all: four held at once, exactly as with no rule. That is invariant 7 in its own words - a prompt is not a permission boundary - measured for holdings rather than for tools.
The bound works and the work still goes round: more grants, not fewer, because members cycle through take, do, release instead of sitting on everything. It is not the default because it is not free: 29% slower and 64% heavier in input tokens, since taking, releasing and being refused are all calls. A caller with no contention should not pay for it.
Two smaller ones:
Taking what you already hold is granted. It is not contention, and a member told “you cannot have it, you have it” learns nothing from being refused.
releaseAll(member)returns the keys it let go, because a member that dies holding claims leaves work nobody will do and nobody can take. The run says which ones rather than leaving a reader to notice the gap.
The same three members, the same job, with take on the tool:
+693ms member#3 take console.ts -> granted
+1063ms member#2 take console.ts -> refused (member#3)
+1675ms member#2 take herdr.ts -> granted
+3081ms member#1 take console.ts -> refused (member#3)
+3711ms member#1 take record.ts -> granted
All three went for the same file again, which is the point: the models did not change, the medium did. Both refusals were absorbed on the next call, inside the same turn, and three different files were described in half the wall time of the run that collided. Nobody posted and nobody read: arbitration made the announcing half unnecessary for this job, which is worth knowing before anything is built on the assumption that members talk.
A swarm is built to be compared against not having one¶
Every other combinator decides who does what. A swarm decides none of it, which
makes it the one whose value is an open question rather than a design. So it is
built to lose honestly: with no board, no claims and one round, swarm is
fanOut. That degenerate case is a test, and it is the control arm of any
experiment run with it - the board’s worth is the difference between the two, on
the job you actually have, and not an argument.
Three things are decided in the combinator rather than left to a prompt, each one from a run that happened before it was written:
Members remember.
lifetimedefaults to"workflow"here and nowhere else. A member that forgets the round before cannot build on what it saw, and a swarm of amnesiacs is a fan-out that costs more."task"with more than one round is refused outright: a member’s id is its name on the board, and a task-lifetime member gets a new one every round, so from the second nobody would be talking to who they think they are.What is new is handed over, not fetched. Measured: once something arbitrated, members stopped reading the board entirely - they take, they are refused, they take something else. Charging them a call to learn what the workflow already knows is charging them for its bookkeeping.
A member that is gone holds nothing. Measured: two of three members never released what they took and were still holding after they closed. Claims left by a member that is gone are work nobody will do and nobody can take, so the swarm releases them and the result says which.
stoppedBy is separate from ok and from converged, for the reason loop
separates them: a swarm that ran out of rounds did not succeed, it stopped.
A swarm reaches pi as a step of the chain¶
/run leaves its answer in the conversation, because an exploration is read and
then asked about. A swarm’s answer is not one report: it is what every member
said plus the board they said it on, and in a real run that is a screenful. Put
in the conversation it would be an orchestrator’s brief that nobody asked for,
so /swarm does what /step does - recorded in the relay, drawn in the
transcript, out of the model’s context until /quote.
That reuse is the whole design of the command. The relay gained a third kind
and nothing else: /chain lists a swarm, /quote brings it in, /step --from
carries it on, and the entry renderer already knew how to draw a step.
--claim also gives the run something to be finished by. With things named,
the swarm stops once each of them has been reported on, rather than spending
every round it was allowed. Measured in a real pi, three members and three
files: 3 rounds and 9 turns with the cap alone, 1 round and 3 turns with the
condition, and three of the first run’s six posts described a file another
member had already described. A round cap bounds the worst case; it is not a
plan. With nothing named there is nothing to be done with, and the rounds are
all there is.
--until agree is the other way to be finished, for the jobs that do not
split. Coverage is a test on work: every named thing reported on. A question
put to three members has no named things, so the only end it has is the three of
them saying one thing. The condition reads the roster rather than whoever spoke,
because two of three agreeing is not agreement, and a member that dropped out
therefore never lets it fire - the run spends its rounds and says so.
The vote is a line, VOTE: <answer>, and the sentence asking for it is appended
to the goal by the command. Two alternatives were weighed. A JSON shape would be
parsed more exactly and comes back fenced or explained from a small model, where
a line is what one writes correctly on its first turn. Putting the request in an
agent definition instead would have every other run of that agent posting a vote
nobody counts, and would miss any agent a user writes. So the instruction lives
beside the parser that reads it, in src/board/agreement.ts: told in one file
and read in another, the two drift the first time either is edited.
Measured in a real pi, three members on one question: 3 rounds and 9 turns with
the cap alone, 1 round and 3 turns with the condition. What they agreed on is
the part worth reading - all three read the repository they stood in and voted
for its language, which is agreement about a fact rather than an argument
anybody won. Copies of one model share its opinion, so a swarm asked to debate
has to be handed its disagreement, and --claim is what hands it out: the camps
are leased one owner at a time, and the vote stays free every round.
An agent that does not name board is run anyway, with a word saying its
copies cannot reach each other. That is a fan-out, which is precisely the arm a
swarm has to be compared against, and refusing it would remove the control from
the one place someone can try it in a second.
A directory is a module, and its index is the door¶
src/ had thirty-nine files at its root, and the ones that formed a module
together said so only in their headers: scratch.ts names worktree.ts,
board-tool.ts sends the reader to announced.ts, settle.ts says it is
“what deliver does with” land.ts. A reader who wanted one concept opened
five files, in the order the headers pointed, out of a flat listing that gave
no hint which five.
The rule: files whose headers name each other sit in one directory, and its
index.ts is the only file anything outside imports. What the index lists is
the module’s interface; what it does not list is implementation, and the
directory is what makes that distinction visible in a listing rather than in
prose. Tests are the one exception, and a deliberate one: they reach past every
door, so a helper exported for a test never has to appear on an index.
Two things about the generated reference made this cheap. npm run docs follows
export … from transitively and names each page after the declaring file,
so re-exporting a directory’s index from src/index.ts changes nothing on the
public surface and moves the module’s pages under its directory. And the
reference’s toctree is generated, so a page that moves takes its navigation
with it; only the hand-written links in docs/guide/ have to follow. What a
move cannot lean on is a test: two constants compute a path from their own
depth, PACKAGE_ROOT in builtin.ts and the pane’s entry in
reporters/herdr.ts, and nothing offline reads either. That is why
builtin.ts stays at the root whatever moves around it.
The delivery owns its git policy and its saved state¶
Removed: workflows/deliver/ went whole with the linear pipeline. See
The linear pipeline is removed.
The first module to become a directory was the one whose two files pointed the
wrong way. resume.ts sat at the root and imported four files from
workflows/ - audit, deliver, pair, plan - while none imported it back:
every arrow left the root and went down. settle.ts sat in workflows/ and
was not a workflow by this repository’s own definition - it takes { cwd, worktree, writers, verify }, no spawn, no lifetime, no signal - and its
header said whose it was: “this is what deliver does with it”. They are also,
with pair.ts and audit.ts, the most edited files under src/ after the
barrel and subagent.ts, and deliver and pair change together in most of
those commits.
src/workflows/deliver/ now holds the combinator, its two stages, settle.ts
and resume.ts. Its index lists what the barrel and pipeline-run.ts reach:
deliver, pair, audit, the saved-build functions, and the two settle
types a DeliverOptions names. settling itself is not on it; the delivery is
its one caller. plan.ts stays in workflows/, because orchestrate plans
too.
The extension: commands, the terminal, and the floor¶
extension/ had twenty-two files at one level, and the suffix was doing the
directory’s job: six ended in -command, two in -ui, and build.ts,
stop.ts, commit.ts and stage.ts carried nothing, so a reader could not
tell a command from what it stood on without opening it. src/stop.ts and
extension/stop.ts were unrelated files with one name, told apart only by
their tests being called stop.test and stop-ui.test.
The split follows what a file does with pi. commands/ holds every file that
registers a slash command, plus the two helpers only one command uses -
commit.ts for /build, stage.ts for /step. ui/ holds what paints or
reads the terminal without registering anything: the live view, the question
card, and the two module-level switches a card and a key share. The root keeps
the floor - pi.ts, deps.ts, command.ts, flags.ts, params.ts,
relay.ts - with index.ts and execute.ts, because the tool is what the
extension is and the floor is what every command stands on. The suffixes
went with the move: the path now says what the name used to.
Two type-only cycles between the floor and its leaves went at the same time,
because a directory makes an arrow’s direction visible and both pointed up.
pi.ts imported ExecuteDeps from the tool body to type what it handed the
tool; ToolDeps now lives in pi.ts, as the slice of pi’s context the tool
reads, and execute.ts widens it with what a test may replace. deps.ts and
relay.ts each held one half of AppendEntry; the relay owns the entry a step
leaves, so it owns the door it leaves it through as well.
One arrow still crosses from ui/ into commands/: the live view tells /stop
which run is on screen. That is the sign that stop.ts holds a key and a
command at once, and it is left in view rather than papered over.
The board is one module with one caller¶
Five files at the root of src/ - board.ts, claims.ts, announced.ts,
board-tool.ts, agreement.ts - had one caller inside the library,
workflows/swarm.ts, which imported nine names from four of them. Their headers
already read as one text: claims.ts calls itself “pure data, like ledger.ts
next door”, board-tool.ts sends the reader to announced.ts for the bus, and
following “a member claims a file” meant four files in the order the headers
pointed. agreement.ts is a policy over a Board, the way settle.ts is a
policy over land, and its one caller is the /swarm command.
src/board/ now holds the five, board-tool.ts renamed tool.ts since the
directory says whose. Its index lists what swarm builds and hands out, and
the vote the command reads; latestVotes stays behind it, counted only by the
agreement. The one arrow into the module from the core is events.ts naming
Post, because a board’s traffic is an event like any other; a directory makes
that arrow readable where a flat listing hid it.
Running a pipeline is not a combinator¶
Removed: src/pipeline/ went with the linear format. The reason it was
not under workflows/ holds for src/flow/run/. See The linear pipeline is removed.
pipeline-run.ts sat in workflows/ and was the one file there that depended
on every neighbour: it imported the eight combinators and dispatched on a
step’s kind, returned a PipelineRunResult rather than a Result, and
composed with nothing. Every other file in the directory depends on
options.ts and pool.ts and on nothing sideways. Its own header said what it
was: “our code walks the steps”. Meanwhile pipeline.ts and pipeline-load.ts
sat at the root, two of the three stages of one thing, in a listing that put
ledger.ts between them.
src/pipeline/ now holds the three in the order they are used - pipeline.ts
reads the file, load.ts finds it, run.ts walks it - and workflows/ holds
only combinators again. builtin.ts stays at the root on purpose: its
PACKAGE_ROOT is two dirnames up from its own file, so a move would point
the shipped agents/ and pipelines/ at src/ and nothing offline would
notice; the layout block now says what it is for, so the next reader does not
try.
The working copy is a stack, and reads as one¶
git-run.ts, git.ts, worktree.ts, scratch.ts and land.ts were a stack
that said so in every header - “git.ts covers the repository; this covers the
copies of it”, “worktree.ts holds the primitives; this holds the one shape
every caller wants” - and sat at the root of src/ with ledger.ts and
language.ts between them. src/git/ now holds the five, git-run.ts
renamed run.ts, and its index lists what leaves the directory: the git a
pipeline may do, the landing, the scratch copy pair opens, and the worktree
primitives. run.ts is not on it; running git is the how, and everything
outside asks what.
Two things did not move, each for a reason worth keeping. verify.ts is a
port, beside ask.ts - its header says so - and seven files depend on it that
have nothing to do with git; putting a port under a mechanism would read the
dependency backwards. And the worktree primitives stay on the public surface
although only scratch.ts calls them inside the library, because the
worktree guide teaches them in its first code block; the rule for the surface
is what somebody outside calls, and a guide is somebody outside.
The review record’s door was already in the barrel¶
review.ts said in its header that it was “the one place that joins” the
verdict and the ledger, and the code agreed: verdictTool, declaresVerdict,
createLedger and openList had no caller but review.ts, and the barrel
exported the types of all three files and the function of one. The interface
was visible everywhere except in the listing, where the three sat among
thirty-nine files with resume.ts and run.ts between them.
src/review/ now holds the three, and its index lists what the barrel already
did: reviewRecord and the types a result names. Its three callers are the
delivery - pair, audit, and resume for the types - which is why it could
have gone under workflows/deliver/; it did not, because a verdict as a tool
call is one of the three tools combo hands a subagent, beside the board’s and
the delegation’s, and that family reads better from the root. tool.ts, the
words those three share, stays at the root for the same reason.
Measuring is a chain, and usage.ts is not a link of it¶
export.ts, measured.ts, experiment.ts and experiment-report.ts were one
chain - a run directory, a run that measures itself, the same run over M models
and N times, the matrix read back - split across the root of src/ by
alphabetical accident. src/measure/ now holds the four, experiment-report.ts
renamed report.ts, and its index lists what the extension’s live view, the
examples and spawn reach. The chain had one back-edge, experiment.ts
importing its report and the report importing ExperimentOutcome back; the
outcome now lives with its reader, and the module reads in one direction.
usage.ts is not in it, although the word suggests so. It is what Result
and every event carry, imported by fifteen files including result.ts itself;
a measure/usage.ts would have the core reaching into a feature directory for
its own currency. The one arrow that does run from the core into this module,
spawn calling exportSession, is the honest one: a subagent’s transcript is
exported where it is still in hand.
The public surface: one entry point, grouped as it is learnt¶
src/index.ts is the only door - the examples and the extension import from it,
never from a file inside src/. What changed is that it is now grouped the way
the library is learnt rather than alphabetically: start here (an agent, a run, a
result), the combinators, watching a run, measuring a run, flows, pipelines, the ports
that touch the world, the pi session. A reader who needs a dozen symbols finds
them in the first section instead of scanning ninety.
Two rules decide whether a symbol belongs on that list at all:
A type named by a public option or return value is public.
HerdrSendisHerdrOptions.send,HerdrEnvis whatdetectHerdrreturns,CreateSessionisSpawnOptions.createSession- remove any of them and the option cannot be written from outside. This is why the option and result types of every combinator stay, even though nothing in this repository names them.Test-only is a reason to stay off it. Tests reach into
src/directly, so a helper exported for one is not part of the surface. Eleven symbols left on that basis: the package’s own directory constants (PACKAGE_ROOT,BUILTIN_AGENTS_DIR,BUILTIN_PIPELINES_DIR), the herdr transport underdetectHerdr(createHerdrSend,HERDR_SOURCE), the leaf formatterswidgetRowsalready composes (currentActivity,detailLine,elapsedMs,widgetLines) and the id counters (nextSubagentId,resetSubagentIds).
Both rules are stated at the top of the file, because the next person adding an export will read that before they read this.
A value is public when somebody outside calls it¶
The two rules decided types and test helpers, and said nothing about a value nobody called. The file was touched in twelve of twenty-five commits, every time to add, and of 168 exported values 75 were referenced by nothing in the extension, the examples or the scripts: every prompt builder, the word constants, the four working-copy layers, the record and the ledger, the board and its announcing wrappers, the mirror, the event bus, the pipeline directory constants. The list had become a measurement of feature count. A reader learning the surface learnt a third of it for nothing, and the generated reference documented it.
The third rule: a value is public when somebody outside src/ calls it -
the extension, an example, a guide’s code block, or the TSDoc of a public option
that names it as the default a caller may wrap (formatBranches for
ReduceOptions.format, pickDestination for RouteOptions.parse). Two more
were kept on the same footing although the rule as computed missed them: the
guide for writing a combinator tells its reader to open a Trail and to compose
offers with offerBoth, and the file’s own comment names createDefaultSession
as what a caller wraps to reach pi’s session. succeeded stays as the pendant
of failed.
Forty-seven values left: announcedBoard, announcedClaims, boardTool,
BOARD_TOOL, verdictTool, declaresVerdict, VERDICT_TOOL, SUBAGENT_TOOL,
reviewRecord, createLedger, openList, latestVotes, scriptedAsk,
busFor, createEventBus, experimentReport, writeExperimentReport,
copyMainSession, exportSession, applyPatch, currentBranch, headSha,
landable, scratchWorktree, settling, answerInTheirLanguage,
inTheLanguageOfTheWork, ANSWER_IN_THEIR_LANGUAGE, mirrorSocket,
registerMirror, REFUSED_IDLE, loadPipelinesFromDir, PIPELINES_DIR,
STEP_KINDS, abortError, loadBuildState, BUILD_STATE_FILE, skillDirs,
accumulate, snapshotUsage, AUDIT_APPROVAL, auditPrompt, answerPrompt,
briefPrompt, questionPrompt, remarksPrompt, makePlan. Every one is still
exported by its own file and reached by its tests there; what changed is what a
reader is told they may call. The one reference page whose module kept no public
symbol, announced, went with it, and no guide linked to it. The day a script
needs one of these back, the rule says how: show it in a guide, or call it from
an example.
latestVotes came back that way. examples/16-debate.ts prints each member’s
last vote, and it had been written with a parser of its own that stripped
decoration only after the answer: VOTE: **Rust** read as **rust, so it and
VOTE: Rust were two votes, and a debate could spend its rounds on an agreement
it had already reached. The example calls agreed and latestVotes now, and
the board’s door lists the second.
A default is written after the spread, never before¶
pair and interview default their lifetime to "workflow" - two agents in a
conversation keep their memory unless the caller says otherwise. That default
used to be written as { lifetime: "workflow", ...options }, which is correct
for a caller that types its options by hand and wrong for every caller that
builds them by merging.
runPipeline is such a caller. It laid a step’s overrides on top of the run’s
options with lifetime: step.lifetime ?? workflow.lifetime, and when neither
was set the key still existed, holding undefined. Spread over the default,
that undefined won: the same deliver, run from code, gave its pair one
worker and one reviewer for the whole conversation; run from a pipeline file, it
gave them a fresh pair every round. A reviewer that never remembers its own
remarks is not a detail, and nothing in the output said so - the run simply cost
several times more turns.
Two rules came out of it, and both are now tests. A default belongs after the
spread, as options.x ?? default, so that an explicit undefined reads as
“nobody set this” rather than as a choice. And a merge must not invent keys:
override() in pipeline-run.ts copies an override only when it is defined,
because { lifetime: undefined } and {} are the same intent and must become
the same object.
The general form is worth stating, since the next merging caller will be a new
extension command: in this codebase, absent and undefined mean the same thing,
and any code that turns the first into the second is a bug even when the types
allow it.
The pages, split into a guide and a reference¶
Eleven hand-written pages sat in one flat directory next to the generated
docs/api/. They are now docs/guide/ - task by task, in the order the library
is learnt - and docs/reference/, holding the generated API beside the list of
examples. index.md, development.md and decisions.md stay at the root: they
are the way in and the two pages about the repository rather than about using it.
The split is the question a reader arrives with. How do I make two agents argue
until they agree and what does fanOut take are different questions, and a
flat directory answered neither first - it offered fourteen file names, sorted
alphabetically, of which the second was build.md and the third decisions.md.
scripts/api-docs.ts computes its links out of DOCS_DIR instead of spelling
them, so the two that leave the generated tree - the design decisions and the
README - follow the next move on their own. That was the actual cost of this one:
the pages moved with git mv in a second, and the links took the afternoon.
The documentation, as a site¶
The pages were always Markdown in docs/, read on GitHub and in the published
tarball. They are now also a Sphinx site - MyST Markdown, the furo theme, built
by make -C docs html with -W, so a warning fails the build exactly as a
failing test does.
Sphinx over the same files, not a second copy. Nothing was written twice: the site renders the pages that were already there, and the only Sphinx-specific syntax in them is the toctrees and the cards on the landing page. A page that reads well in a repository and badly on a site is a page with two audiences and one author; this way there is one file per subject, whatever is reading it.
Python is a documentation dependency, and says so. It lives in
docs/requirements.txt, never in package.json. npm test and npm run typecheck do not reach the directory, and the library still depends on the pi
SDK alone - which is the rule that made this worth stating rather than assuming.
guide/ and reference/. Task-oriented pages moved under guide/, and the
generated API under reference/api/ beside reference/examples.md. The split is
the question a reader arrives with: how do I do this has a different shape from
what does this export do, and eleven pages in a flat directory answered neither
first. scripts/api-docs.ts computes the links out of DOCS_DIR rather than
spelling them, so the next move is one constant.
The toctrees are the navigation, and the only one. docs.json listed every
page for test/docs.test.ts to check reachability; Sphinx needs the same list as
toctree entries, and two lists of the same pages disagree the day someone edits
one. The JSON went, and scripts/doc-links.ts reads the toctrees instead - so
the offline suite still fails in seconds on a page nobody can reach, and it fails
on the list the site actually uses.
What -W caught on the first build, and neither the suite nor a reader
would have: ninety-three code blocks that failed to highlight, because { … }
is not TypeScript a lexer accepts, and a dead link at the top of every generated
page. The first is why a signature now elides with { /* … */ } - a comment, so
the block stays lexable - and why a long initialiser keeps its last line: the
bracket it closes. The second is why “Source:” is an absolute URL into the
repository rather than ../../../src/<module>.ts: that path resolves in a
checkout and in the tarball, and is dead on a site that publishes docs/ alone.
It is read from package.json, so the repository is named once.
The site is built in CI, and published from main alone.
.github/workflows/docs.yml is the first workflow this repository has had, and
it exists because -W is only a standard if something enforces it: a build that
runs on one laptop is a build that breaks quietly. Every pull request builds the
site; only main deploys it to GitHub Pages. The workflow also regenerates
docs/reference/api/ and diffs the result, because the site publishes what is
committed - and a reference that no longer matches the source is exactly the
failure the generator was written to prevent, arriving by a different door.
The type¶
Three faces, one job each: EB Garamond for what is read, Inter for what is navigated - headings, sidebar, tables, cards - and the reader’s own monospace for what is typed. The third is not shipped on purpose: code is read in the face someone has already chosen for code, and a page that overrides it is arguing about the wrong thing.
The other two are shipped, and that is the decision. A font CDN would tell a
third party who reads this documentation and would leave every page waiting on a
host nobody here controls; system stacks cost nothing and give a different page on
every machine. So the files live in the repository, cut down to the characters
these pages use by scripts/subset-fonts.py, from a pinned commit of
google/fonts - never main, which moves - with the OFL text beside them, as
that licence requires.
Static instances, not variable. Measured, subset the same way: variable was 480 kB for three files, static 272 kB for five. A variable font pays for every weight between 400 and 800 whether or not a stylesheet asks for one, and this one asks for four weights in total.
A subset is a silent failure waiting to happen. A character no shipped face
carries is drawn from whatever the reader has installed - different weight,
different baseline, and visible to them alone. So the script writes
coverage.json from the cmap of the files it actually produced, and
test/fonts.test.ts fails on a page whose prose needs more. The serif stack also
names Inter before any system face, so a character only one of the two carries
still lands in a face this site ships.
Two details the faces themselves forced. EB Garamond is a sixteenth-century
design with a small x-height, so article sets its own size rather than furo’s -
16px of it reads a size smaller than 16px of anything drawn for a screen, and
raising the root size would have shrunk nothing but grown the entire chrome. And
its figures are old-style, which is right in a sentence and wrong in a column, so
tables ask for lining and tabular ones.
The mark¶
combo had no drawing of its own. It has one now: three bars, one bracket -
the bars identical and in the ink, because a fan-out has no favourite branch,
and the bracket in verdigris, because holding the three as one is the claim of
the library. docs/_static/logo/README.md holds the palette, the file table and
the two rules that are easy to get wrong: a two-tone drawing needs a file per
ground, and currentColor never reaches an SVG referenced as an image.
The bracket replaced the first mark, three strokes in, one out. The strokes read as a generic merge glyph, and their curves closed into a blob below 24px. Of the directions compared against it, the bracket is the one that states the library plainest - three equal things made a group by one device - and the only one legible at 16px with a single geometry, in rectangles only, which is trysquare’s vocabulary. The proposal drew that bracket in brass; here it takes verdigris, because the accent is the identity and brass is trysquare’s.
The wordmark is geometry, not type. Circles on a 20-unit x-height at one
stroke width, rather than glyphs outlined from a font. Outlining is the usual
answer, and it costs a vendored typeface, a licence to check and a generator to
run before the lockup can be rebuilt. Drawing it costs a paragraph of
construction notes - and, like an outlined wordmark and unlike a font-family,
it cannot fall back silently on a reader who lacks the face.
The neutrals are trysquare’s, and the site is shaped like its documentation, deliberately: two tools by the same hand, meant to be read together, cost a reader more when they look unrelated than they gain by being distinct. What differs is the accent - brass there, verdigris here.
One package, and what would split it¶
The library and the extension ship in the same tarball. pi lets one package
be both: exports answers import … from "@ai-for-dev/combo", and the pi
manifest in package.json names extension/index.ts, which is what
pi install npm:@ai-for-dev/combo loads. A real pi says so on startup, listing
the extension with no -e on the command line.
Two packages was the other candidate, and nothing pays for it today. The two
halves share every dependency they have, and all of those are packages pi
bundles, so a separate extension package would weigh nothing less. extension/
and pane/ reach into src/ from twenty files: split them and every internal
change becomes a version bump across a boundary, where a mismatched pair fails
at runtime rather than at the typecheck. What would pay for it is the extension
needing a dependency the library does not, or the two wanting different release
cadences. Neither has happened, and rule 11 covers the rest.
The published name carries a scope because combo was taken on npm in 2011.
The packages pi bundles are peer dependencies with a * range, which is
what pi’s packaging documentation asks for, and the rule buildRegistry already
obeys from the other side: an installed copy binds to the pi it is loaded into,
never to a second one of its own. @earendil-works/pi-tui and typebox were
imported and declared nowhere at all. In a clone they resolve by transitivity,
and an installed copy is exactly where that stops being true.
The version comes from the commit titles¶
Release Please reads the conventional commits landed on main and opens a pull
request that is the next release. Its workflow starts by hand, never on a
merge, so an ordinary pull request leaves nothing behind it. The price is that
a release takes two runs: the first opens the pull request, and the second,
after it has been merged, tags the release. Running on every push would have
removed that second run, and would have put a release pull request in the way of
every merge. That is the more expensive of the two.
Publishing is a workflow of its own, woken by the release event and by a tag handed to it, and it stages rather than publishes. As a second job beside the release it was reachable only while that one run existed, and the first release proved why that matters: the tag was written, the publish failed on a credential, and nothing but re-running that exact run could finish it. It carries no npm token either. Trusted publishing authenticates it by the identity GitHub gives the run, which is one fewer secret to hold and to rotate, and the provenance attestation follows from the same identity.
What no credential supplies is proof of presence, and npm asks for it on a write
to this package. npm stage publish is the shape that admits it: the run builds
and uploads the tarball, and npm stage approve finishes the job from a machine
where somebody can answer. The alternative was a token allowed to bypass the
second factor, which is what npm is in the middle of taking away, so the manual
step is not a workaround to be removed later. It is where a release genuinely
stops being automatic.
The cost is a vocabulary. Pull requests are squashed here, so each title becomes
a commit title, and feat: or fix: has to be written on it rather than
inferred from the diff; check-pr-title refuses a title that cannot be parsed.
The titles already in the history are prose, which is why bootstrap-sha names
the tip of main at the time: the first release reads what comes after it.
versioning-strategy: always-bump-minor was in the configuration this one was
taken from, and is not here. It suits a project released as a whole. It does not
suit a package whose readers write ~0.1.0 and would then never be offered a
fix.
A deadline travels in the abort’s reason¶
pi cannot tell a deadline from a stop: both are session.abort(). A subagent
told the two apart only when the deadline was its own timeoutMs. A flow
bounds each attempt with a signal of its own and passes it as the turn’s
signal, so its journal said timeout while the subagent’s Result, and the
usage.json built from it, said aborted.
The deadline now says what it is in the signal’s reason, a TimeoutError, as
AbortSignal.timeout gives, and src/deadline.ts both writes that reason and
reads it back. The subagent reads the reason of whichever signal fired first,
so a caller’s abort before the deadline still reads aborted, and a person’s
stop still reads stopped. The other way was to pass the flow’s bound as
timeoutMs, which would have taken away the flow’s own deadline port, the
switch a dry run throws when its script says an attempt timed out.
A deadline is worded once, as the flow file writes it¶
One fact was worded three ways: timed out after 20000ms in the subagent’s
failure and usage.json, no answer within 20000 ms in a flow’s journal, and
ran past its bound of 20000 ms for a check. A turn that hit its deadline
read one way in the journal and another in usage.json, two files a person
reads side by side.
src/deadline.ts now builds the phrase, timedOutAfter, and every bound that
fires uses it: the reason a deadline aborts with, an agent node’s failure, an
ask with no default: (which adds that clause after it), a check. The words
are timed out after, which the subagent already said. The bound is shown the
way a flow file writes it and the plan prints it, 30m rather than
1800000ms, so the error reads like the timeout: a person wrote and the
timeout 30m by default the plan showed them. A bound that is not whole
seconds, which only a timeoutMs passed from code can give, stays in
milliseconds: the plan’s formatter rounds up, which suits a worst case and
would misstate a deadline. That formatter moved out of src/flow/ into
src/duration.ts, because a subagent’s deadline exists without any flow.
A frontmatter lifetime holds inside a workflow¶
The record said it from the start: a frontmatter lifetime is the agent’s
default, and an explicit argument wins over it. spawn did that. The pool did
not: it passed options.lifetime ?? "task" to every spawn, so it chose the
default before it looked at the agent. In a loop that named no lifetime, the
shipped coder and reviewer, both lifetime: workflow, got a fresh subagent
every iteration, and the spawn events said task.
The pool now resolves the lifetime per agent, with the same rule as spawn
(lifetimeOf in src/agent.ts, the one place that writes it): the workflow’s
lifetime when it names one, then the agent’s frontmatter, then "task". This
keeps invariant 6. A lifetime: workflow in a definition is persistence asked
for, in the file that defines the agent, and a workflow that wants every agent
fresh says lifetime: "task". The pool closes the subagents it opens either
way: "task" after the turn, the rest in closeAll().
Two paths still set the lifetime themselves, on purpose. run is one turn and
forces "task". A flow node reads memory:: it runs as "task" unless it
names a scope, because in a flow the file says which nodes share a subagent,
and a frontmatter default would let an agent definition change how a flow
reads. interview and swarm keep their own "workflow" default, which they
pass as the workflow’s lifetime, so it wins over a frontmatter the same way a
caller’s would.
candidates beside a field only orchestrate reads is an orchestration¶
The tool inferred route from candidates whatever stood beside it, so a call
with candidates and reduceWith ran a route. The route never read
reduceWith, no synthesiser was spawned, and nothing said so. The schema did
not say that orchestrate needed an explicit mode, and the task
description named three of the seven modes that read it.
candidates alone is still a route, the cheaper of the two. Beside
concurrency, maxTasks or reduceWith, the fields orchestrate reads and a
route does not, it is an orchestration: a route would drop that field, so the
call has only one reading. The other way was to keep orchestrate
explicit-only and say so in the schema. That still let a call that meant an
orchestration run as a route and drop a field.
extension/params.ts now keeps one table of which modes read each field.
The descriptions a model reads are written from it, the set of fields that
make an orchestration comes from it, and so does the list flow mode accepts.
A test does not take the table on trust: it runs every mode, records which
fields the tool body reads, and checks each description against that.
A flag written wrong is refused, not read¶
parseLeadingFlags read a switch written --name=<word> as on for any word
but false. /step --agent=no explore where usage is measured forced the
agent, the opposite of what was typed, and refused with Unknown agent "explore" when no agent had that name. A switch now takes --name,
--name=true or --name=false, in any case. Any other value is refused, and
the refusal says what the switch takes. yes, no, on, off, 1 and 0
are refused too. Nothing in this record asked for them, one spelling of each
answer is enough, and a word the parser does not read is better refused than
guessed at. An absent switch still arrives as nothing, so a switch keeps the
three answers --worktree was given.
A count went the same way. /swarm refused --members x in its own words,
and /interview dropped --questions x without saying so. The reason given
for dropping it was that 0 would skip the interview, but running on the
default of 6 is also a guess, and nobody is told. A count is now a whole
number of at least 1, checked in flags.ts for every command that names one.
It is written in digits: 1.5, 0x10 and 1e2 are all refused.
The parser returns the refusal as refused and does not throw. Each command
prefixes it with its own name and shows it through refuse(), as a warning,
the same level as its other refusals of what was typed.