Skip to content
View as .md

Pi session resume

Pi version inspected: @earendil-works/pi-coding-agent@0.78.0. Sources checked: Pi docs plus installed dist/core/session-manager.js, dist/main.js, and our mirror at packages/sandbox/src/pi-extension/thread-mirror.ts.

1. What lives in --session-dir?

Only session JSONL files. Pi-created filenames are:

<iso-timestamp-with-:-.-replaced>_<sessionId>.jsonl

Example:

2026-05-31T10-12-13-456Z_019...jsonl

--continue/listing scan *.jsonl; filename is not parsed except extension. The session id comes from the header line.

First line:

{"type":"session","version":3,"id":"session-id","timestamp":"ISO","cwd":"/working/dir","parentSession":"optional"}

Subsequent entries are append-only tree records:

{"type":"message","id":"u1","parentId":null,"timestamp":"ISO","message":{"role":"user","content":"...","timestamp":1790000000000}}
{"type":"message","id":"a1","parentId":"u1","timestamp":"ISO","message":{"role":"assistant","content":[{"type":"text","text":"..."}],"api":"...","provider":"...","model":"...","usage":{...},"stopReason":"stop","timestamp":1790000000001}}

Other top-level entry types: model_change, thinking_level_change, compaction, branch_summary, custom, custom_message, label, session_info.

Tool calls are assistant content blocks:

{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"x"}}

Tool results are separate message entries whose message.role is toolResult:

{"type":"message","id":"t1","parentId":"a1","timestamp":"ISO","message":{"role":"toolResult","toolCallId":"call_1","toolName":"read","content":[{"type":"text","text":"..."}],"isError":false,"timestamp":1790000000002}}

Pi also has extended message roles such as bashExecution and custom inside type:"message" entries.

2. Can we hand-write a file into a fresh dir and use --continue?

Yes, if it is a valid .jsonl file with a valid first header and its header cwd matches the current cwd when using a custom --session-dir.

No extra index, pointer, lock, checksum, or pi-managed sidecar is required. Pi scans files directly.

But there are two gotchas:

  1. For custom --session-dir, --continue filters sessions by header cwd === process.cwd().
  2. In non-interactive modes, Pi rejects a loaded session if the header cwd does not exist.

So for fresh-sandbox reconstruction, either:

  • write cwd as the sandbox working dir that will run Pi, or
  • avoid --continue and use --session <path> with a header cwd that exists.

3. How does --continue choose?

SessionManager.continueRecent(cwd, sessionDir):

  • scans sessionDir for *.jsonl
  • reads only the first line/header
  • filters invalid headers
  • with a custom non-default sessionDir, filters by header cwd matching current cwd
  • sorts by file mtime, newest first
  • opens the newest

No pointer file. Not single-session-per-dir.

With arbe-sessions/<threadId>, it is only one session if we keep it that way. Pi does not enforce it.

4. Minimum records needed to remember context

For load: header + any message entries on a valid parent chain are enough. Pi does very little schema validation at load time.

For useful continuation: user + assistant text turns are enough for memory. Tool records are not required if you omit tool calls too.

Important: do not include assistant toolCall blocks without matching toolResult messages unless you know the provider accepts that. Most chat/tool APIs expect tool-result consistency. A safe context-only reconstruction should either:

  • include full assistant toolCall + toolResult pairs, or
  • flatten/summarize tool activity into plain text and omit toolCall blocks entirely.

5. Are our mirrored pi.* thread entries verbatim session lines?

No. Current mirror is a transformed/lossy projection.

Path:

thread-mirror.ts
-> createPiLiveEntryIngestor()
-> decodePiEvent()
-> emits arbe thread payloads

It emits normalized thread entries:

  • pi.chunk from message_update
  • pi.assistant from assistant message_end
  • pi.tool_result from tool-result message_end
  • pi.compaction from compaction_end

It synthesizes envelope ids like:

<threadId>:pi:idx:<n>

