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.
When you want to reconstruct what Pi recorded, start with its session file. It is plain JSONL that you can read with a text editor: the first line is a header, and subsequent entries describe messages, settings changes and other session events. Entries with IDs and parent IDs form a tree, but not every recorded entry is a message sent to the model.
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 (file metadata, not a tree node)"] -.-> 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. Its active branch follows U1, A1, U2, A2, U3, A3. That path supplies the history from which Pi constructs the next request; compaction, context edits and other projections can still change what is actually sent. The path through U2b is an abandoned alternative: it remains in the file and may contribute to session-wide totals, but is not directly part of the active path. A branch summary may carry information about it forward. This describes the next request, not all past requests: the model may have seen U2b before you branched away.
Wrong: “The session is the conversation.”
Correct: “The session file stores the conversation tree and its metadata. The active branch is the path from which the next model context is constructed.”
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 C--Projects-pi: the backslashes become dashes and the drive colon becomes a dash, while the drive letter itself stays. No separator and no colon survives. <session-id> is a UUID by default; --session-id or the SDK can supply your own. Chapter 27 shows the same encoding for a POSIX path, which is the case that needs no substitution at all beyond the leading separator.
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.
Stored is not the same as sent
Every entry type has to be classified twice, once for disk and once for the request, and stored is not the same as sent. A message entry contributes to history, but that message is sent only if it survives active-branch selection, compaction and context edits. custom, usage and label entries are stored without becoming ordinary model messages. A compaction entry changes how earlier history is represented in the next request; the entry itself is not simply forwarded as-is. The first line of the file is a versioned header, not a node in the conversation tree.
Chapter 27 is the reference for the header, the versions, all eleven entry types, context_edit, and the three passes that turn the file into a request. This chapter stops at the idea.
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 is again eligible for context. This is reversible within the session tree, but it is not a security or privacy boundary: the original content remains on disk and may already have been sent to a provider.
Three ways to branch
/tree moves within the current session file, /fork creates a new session from an earlier user message, and /clone copies the active branch into a new session. Leaving a branch can attach a branch_summary entry that summarises what you left. Chapter 24 explains which command to use and what each does to the history you leave behind.
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.
Location and identity
Three things get confused here, and the confusion shows up as “Pi resumed the wrong conversation”. They are the directory, the file, and the branch inside the file.
| Question | Answered by | Where to look |
|---|---|---|
| Which directory holds sessions | sessionDir setting, PI_CODING_AGENT_SESSION_DIR, or --session-dir — the flag wins |
/session, or the sessionDir setting |
| Which file is this run | the working-directory group plus the session id | the --<path>-- directory name, and PI_SESSION_FILE |
| Which messages were eligible for the next model request | the active branch, after compaction and context edits | /tree to inspect the branch; chapter 27 for request projection (the stored entries alone do not prove what was sent) |
| Whether it is this run at all | all three agreeing | comparing /session’s file and id with the directory you expected |
The row that catches people is the third. Resuming gives you a session file with a whole
tree in it, and the model is shown one branch of that tree. Chapter 24 is about forking
it and chapter 27 about the format; for debugging, the useful first move is /tree, which
answers “which branch am I on” without reading the file at all.
The row that catches people second is the first. A session started in a different
directory lands in a different --<path>-- group, so “I resumed and it was empty” is
usually a working-directory difference rather than a lost session.
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-ChildItem "$env:USERPROFILE\.pi\agent\sessions" -Recurse -Filter *.jsonl |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1 |
ForEach-Object { Get-Content $_.FullName -TotalCount 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.
What was run
Chapters 4, 5, 23 and 24 are tested on one shared history, so that they read as one subject, state over time, and not as four. It is three turns in a persisted session: question A and its answer; question B, which makes a bash tool call and then answers; question C and its answer. examples/harness/history.ts builds it, and each chapter reads a different property of the same run.
Chapter 5’s question is what is on disk, and how do I find and read it? examples/ch05-disk/ checks it on the real file and on the shipped binary:
- the persisted session records entries as work proceeds: a header, followed by message and other session entries, including the tool result. The assembled system prompt is not necessarily a separate persisted message entry;
- entries form a tree by
idandparentId, ids are unique, and for a linear conversation each entry’s parent is the entry before it; - stored is not sent: model-change and thinking-level entries and a
customentry written withappendCustomEntry()were all in the file, and none appeared in what the provider received; - a
context_editwithnullhid question A from the next request and left it in the file, and navigating to the assistant’s answer to A, a point before the edit, revealed the original again: the edit is branch-relative. A replacement,{ content: "REDACTED" }, rewrote the message for the model and left the stored entry untouched. The replacement must be an object withcontent, not a bare string; - on the shipped binary, the default location was
--<path>--with/,\and:replaced by-, and the file was named<timestamp>_<session-id>.jsonlwith a UUID; --session-dirbeatPI_CODING_AGENT_SESSION_DIR, which beat thesessionDirsetting, tried with all three set and then with each removed in turn.
Next
The tree records user messages, assistant responses, tool results and other session events. It tells you what was recorded, not by itself everything the model saw or was authorised to do. The next question is therefore about the available tools: which ones Pi starts with, how settings compose that set, and how a result tells the model that something failed.
Chapter 6: the built-in tools.