Export¶
Ask for a directory, and every subagent writes its own transcript into it as it closes. Two formats, both produced by pi itself: a readable HTML page and a replayable JSONL. We render neither.
import { createRunDir, fanOut, measuredRun } from "@ai-for-dev/combo";
const dir = createRunDir(); // runs/<timestamp>/
const run = measuredRun({ dir }); // the clock starts here
await fanOut({ agent: scout, tasks, exportDir: dir, onEvent: run.onEvent });
run.finish(); // usage.json, with the time measured
measuredRun is the one assembly of a picture, a clock and the report: the pi
extension’s live view is one with a terminal on top, an experiment’s cell is
one with a recorder beside (record: true keeps events.jsonl), and a script
that wants usage.json is one with nothing added. usageReport and
writeUsageReport are still there for whoever builds the report from a
snapshot of their own.
runs/
├── .gitignore written once: git has no business with these
└── 2026-07-19_17-16-48/
├── scout-1.html scout-1.jsonl
├── scout-2.html scout-2.jsonl
├── main.jsonl
└── usage.json
The .gitignore holds *, and it is there because the exports land inside
the repository the run worked on. Both halves of that were measured rather
than imagined: a delivery that gives its subtasks copies of the repository
cannot put them back while runs/ alone makes the tree unclean, and the next
git add -A in that repository would sweep a run’s transcripts into somebody’s
history. A .gitignore already in runs/ is left alone.
Every call gets a directory of its own. The name is the start time to the
second; a run started in a second already taken is 2026-07-19_17-16-48-2,
then -3. Each name is created exclusively, so two processes never share one,
and newestRunFirst sorts the names by start, -10 after -9 included.
What is in there, and what is not¶
One HTML and one JSONL per subagent. An orchestration export that lost the subagents’ work would be useless.
main.jsonlis the parent session’s transcript. pi’s own file is copied once pi has written it. pi creates that file with the parent’s first reply, so a run launched before it, the first command in a fresh pi for example, gets the same lines written from what pi holds in memory: the header and the entries, one JSON object per line.usage.jsonlists it underexports, with the reason when it could not be written.main.htmlis not there, and will not be. pi’s HTML renderer is a method of a live session, and an extension only ever gets a read-only session manager. So the JSONL is copied instead, andpi --export <file>turns it into the same page on demand. Writing our own HTML would mean reimplementing something pi already does.usage.jsonis the only artefact produced here. It carries what pi cannot: time, attribution per subagent, andparallelism(busy over wall). It is built from the same snapshot the TUI draws - one collected state, two consumers.A delegated subagent carries
parentId, and the rows come out in tree order, so reading top to bottom already shows the children under the one that spawned them. The list stays flat on purpose: the total is a sum over the whole tree either way, and a flat list with a link in it is one pass away from a tree without asking every reader to walk one.
exportDir implies a session directory¶
An in-memory session persists nothing, and pi answers a request to export one
with Cannot export in-memory session to HTML. Asking for an export is asking
for the session to be kept long enough to export it, so exportDir implies
<exportDir>/.sessions. That is one decision, not two.
sessionDir stays available for anyone who wants the working files elsewhere.
Neither is a default: with no exportDir, a subagent leaves nothing behind, not
in ~/.pi and not in the working directory.
When it happens¶
Export can be triggered at any time, not only at the end of a workflow:
await subagent.export(dir); // on any live subagent
close() exports first and disposes after, so a workflow’s finally -
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 the result’s 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. JSONL and
HTML are attempted separately: an in-memory session still yields its transcript
even though pi refuses to render its page.
From the extension¶
> use subagent with export true to explore the parser with three scouts
The run directory’s path is shown in the tool row.
A flow run¶
A flow’s run directory is also its export directory. Each subagent writes
its transcript under its home, the path of its memory scope or of its visit,
as <home>/<agent>.jsonl and .html, and a name already taken takes the
first free ~n, as main.jsonl and events.jsonl do when a resume adds a
life. Delegated children go in <parent>.children/. usage.json adds the
run’s visits, its nodes and its lives.
The run directory and
Measuring a run give the layout and the fields.
One run, or a matrix of them¶
Experiments nests this layout one level deeper: a directory per
model, rep-<n>/ inside it, and each cell writing the very same usage.json.
Reference¶
measure/export-createRunDir,exportSession,usageReport,writeUsageReport.subagent-Subagent.export,SpawnOptions.exportDir.