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.jsonl is 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.json lists it under exports, with the reason when it could not be written.

  • main.html is 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, and pi --export <file> turns it into the same page on demand. Writing our own HTML would mean reimplementing something pi already does.

  • usage.json is the only artefact produced here. It carries what pi cannot: time, attribution per subagent, and parallelism (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.