ππ Agent
Chapter 4 / 21

Session JSONL Tree

A session is a durable tree, not a log

Branch in place — never copy a file
No simulation scenario available for this chapter

Deep Dive

~6 min read·6489 chars

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

FileLinesResponsibility
packages/coding-agent/src/core/session-manager.ts1712Session management: create/list/fork/migrate
packages/agent/src/harness/session/session.ts359Session abstraction: append/read/buildContext
packages/agent/src/harness/session/jsonl-storage.ts376JSONL 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:

  1. Generate a new session ID
  2. Write a session header with parentSession pointing to the original
  3. Write a branch_summary containing a summary of severed history (→ Chapter 3)
  4. 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?

DimensionJSONLSQLite
Crash safetyAppend-only inherently safeRequires WAL configuration
Debuggabilitycat/jq directlyNeeds tooling
Zero dependenciesNode fs sufficesRequires better-sqlite3
Concurrent writesUnsupported (single process suffices)Supported
Query capabilityFull scanIndexed

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

  1. CURRENT_SESSION_VERSION = 3: The session format has iterated 3 versions. migrateSessionEntries auto-upgrades old formats on load — forward-compatible without losing history.

  2. 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.

  3. 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

DirectionChapterRelationship
PrerequisiteCh02 AgentHarnessCommitted state written to Session
PrerequisiteCh03 CompactionCompaction entries are transform nodes on the tree
NextCh11 AgentSessionAgentSession manages sessions via SessionManager
NextCh20 SQL BackendServer mode replaces JSONL with SQLite

Check Your Understanding

  1. If a user runs /fork then continues on the original branch, do the two branches' files affect each other? Why or why not?
  2. Why does buildContextEntries execute context.length = 0 when encountering a compaction entry?
  3. 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.