Those are not Pi session-entry ids, and there is no Pi parentId. User turns are not mirrored as Pi session messages; in arbe they live as chat entries. Many Pi session features are absent: exact top-level session entries, tree lineage, labels, model/thinking entries, exact session-file ids, etc.

So: current thread entries cannot be concatenated back into a verbatim Pi session file. We can reconstruct an approximate valid file, but not the original file.

If we want lossless resume-anywhere, enrich the mirror to carry raw/full Pi session entries or periodic raw session-file snapshots. If we only need semantic resume, synthesize a linear Pi file from chat + pi.assistant/pi.tool_result.

6. Session header validity / cwd

Load-time validation is minimal: first parsed JSONL object must have:

{"type":"session","id":"string"}

version, timestamp, and cwd are expected by types/docs but not strictly checked by loadEntriesFromFile().

However, CLI startup then checks sessionManager.getCwd() exists. Since SessionManager.open() uses header.cwd ?? process.cwd(), a stale non-existent cwd makes non-interactive/RPC/print exit with:

Stored session working directory does not exist: ...

A different-but-existing cwd is accepted; Pi uses that as the runtime cwd. Therefore, reconstructed sessions should set header cwd to the fresh sandbox’s intended workdir.

7. id / parentId

Entry ids do not have to be UUIDs. Pi-generated entry ids are 8 hex chars, but open/load does not validate that. For a linear reconstruction, assign stable unique strings and a parent chain:

u1 parent null
ass1 parent u1
u2 parent ass1
ass2 parent u2

The header session id must only be a string for open/load. If Pi itself creates a session id (--session-id / SessionManager.create), it validates: alphanumeric start/end, and only alnum - _ . inside.

8. Tool entries: required?

Required for exact replay: yes.

Required for Pi to load: no.

Required for provider-valid continuation: only if assistant messages contain tool calls. Keep tool-call/tool-result pairs internally consistent, or omit tool calls and turn prior tool work into normal text/summary.

For resume-anywhere, the safest synthesized transcript is often:

  • user/chat text
  • assistant final text
  • optional plain-text summaries of tool outputs that matter
  • no historical toolCall blocks unless we can reconstruct all matching toolResults

9. Native import/seed/fork paths

No documented CLI flag imports arbitrary external transcripts.

Native paths are file-oriented:

  • --session <path|id> opens a supplied file path or resolves a session id/prefix.
  • --fork <path|id> copies an existing Pi JSONL session into a new session with a new header/cwd.
  • SDK SessionManager.open(path) opens a file.
  • SDK runtime mentions import-from-JSONL flows, but this is still Pi JSONL, not arbitrary transcript magic.

So option (c) only beats hand-writing if we already have Pi-compatible JSONL.

10. --continue vs --session <path|id>

For resume-anywhere, use --session <reconstructed-path> as the portable default.

Why:

  • avoids cwd-filtered directory scan
  • avoids mtime races if multiple sessions exist under arbe-sessions/<threadId>
  • makes the selected lineage explicit

Use --continue only for same-sandbox disk cache where the session dir is trusted and current cwd/header cwd match.

--fork is not what we want for same-lineage continuation. It creates a new session file/header and records parentSession. Use --fork when intentionally branching/copying into a new lineage. For thread-backed resume, straight --session <file> is the right shape: append to the reconstructed lineage.

Contract recommendation

Current mirror = projection. Therefore choose one:

  1. Semantic reconstruction now: synthesize a linear Pi JSONL from arbe chat + mirrored pi.assistant/pi.tool_result; flatten tools unless complete pairs are available.
  2. Lossless later: enrich the mirror to persist raw/full Pi session entries or snapshots in the thread. Then hydration is concat/copy instead of inference.

For fresh-sandbox robust resume, add:

hydratePiSessionFromThread(thread, sandboxCwd, targetPath)
-> writes header.cwd = sandboxCwd
-> writes linear id/parentId chain
-> run pi --session <targetPath>