Session JSONL Tree
A session is a durable tree, not a log
“Branch in place — never copy a file”
Deep Dive
A session is a persistent tree, not a log — in-place branching never modifies files
Learning Objectives
After this chapter you will be able to:
- Explain how JSONL append-only + id/parentId forms a tree structure
- Describe the zero-copy forking implementation of /fork
- Explain how buildContextEntries reconstructs context by walking the chain
- Understand why JSONL was chosen over SQLite as the default storage
Key Source Files
| File | Lines | Responsibility |
|---|---|---|
packages/coding-agent/src/core/session-manager.ts | 1712 | Session management: create/list/fork/migrate |
packages/agent/src/harness/session/session.ts | 359 | Session abstraction: append/read/buildContext |
packages/agent/src/harness/session/jsonl-storage.ts | 376 | JSONL file I/O: append-only + line parsing |
Design Motivation: Why a Tree, Not a Log?
Problem: Traditional chat is a linear log. But coding agents have different usage patterns:
- Users want to "go back to turn 5 and try again" (fork)
- Users want to "explore two approaches simultaneously" (parallel branches)
- After compaction, originals must be recoverable (→ Chapter 3)
Linear log's flaw: Going back = truncating the file = losing subsequent history. Irreversible.
pi's solution: Each record carries id + parentId, forming a directed acyclic tree. Going back is just "switching to a different leaf node" — all history persists forever.
Analogy: Git's commit graph — each commit points to its parent; branching just moves a pointer. pi's session tree is "Git for conversations."
Data Structure: Append-Only + Tree Pointers
~/.pi/agent/sessions/<project>/<session-id>.jsonl
Line 1: {"type":"session","id":"s1","version":3,"createdAt":...}
Line 2: {"type":"message","id":"A","parentId":null,"role":"user","content":"fix bug"}
Line 3: {"type":"message","id":"B","parentId":"A","role":"assistant","content":"..."}
Line 4: {"type":"compaction","id":"C","parentId":"B","summary":"...","readFiles":[...]}
Line 5: {"type":"message","id":"D","parentId":"C","role":"user","content":"continue"}
Line 6: {"type":"label","id":"E","parentId":"D","name":"fork-1"} ← fork point
Entry types (10): message, compaction, model_change, thinking_level_change, branch_summary, label, active_tools_change, info, custom, session(header).
Benefits of append-only:
- Never modifies written lines → crash-safe (at most the last line is lost)
- No transactions/locks needed → simple performance
- The file IS the complete audit log
In-Place Forking: Zero Copy
Before /fork: After /fork:
A → B → C → D A → B → C → D
\
E (label: "fork-1") → F → G (new branch)
/fork implementation:
- Generate a new session ID
- Write a
sessionheader withparentSessionpointing to the original - Write a
branch_summarycontaining a summary of severed history (→ Chapter 3) - Subsequent messages append to the new file
Original file untouched. Disk cost = new branch's delta, not a full copy.
buildContextEntries: Chain Reconstruction
function buildContextEntries(session: Session, leafId: string): SessionEntry[] {
const chain: SessionEntry[] = []
let current = leafId
// ① Walk parentId chain back to root
while (current) {
const entry = session.get(current)
chain.unshift(entry)
current = entry.parentId
}
// ② Forward pass, transform at compaction entries
const context: SessionEntry[] = []
for (const entry of chain) {
if (entry.type === "compaction") {
// Replace all previously compacted entries with the summary
context.length = 0
context.push({ type: "system", content: entry.summary })
} else {
context.push(entry)
}
}
return context
}
Key insight: From any leaf, you can reconstruct "the context the model should see." A compaction entry is a "transform point" — everything before it is replaced by its summary.
Why JSONL Over SQLite?
| Dimension | JSONL | SQLite |
|---|---|---|
| Crash safety | Append-only inherently safe | Requires WAL configuration |
| Debuggability | cat/jq directly | Needs tooling |
| Zero dependencies | Node fs suffices | Requires better-sqlite3 |
| Concurrent writes | Unsupported (single process suffices) | Supported |
| Query capability | Full scan | Indexed |
pi defaults to JSONL because a coding agent is single-user, single-process — no concurrent writes needed, no complex queries. But pi also provides a SQLite backend (→ Chapter 20) for server mode.
Engineering Insights
-
CURRENT_SESSION_VERSION = 3: The session format has iterated 3 versions.
migrateSessionEntriesauto-upgrades old formats on load — forward-compatible without losing history. -
1712 lines of session-manager: Most is "list/search/sort/filter" UX logic — listing all sessions, grouping by project, showing last-active time. Persistence itself is only ~300 lines.
-
The elegance of label entries: Users can tag any node ("v1 approach", "before refactor"), then jump via labels later — friendlier than remembering commit hashes.
Cross-Chapter Links
| Direction | Chapter | Relationship |
|---|---|---|
| Prerequisite | Ch02 AgentHarness | Committed state written to Session |
| Prerequisite | Ch03 Compaction | Compaction entries are transform nodes on the tree |
| Next | Ch11 AgentSession | AgentSession manages sessions via SessionManager |
| Next | Ch20 SQL Backend | Server mode replaces JSONL with SQLite |
Check Your Understanding
- If a user runs
/forkthen continues on the original branch, do the two branches' files affect each other? Why or why not? - Why does buildContextEntries execute
context.length = 0when encountering a compaction entry? - Why did pi choose JSONL over SQLite as default storage? In what scenario would JSONL become a bottleneck?
Key Takeaways
- JSONL append-only + id/parentId = persistent tree, crash-safe, zero-dependency
- /fork zero-copy branching: only appends a new file, original untouched
- buildContextEntries reconstructs along the chain; compaction is the transform point
- For single-user single-process scenarios, JSONL is simpler and more reliable than SQLite
This article is based on source analysis of earendil-works/pi main branch.