Pi's session file looks deceptively simple: a JSONL file in which every line is one JSON record.
It doesn't store a conversation as a flat messages[] array, nor does it create a separate database table for each branch. In the current version, Session Entries are linked into a tree through id / parentId; the file keeps growing in append order, and the current leaf determines which historical path you're on.[1]
So a single Session has two structures at once:
Physical structure
append-only JSONL
Logical structure
entry tree
The separation between these two structures is the key to understanding Pi's session design.
Start with the file itself
The first line of a session file is the header:
Only the entries after that belong to the tree structure. The base fields look roughly like this:
An ordinary conversation might be written as:
On disk, it's still nothing but sequential appends:
A
B
C
D
But parentId has already given these records a logical relationship:
A
└── B
└── C
└── D
If the user then goes back to B and keeps typing from there, new entries keep getting appended to the end of the file:
line 6 user message id=E parent=B
line 7 assistant message id=F parent=E
The physical order becomes:
A B C D E F
while the logical structure becomes:
A
└── B
├── C
│ └── D
│
└── E
└── F ← current leaf
The file doesn't need to rewrite C and D, nor to copy A and B. Creating a branch only requires pointing the new entries at the old nodes.
This is a persistence model that suits local coding agents well: appending is simple, the history stays inspectable, and the user can return to an earlier position in the same session and keep working.
The current session is determined by the leaf
The tree itself contains several branches, but the model can't treat all of them as current history at once.
So SessionManager also needs a "current position." Pi's docs call it the current leaf. To build the current path, walk from the leaf along parentId all the way back to the root, then reverse the order.[1]
The conceptual code looks like this:
For the tree above, if the leaf is F:
A
└── B
├── C
│ └── D
└── E
└── F
the current path is:
A → B → E → F
C and D are still stored in the session file, but they never make it into the model history of the current branch.
This shows that the session file and the model context were never the same object to begin with:
Session File
A B C D E F
Current Branch
A B E F
Model Context
Built from A B E F, then Compaction / Message conversion
Many of the capabilities that follow are built on this three-layer distinction.
/tree is essentially about moving the current position
Pi supports navigating the session tree. After picking an earlier entry, the user can continue working from that point.
From a data-structure standpoint, this action doesn't require modifying any old entry. SessionManager only needs to switch the parent of subsequent appends to the target node.
For example, if the original path is:
A → B → C → D
↑
original path
and you go back to B and continue:
A → B → C → D
\
E → F
This resembles Git's commit graph in some ways: nodes store parent references, and the current leaf is like the current working position. But Pi's entry semantics, context construction, and branch summaries are specific to agent conversations, so Git's model can't be applied wholesale.
More importantly, the tree structure lets "keep the old history" and "change the current context" hold at the same time.
If the system used a plain array:
then going back to an old node would simply delete the subsequent history.
The session tree instead preserves the old branch; new input only adds another path.
That's valuable for a coding agent. A user might try approach A, find it unsuitable, and return to an earlier state to try approach B. The model's earlier analysis and execution records are still preserved, while the current context continues along branch B alone.
Branch summaries solve the information loss that follows leaving a branch
Switching branches entirely raises one problem: the old branch may already have produced valuable information.
For example:
A → B → C → D
Found in D:
An interface can't be modified because another module depends on it
If the user goes back to B and creates E from B, the current path becomes:
A → B → E
D is no longer part of the current path, and that valuable piece of information won't naturally flow into the subsequent context.
Pi therefore supports BranchSummaryEntry. When tree navigation needs to preserve information from the branch being left, it can generate a summary and store it as an entry on the new path.[1]
The structure is roughly:
A
└── B
├── C
│ └── D
│
└── BranchSummary
└── E
A branch summary expresses this:
The old branch is no longer the current path,
but some of its valuable context is carried into the new branch as a summary.
That's different from simply leaving D in the context. The new branch doesn't inherit the old branch's full model trajectory; it inherits only the necessary information after compression.
One important trait of Pi's session tree shows up here: the tree decides which history is selected, while summaries carry the necessary information across paths.
Compaction also changes the context through entries
Long conversations need their early history compressed. Pi doesn't delete the original entries; it appends a CompactionEntry.[2]
The current structure includes:
Suppose the current branch contains:
A B C D E F G H
Once the context grows too long, the system may generate a compaction:
summary(A...E)
firstKeptEntryId = F
The session still stores:
A B C D E F G H Compaction
But when buildSessionContext() constructs the model history, it uses:
Summary(A...E)
F
G
H
This reflects the same design once again: the persisted history stays complete, while the model view can be compressed.
Pi's compaction documentation also explicitly forbids the cut point from landing arbitrarily on a tool result, because a tool result must remain structurally intact with its corresponding tool call.[2]
That means the context builder can't consider only token count; it must also satisfy the structural constraints of provider messages.
For example, the following history must not be cut incorrectly into:
Assistant(tool_call)
--- cut ---
ToolResult
because model protocols generally require tool calls and tool results to remain validly paired.
So compaction actually handles two problems at once:
Token budget
+
Message protocol invariants
buildSessionContext() is the real entry point for restoring
After a session file is reopened, the runtime doesn't simply do this:
Instead, Pi's SessionManager.buildSessionContext() builds the path from the current leaf, then interprets the entries along that path.[1]
The documented flow includes:
- Collect the path from the current leaf to the root;
- Restore settings along that path, such as model and thinking level;
- If a compaction exists, rebuild the messages using the summary and firstKeptEntryId;
- Convert entries such as branch summaries and custom messages into their corresponding messages;
- Produce the message history the current model session needs.
When the SDK creates an agent session, it also calls sessionManager.buildSessionContext() first to determine whether an earlier session exists, and restores the existing messages and model-related state accordingly.[3]
So the restore path looks more like this:
SessionManager here plays an obvious projection role: the entry tree on disk is the durable fact, while the agent runtime needs the running state of the current branch.
Why one session file can hold an entire tree
Many systems model a branch directly as a new session:
Session A
↓ fork
Session B
Pi's choice to keep the tree inside a single file has clear product motivations.
Users of coding agents often need to:
Inspect history nodes
Go back to a given node
Try a different approach
Return to the original branch
If each of these operations created a new session, you'd end up with a pile of session files, and users would have to juggle multiple session identities.
The tree model treats these paths as different routes through the same stretch of work history:
Session
└── Tree
├── branch A
├── branch B
└── branch C
When a user really does need to take a branch out on its own, they can use fork / branched session to create a new session file. Pi's session header also provides a parentSession field that records this relationship between sessions.[1]
So there are two levels of branching:
Within a single session
Entry Tree Branch
Across sessions
Fork / parentSession
Keeping them distinct avoids conflating "navigating history" with "creating a new long-term unit of work."
The cost of the tree model
The session tree is lightweight, but it adds a few constraints.
First, every context construction has to determine the current path first. You can no longer assume that "the last N messages in the file are the current history."
Second, the parent chain of entries must stay intact. A single corrupted parentId directly affects restoring the current branch.
Third, nodes with protocol pairing requirements, such as tool calls and tool results, can't be treated as arbitrary branch boundaries. The fact that the tree lets you return to any node doesn't mean every node is a valid endpoint for the model history.
Fourth, cross-branch information has to be handled explicitly. Branch summaries exist precisely so that valuable information from an old branch doesn't disappear entirely.
Fifth, the semantics of session entries keep widening. Besides messages, there are compactions, branch summaries, setting changes, and other types. SessionManager has to understand how each of these entries affects the current context.
This is still fundamentally different from full event sourcing. Pi's entries are organized mainly around "how to restore a coding agent conversation and the model context"; they don't attempt to record every step, chunk, or tool lifecycle event during a run.
Pi's session is closer to "branchable persisted history"
Pi's session model can be compressed into four layers:
Neither the tree nor compaction directly rewrites the past.
Branching changes the current path; compaction changes how the current context is interpreted. The old entries are still there.
This gives Pi a few very practical properties:
It also clearly bounds what a session is responsible for: Pi's session mainly stores the entries needed to reconstruct conversation semantics; the complete runtime-level execution trace does not all make it into this tree.
The next article's DeepSeek Harness chose a different boundary. It puts turns, steps, raw stream chunks, tool calls, tool results, and request headers into an append-only session event log, then projects the model history through a surface. A session, then, no longer only answers "how do we restore the current conversation"; it also becomes the single source of truth for replay, UI, persistence, and request reconstruction.
References
[1] Pi Session File Format: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/session-format.md
[2] Pi Compaction: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md
[3] Pi SDK Session Restore: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/sdk.ts
[4] Pi SDK Session API: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/sdk.md