Pi's Session file appears deceptively simple on the surface: a JSONL file where each line is a JSON record.
It doesn't store a conversation directly as messages[], nor does it create separate database tables for each branch. In the current version, Session Entries connect into a tree through id/parentId, and the file continues to grow in append-only order, with the current leaf determining the current path through history. [1]
As a result, the same Session possesses two structures simultaneously:
Physical Structure
append-only JSONL
Logical Structure
entry tree
The separation of these two structures is key to understanding Pi's Session design.
First, Let's Look at the File Itself
The first line of the Session file is the Header:
{
"type": "session",
"version": 3,
"id": "...",
"timestamp": "...",
"cwd": "/project"
}
Subsequent Entries then enter the tree structure. The base fields are as follows:
interface SessionEntryBase {
type: string
id: string
parentId: string | null
timestamp: string
}
For example, a normal conversation might be written as:
line 1 session header
line 2 user message id=A parent=null
line 3 assistant message id=B parent=A
line 4 user message id=C parent=B
line 5 assistant message id=D parent=C
From the disk's perspective, it's still just sequential appends:
A
B
C
D
But parentId has already added logical relationships to these records:
A
└── B
└── C
└── D
If the user then goes back to B and continues from there, new Entries continue appending to the file tail:
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
The logical structure becomes:
A
└── B
├── C
│ └── D
│
└── E
└── F ← current leaf
The file doesn't need to rewrite C or D, nor does it need to copy A or B. Creating a branch only requires pointing the new Entry to an old node.
This is a persistence model well-suited for local Coding Agents: append writes are simple, history remains inspectable, and users can return to old positions within the same Session to continue working.
Current Session is Determined by Leaf
The tree itself contains multiple branches, but the model can't treat all branches as current history at once.
So SessionManager also needs a "current position." Pi's documentation calls this the current leaf. To construct the current path, simply traverse from leaf along parentId back to root, then reverse the order. [1]
Conceptual code is roughly:
function getPath(leaf: SessionEntry | null): SessionEntry[] {
const result: SessionEntry[] = []
let current = leaf
while (current) {
result.push(current)
current = current.parentId
? byId.get(current.parentId) ?? null
: null
}
return result.reverse()
}
For the previous tree, if leaf is F:
A
└── B
├── C
│ └── D
└── E
└── F
The current path is:
A → B → E → F
C and D are still preserved in the Session file, but they don't enter the current branch's model history.
This shows that Session File and Model Context are not the same object from the start:
Session File
A B C D E F
Current Branch
A B E F
Model Context
Constructed from A B E F through Compaction / Message conversion
Many subsequent capabilities are built on this three-layer distinction.
/tree's Essence is Moving the Current Position
Pi supports navigation within the Session Tree. After users select an earlier Entry, they can continue working from there.
From a data structure perspective, this action doesn't require modifying old Entries. SessionManager only needs to switch the parent of subsequent appends to the target node.
For example, the original path is:
A → B → C → D
↑
original path
After returning to B and continuing:
A → B → C → D
\
E → F
This design has some similarity to Git's commit graph: nodes store parent references, and the current leaf is similar to the current working position. However, Pi's Entry semantics, Context construction, and Branch Summary are all specific to Agent conversations and can't directly apply Git's full model.
More importantly, the tree structure allows "preserving old history" and "changing current Context" to happen simultaneously.
If the system uses a regular array:
messages.splice(branchIndex + 1)
When users return to an old node, subsequent history gets directly deleted.
The Session Tree preserves old branches; new input simply adds another path.
This is valuable for Coding Agents. Users might try approach A, find it unsuitable, then return to an earlier state to try approach B. The model's previous analysis and execution records can still be preserved, while the current Context can continue along branch B only.
Branch Summary Solves Information Loss After Leaving a Branch
Switching branches completely brings a problem: valuable information may have been generated on the old branch.
For example:
A → B → C → D
D discovered:
some interface cannot be modified because another module depends on it
If the user returns to B and creates E from there, the current path becomes:
A → B → E
D no longer belongs to the current path, and that valuable information won't naturally enter subsequent Context.
Pi therefore supports BranchSummaryEntry. When tree navigation needs to preserve information from a departing branch, it can generate a summary and place it as an Entry on the new path. [1]
The structure is roughly:
A
└── B
├── C
│ └── D
│
└── BranchSummary
└── E
Branch Summary expresses:
Although the old branch is no longer the current path,
part of its valuable context is summarized into the new branch.
This differs from simply leaving D in Context. The new branch doesn't inherit the old branch's complete model trajectory—it only inherits the compressed necessary information.
This reveals an important characteristic of Pi's Session Tree: the tree determines historical choices, while Summary handles passing necessary information across paths.
Compaction Also Changes Context Through Entry
Long conversations need to compress early history. Instead of deleting original Entries, Pi appends CompactionEntry. [2]
The current structure includes:
interface CompactionEntry {
type: 'compaction'
id: string
parentId: string
summary: string
firstKeptEntryId: string
tokensBefore: number
// ...
}
Assuming the current branch has:
A B C D E F G H
After Context becomes too long, the system might generate Compaction:
summary(A...E)
firstKeptEntryId = F
The Session still preserves:
A B C D E F G H Compaction
But when buildSessionContext() constructs model history, it uses:
Summary(A...E)
F
G
H
This reflects the same design once more: persistent history remains complete, while the model view can be compressed.
Pi's Compaction documentation also specifically states that the cut point cannot arbitrarily fall on Tool Results, because Tool Results must remain structurally complete with their corresponding Tool Calls. [2]
This shows that Context Builder can't consider token count alone—it must also satisfy Provider Message's structural constraints.
For example, the following history cannot be incorrectly split:
Assistant(tool_call)
--- cut ---
ToolResult
Because model protocols typically require Tool Call and Tool Result to remain valid pairs.
Therefore, Compaction actually handles two problems simultaneously:
Token budget
+
Message protocol invariants
buildSessionContext() is the True Recovery Entry Point
When a Session file is reopened, the runtime doesn't simply execute:
agent.messages = allSessionEntries
Pi's SessionManager.buildSessionContext() constructs the path from the current leaf, then interprets the Entries in the path. [1]
The documented process includes:
- Collecting the path from current leaf to root;
- Restoring model/thinking level settings on the path;
- If Compaction exists, using Summary and firstKeptEntryId to reconstruct messages;
- Converting Branch Summary, Custom Message, and other Entries into corresponding messages;
- Forming the Message History required for the current model session.
When SDK creates an Agent Session, it also first calls sessionManager.buildSessionContext() to determine if an old session exists, and restores existing messages and model-related state accordingly. [3]
So the recovery chain is closer to:
session.jsonl
↓ parse
Session Entries
↓ build indexes / current path
SessionManager
↓ buildSessionContext()
Current Session Context
↓
Agent State
↓
Agent Loop
Here, SessionManager plays an obvious Projection role: the Entry Tree on disk is the persistent truth, while Agent Runtime needs the execution state corresponding to the current branch.
Why a Single Session File Can Store the Entire Tree
Many systems would model Branch directly as a new Session:
Session A
↓ fork
Session B
Pi chooses to keep the Tree in the same file, which has clear product context.
Coding Agent users frequently need to:
View historical nodes
Return to a certain node
Try another approach
Return to the original branch
If each operation creates a new Session, it would generate many session files, and users would also need to manage multiple Session identities.
The Tree model treats these paths as different routes of the same piece of work history:
Session
└── Tree
├── branch A
├── branch B
└── branch C
When users genuinely need to extract a branch independently, they use fork/branched session to create a new Session file. Pi's Session Header also provides a parentSession field to record such inter-Session relationships. [1]
Therefore, there are two layers of branching:
Within the same Session
Entry Tree Branch
Across Sessions
Fork / parentSession
Distinguishing between them prevents mixing "historical navigation" and "creating a new long-term work unit" into the same operation.
Costs of the Tree Model
The Session Tree is lightweight, but it also introduces several constraints.
First, all Context construction must first determine the current path. It can no longer assume "the last N messages in the file are the current history."
Second, the Entry's parent chain must remain complete. A corrupted parentId directly affects current branch recovery.
Third, nodes with protocol pairing requirements like Tool Call and Tool Result cannot be arbitrarily used as Branch Boundaries. The tree structure allows returning to any node, but that doesn't mean all nodes are suitable as legitimate endpoints for model history.
Fourth, cross-branch information needs explicit handling. Branch Summary exists precisely to prevent valuable old branch information from being completely lost.
Fifth, Session Entry semantics gradually widen. Besides Messages, there are types like Compaction, Branch Summary, and setting changes. SessionManager must understand how these Entries affect current Context.
This still differs fundamentally from full Event Sourcing. Pi's Entries are primarily organized around "how to restore Coding Agent sessions and model Context," without trying to record every Step, Chunk, and Tool lifecycle event during execution.
Pi Session is Closer to "Branchable Persistent History"
Pi's Session model can be condensed into four layers:
JSONL
Physical append storage
↓
Entry Tree
Preserves complete branchable history
↓
Current Path
Selects current branch
↓
buildSessionContext()
Constructs history needed by current model
Neither Tree nor Compaction directly rewrites the past.
Branch changes the current path, Compaction changes how current Context is interpreted, and old Entries are preserved.
This gives Pi several very practical properties:
Simple file format
History can be manually inspected
Branches don't need to copy prefixes
Compaction doesn't destroy original history
Recovery logic concentrated in SessionManager
It also clearly limits the Session's scope: Pi Session primarily stores Entries that can reconstruct conversation semantics, while Runtime-level complete execution traces don't all enter this tree.
The next article's DeepSeek Harness takes a different boundary. It puts Turn, Step, Raw Stream Chunk, Tool Call, Tool Result, and Request Header all into an append-only SessionEvent Log, then projects model history through Surface. This makes Session responsible not only for "how to restore the current conversation" but also becomes the unified 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