Sessions and Where They Live
Session files are append-only JSONL trees keyed by id and parentId; here is how to read one, resume it, fork it, and control where it is stored.
You want to know what Pi actually did. Not what it summarised in the final message — what it did. That means opening the session file, and it turns out the file is plain JSONL you can read with any text editor, and every line is a node in a tree.
That tree is the reason Pi can offer /tree, /fork and /clone, and it carries the
book’s most-repeated distinction: a session is a tree; the model’s context is one
path through it.
flowchart TD
H["header"] --> U1["user"]
U1 --> A1["assistant"]
A1 --> U2["user, edited"]
U2 --> A2["assistant"]
A2 --> U3["user"]
U3 --> A3["assistant"]
A1 --> U2b["user, original"]
U2b --> A2b["assistant"]
The current leaf is A3. The active branch is the path U1, A1, U2, A2, U3, A3 — the
only entries read into the next request. The path through U2b is an abandoned
alternative: it is still in the file, still counts toward your session totals, and the
model has never seen it.
Wrong: “The session is the conversation.”
Correct: “The session is the conversation plus every alternative one. The conversation is the active branch.”
One file, one tree
sessions.md: Pi saves sessions automatically unless you start it with --no-session.
Persistent sessions are JSONL files where each tree entry has an ID and refers to its
parent, and the current entry identifies the active branch.
session-format.md gives the location:
~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl
For the working-directory component, Pi removes the leading path separator and replaces /, \, and : with -. So a session started in C:\Projects\pi lands under a directory whose name has no drive letter and no backslashes. <session-id> is a UUID by default; --session-id or the SDK can supply your own.
Every entry except the header extends the same base:
interface SessionEntryBase {
type: string;
id: string; // Usually an 8-char hex ID; may fall back to a full UUID
parentId: string | null; // null for a root entry
timestamp: string; // ISO timestamp
}
Two timestamps, two formats. Entry timestamps are ISO 8601 strings. The nested message timestamp is Unix milliseconds — message-types.md calls this out explicitly to prevent a whole class of parsing bugs.
The header
The first line is metadata, not part of the tree:
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
session-format.md lists three versions: version 1 was a linear sequence and is auto-migrated on load, version 2 introduced id/parentId linking, version 3 renamed the hookMessage role to custom. Existing sessions migrate to v3 when loaded. If you are parsing sessions by hand, handle the version field.
The entries that matter
type |
In LLM context? | What it holds |
|---|---|---|
message |
yes | An AgentMessage, including the system messages that carry prompt sections and tool declarations |
compaction |
yes | summary, firstKeptEntryId, tokensBefore, optional systemMessage checkpoint |
branch_summary |
yes | A summary of the branch you navigated away from, plus fromId |
context_edit |
no | An append-only edit that hides or rewrites one earlier entry for future requests |
custom |
no | Extension state via appendEntry() |
custom_message |
yes | Extension-injected content via sendMessage() |
usage |
no | Model-attributed usage such as cache warming; counted in totals, hidden in the tree |
model_change, thinking_level_change |
no | Which model and thinking level are selected on this branch |
label |
no | A user-defined bookmark on an entry |
session_info |
no | The display name set by /name, --name, or pi.setSessionName() |
Read the middle column before the third one. Every entry type has to be classified twice — once for disk, once for the request — and stored is not the same as sent.
Context edits rewrite without deleting
ContextEditEntry is worth understanding on its own:
{"type":"context_edit","id":"g6h7i8j9","parentId":"f6g7h8i9","timestamp":"2024-12-03T14:11:00.000Z","targetId":"c3d4e5f6","replacement":null}
replacement: null omits the target from model context. A non-null replacement
replaces only that message’s content; string replacements for assistant and tool-result
entries are normalised into one text block. If several edits target the same entry, the
latest one on the active branch wins.
The important property is that edits are branch-relative: navigate to a point before the edit and the original contribution returns. That is what makes it safe for Pi — and later for you — to hide an over-budget attempt.
Building the request from the file
buildContextEntries() walks from the current leaf to the root and honours compaction:
if a CompactionEntry is on the path, the latest one wins, and the entries from
firstKeptEntryId up to the compaction entry are included alongside the entries after
it. buildSessionProjection() then applies the latest context_edit for each target.
Finally buildSessionContext() converts entries to messages.
The third column of the table above is produced by that last step. usage and custom
become no context message; context_edit produces none of its own. The entries
themselves survive because the renderers and the accounting still need them.
Three ways to branch
sessions.md is explicit about when to use which:
| Action | Result | Use it when |
|---|---|---|
/tree |
Moves within the current session file | Related alternatives should stay together |
/fork |
Creates a new session from an earlier user message | The alternative should become separate work |
/clone |
Copies the active branch into a new session | You want a separate copy of the current state |
In /tree, selecting a user message puts its text back in the editor so you can edit and submit it as a new branch. Selecting an assistant response continues after that entry with an empty editor.
When you leave a branch, Pi can summarize it and attach that summary to the branch you enter — that is the branch_summary entry, and it preserves relevant work without dragging every message along.
Control where sessions live
By default, ~/.pi/agent/sessions/, grouped by working directory. Three ways to change it, in precedence order: --session-dir, then PI_CODING_AGENT_SESSION_DIR, then the sessionDir setting. cli.md confirms the CLI option has highest precedence.
--no-session gives you an ephemeral in-memory session that cannot be resumed after Pi
exits. environment-variables.md notes PI_SESSION_FILE is unset for ephemeral
sessions, which is how a script can detect that.
Read your own session
/session reports the current file, ID, message count, token usage, and cost. And because the file is JSONL, you can read it directly:
Get-Content "$env:USERPROFILE\.pi\agent\sessions" -Recurse -Filter *.jsonl |
Select-Object -First 1 |
ForEach-Object { Get-Content $_ | Select-Object -First 3 }
session-format.md ends with a parsing example that switches on entry.type and prints a line per entry. Copy it and extend it; it is the fastest way to answer “what did that run actually do”.
Delete deliberately
Sessions are removed by deleting their .jsonl files. /resume also supports
interactive deletion with Ctrl+D followed by a confirmation, and Pi uses the trash
CLI when available to avoid permanent deletion.
Next
Almost everything in this tree was written by a tool call: the file is a record of what the model was able to do. So the next question is not about storage at all, it is about the set itself — which tools Pi starts with, how that set is composed from settings, and what a tool has to return for the model to learn that something failed.
Chapter 6: the built-in tools.