TL;DRSessionManager core: JSONL append-only storage, id/parentId tree formation, branch() moves leaf pointer without mutating history, buildSessionContext() handles compaction entries, createBranchedSession() forks to new file. Complete Entry types: message, thinking_level_change, model_change, compaction, branch_summary, custom, custom_message, label, session_info. Migration v1→v2→v3 details.
Series: pi-mono Deep Dive (5 / 17)
🌏 中文版
TL;DR
- Storage Format: JSONL (one Entry per line),
~/.pi/agent/sessions/--cwd--/, filenameISO8601_sessionId.jsonl - Tree Structure: Each Entry has
id,parentId; append creates child of current leaf - Branch:
branch(id)only movesleafIdpointer, history completely unchanged - Compaction:
compactionentry withfirstKeptEntryId,tokensBefore;buildContextEntries()skips summarized segment - Fork:
createBranchedSession(leafId)copies root→leaf path to new file, cross-project capable - Migration: v1 (no id) → v2 (add id/parentId) → v3 (hookMessage→custom)
Why Append-only Tree?
| Need | Traditional Linear History | Append-only Tree |
|---|---|---|
| Replay & Edit | ❌ Only truncate tail | ✅ Jump to any node, continue |
| Experimental Attempts | ❌ Pollutes mainline | ✅ /branch creates parallel timeline |
| Compaction | ❌ Loses detail | ✅ Keeps summary + key segments |
| Fork to New Project | ❌ Copy entire file | ✅ Copy only needed path |
| Bookmarks/Labels | ❌ External | ✅ label entry built-in |
JSONL File Format
Directory Structure
~/.pi/agent/sessions/
└── --Users-xiaoxu-Projects-myproject--/
├── 2026-08-31T10-30-00_a1b2c3d4.jsonl
└── 2026-08-30T14-22-11_e5f6g7h8.jsonl
- Dir name:
--+cwdwith separators→-+-- - Filename:
timestamp_sessionId.jsonl(timestamp:→-)
Entry Common Fields
// packages/coding-agent/src/core/session-manager.ts
export interface SessionEntryBase {
type: string;
id: string; // 8-char hex (UUID first 8, retry on collision)
parentId: string | null; // Parent node id, root = null
timestamp: string; // ISO8601
}
All Entry Types
// 1. Session Header (first line of file)
export interface SessionHeader {
type: "session";
version?: number; // v1 lacks this
id: string; // session id
timestamp: string;
cwd: string;
parentSession?: string; // fork source
}
// 2. Regular Message
export interface SessionMessageEntry extends SessionEntryBase {
type: "message";
message: AgentMessage; // user/assistant/toolResult
}
// 3. Thinking Level Change
export interface ThinkingLevelChangeEntry extends SessionEntryBase {
type: "thinking_level_change";
thinkingLevel: string; // "off" | "low" | "medium" | "high"
}
// 4. Model Switch Record
export interface ModelChangeEntry extends SessionEntryBase {
type: "model_change";
provider: string;
modelId: string;
}
// 5. Compaction Summary Point
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
type: "compaction";
summary: string; // Summary text
firstKeptEntryId: string; // Kept segment start entry id
tokensBefore: number; // Tokens before compression
details?: T; // Extension-specific data
usage?: Usage; // LLM usage for summary generation
fromHook?: boolean; // Extension-generated vs built-in
}
// 6. Branch Summary (abandoned path summary on branch)
export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
type: "branch_summary";
fromId: string; // Discarded path leaf id
summary: string;
details?: T;
usage?: Usage;
fromHook?: boolean;
}
// 7. Extension Internal State (NOT in LLM Context)
export interface CustomEntry<T = unknown> extends SessionEntryBase {
type: "custom";
customType: string; // Extension identifier
data?: T;
}
// 8. Extension Injected Message (IN LLM Context)
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
type: "custom_message";
customType: string;
content: string | ContentBlock[];
details?: T;
display: boolean; // Whether TUI shows it
}
// 9. Label Bookmark
export interface LabelEntry extends SessionEntryBase {
type: "label";
targetId: string; // Marked entry id
label: string | undefined; // undefined = remove label
}
// 10. Session Info (Display Name)
export interface SessionInfoEntry extends SessionEntryBase {
type: "session_info";
name?: string; // Empty string = clear name
}
Core Tree Traversal Algorithms
1. Build Index
function buildEntryIndex(entries: SessionEntry[], byId?: Map<string, SessionEntry>): Map<string, SessionEntry> {
if (byId) return byId;
const index = new Map<string, SessionEntry>();
for (const entry of entries) {
index.set(entry.id, entry);
}
return index;
}
2. Leaf to Root Path (buildSessionPath)
function buildSessionPath(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map<string, SessionEntry>,
): SessionEntry[] {
const index = buildEntryIndex(entries, byId);
let leaf: SessionEntry | undefined;
if (leafId === null) return [];
if (leafId) leaf = index.get(leafId);
leaf ??= entries[entries.length - 1]; // Default latest entry
if (!leaf) return [];
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
path.push(current);
current = current.parentId ? index.get(current.parentId) : undefined;
}
path.reverse(); // Root → Leaf order
return path;
}
3. Get Session Context Settings (Thinking Level, Model)
function getSessionContextSettings(path: SessionEntry[]): Pick<SessionContext, "thinkingLevel" | "model"> {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
for (const entry of path) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel;
} else if (entry.type === "model_change") {
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
}
}
return { thinkingLevel, model };
}
4. Entry → Context Messages (sessionEntryToContextMessages)
export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {
if (entry.type === "message") {
const message = entry.message;
// Defensive: old versions may have null content
if ((message.role === "user" || message.role === "assistant" || message.role === "toolResult") && message.content == null) {
return [{ ...message, content: [] }];
}
return [message];
}
if (entry.type === "custom_message") {
return [createCustomMessage(entry.customType, entry.content ?? [], entry.display, entry.details, entry.timestamp)];
}
if (entry.type === "branch_summary" && entry.summary) {
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
}
if (entry.type === "compaction") {
return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
}
// custom, label, session_info, thinking_level_change, model_change → no context
return [];
}
5. Compaction-Aware Context Entries (Core Logic)
export function buildContextEntries(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map<string, SessionEntry>,
): SessionEntry[] {
const path = buildSessionPath(entries, leafId, byId);
let compaction: CompactionEntry | null = null;
// Find latest compaction entry
for (const entry of path) {
if (entry.type === "compaction") {
compaction = entry;
}
}
if (!compaction) return path; // No compaction, return full path
const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);
if (compactionIdx < 0) return path;
// Build: [compaction] + [entries after firstKeptEntryId] + [entries after compaction]
const contextEntries: SessionEntry[] = [compaction];
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = path[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) {
contextEntries.push(entry);
}
}
contextEntries.push(...path.slice(compactionIdx + 1));
return contextEntries;
}
Illustration:
Path: [msg1] → [msg2] → [msg3] → [compaction: firstKept=msg3] → [msg4] → [msg5] → [msg6]
↑
Context: [compaction] → [msg3] → [msg4] → [msg5] → [msg6]
(msg1, msg2 summarized, no longer in LLM)
6. Complete Session Context
export function buildSessionContext(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map<string, SessionEntry>,
): SessionContext {
const path = buildSessionPath(entries, leafId, byId);
const { thinkingLevel, model } = getSessionContextSettings(path);
const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages);
return { messages, thinkingLevel, model };
}
SessionManager Class Core Operations
Initialization & Persistence
export class SessionManager {
private sessionId: string = "";
private sessionFile: string | undefined;
private sessionDir: string;
private cwd: string;
private persist: boolean;
private flushed: boolean = false; // Whether written to file
private fileEntries: FileEntry[] = [];
private byId: Map<string, SessionEntry> = new Map();
private labelsById: Map<string, string> = new Map();
private labelTimestampsById: Map<string, string> = new Map();
private leafId: string | null = null;
// Create new Session
newSession(options?: NewSessionOptions): string | undefined {
this.sessionId = options?.id ?? createSessionId();
const timestamp = new Date().toISOString();
const header: SessionHeader = {
type: "session",
version: CURRENT_SESSION_VERSION, // 3
id: this.sessionId,
timestamp,
cwd: this.cwd,
parentSession: options?.parentSession,
};
this.fileEntries = [header];
this.byId.clear();
this.labelsById.clear();
this.labelTimestampsById.clear();
this.leafId = null;
this.flushed = false;
if (this.persist) {
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
}
return this.sessionFile;
}
// Open existing Session file
static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
// Read header, load entries, Migration, build index
}
}
Append Operations (All Create Child of Current Leaf)
// Generic append
private _appendEntry(entry: SessionEntry): void {
this.fileEntries.push(entry);
this.byId.set(entry.id, entry);
this.leafId = entry.id;
this._persist(entry);
}
// Concrete methods
appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {
const entry: SessionMessageEntry = {
type: "message",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
message,
};
this._appendEntry(entry);
return entry.id;
}
appendThinkingLevelChange(thinkingLevel: string): string { ... }
appendModelChange(provider: string, modelId: string): string { ... }
appendCompaction<T>(summary: string, firstKeptEntryId: string, tokensBefore: number, details?: T, fromHook?: boolean, usage?: Usage): string {
const entry: CompactionEntry<T> = {
type: "compaction",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
summary,
firstKeptEntryId,
tokensBefore,
details,
usage,
fromHook,
};
this._appendEntry(entry);
return entry.id;
}
appendCustomEntry(customType: string, data?: unknown): string { ... }
appendSessionInfo(name: string): string { ... }
appendCustomMessageEntry(customType: string, content: string | ContentBlock[], display: boolean, details?: unknown): string { ... }
Persist Strategy: No File Until First Assistant Response
private _persist(entry: SessionEntry): void {
if (!this.persist || !this.sessionFile) return;
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
if (!hasAssistant) {
if (this.flushed) {
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
} else {
this.flushed = false; // Wait for Assistant, then write all accumulated entries at once
}
return;
}
if (!this.flushed) {
// First Assistant: create file, write all accumulated entries
const fd = openSync(this.sessionFile, "wx");
try {
for (const e of this.fileEntries) writeFileSync(fd, `${JSON.stringify(e)}\n`);
} finally { closeSync(fd); }
this.flushed = true;
} else {
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
}
}
Branch / Reset / Fork Core Operations
Branch: Move Leaf Pointer
// Start new branch from specified entry
branch(branchFromId: string): void {
if (!this.byId.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
this.leafId = branchFromId; // Only move pointer, no entry modified
}
// Branch with Summary (record abandoned path)
branchWithSummary(
branchFromId: string | null,
summary: string,
details?: unknown,
fromHook?: boolean,
usage?: Usage,
): string {
if (branchFromId !== null && !this.byId.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
const fromId = this.leafId ?? "root";
this.leafId = branchFromId;
const entry: BranchSummaryEntry = {
type: "branch_summary",
id: generateId(this.byId),
parentId: branchFromId,
timestamp: new Date().toISOString(),
fromId,
summary,
details,
usage,
fromHook,
};
this._appendEntry(entry);
return entry.id;
}
// Reset to Root (parentId = null)
resetLeaf(): void {
this.leafId = null;
}
Fork: Copy Path to New File
createBranchedSession(leafId: string): string | undefined {
const path = this.getBranch(leafId); // Root → Leaf path
if (path.length === 0) throw new Error(`Entry ${leafId} not found`);
// Filter LabelEntry, relink parentId
const pathWithoutLabels: SessionEntry[] = [];
let pathParentId: string | null = null;
for (const entry of path) {
if (entry.type === "label") continue;
pathWithoutLabels.push({ ...entry, parentId: pathParentId });
pathParentId = entry.id;
}
// New Session Header
const newSessionId = createSessionId();
const timestamp = new Date().toISOString();
const header: SessionHeader = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: newSessionId,
timestamp,
cwd: this.cwd,
parentSession: this.persist ? this.sessionFile : undefined, // Record source
};
// Collect Labels on path
const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));
const labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = [];
for (const [targetId, label] of this.labelsById) {
if (pathEntryIds.has(targetId)) {
labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! });
}
}
if (this.persist) {
// Build Label Entries (append at path end)
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
let parentId = lastEntryId;
const labelEntries: LabelEntry[] = [];
for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
const labelEntry: LabelEntry = {
type: "label",
id: generateId(new Set(pathEntryIds)),
parentId,
timestamp: labelTimestamp,
targetId,
label,
};
pathEntryIds.add(labelEntry.id);
labelEntries.push(labelEntry);
parentId = labelEntry.id;
}
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
this.sessionId = newSessionId;
this.sessionFile = newSessionFile;
this._buildIndex();
// Write immediately only if has Assistant
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
if (hasAssistant) { this._rewriteFile(); this.flushed = true; }
else { this.flushed = false; }
return newSessionFile;
}
// In-memory mode...
}
Read Optimizations: Header Scan Limit & Streaming Parse
Header Bounded Scan (Avoid Reading Large Files)
const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024; // 1MB
function readSessionHeader(filePath: string): SessionHeader | null {
const fd = openSync(filePath, "r");
try {
const decoder = new StringDecoder("utf8");
const buffer = Buffer.allocUnsafe(4096);
const lineChunks: string[] = [];
let scannedBytes = 0;
while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) {
const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes);
const bytesRead = readSync(fd, buffer, 0, readLength, null);
if (bytesRead === 0) {
lineChunks.push(decoder.end());
return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
}
scannedBytes += bytesRead;
// ... Parse line by line, return on first valid session header
}
throw new SessionHeaderScanLimitError(filePath);
} finally { closeSync(fd); }
}
Streaming Parse Large Files (loadEntriesFromFile)
export function loadEntriesFromFile(filePath: string): FileEntry[] {
const fd = openSync(filePath, "r");
try {
const decoder = new StringDecoder("utf8");
const buffer = Buffer.allocUnsafe(1024 * 1024); // 1MB buffer
const entries: FileEntry[] = [];
let pending = "";
while (true) {
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
if (bytesRead === 0) break;
pending += decoder.write(buffer.subarray(0, bytesRead));
let lineStart = 0;
let newlineIndex = pending.indexOf("\n", lineStart);
while (newlineIndex !== -1) {
const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
if (entry) entries.push(entry);
lineStart = newlineIndex + 1;
newlineIndex = pending.indexOf("\n", lineStart);
}
pending = pending.slice(lineStart);
}
pending += decoder.end();
const finalEntry = parseSessionEntryLine(pending);
if (finalEntry) entries.push(finalEntry);
// Validate Header
if (entries.length === 0) return entries;
const header = entries[0];
if (header.type !== "session" || typeof header.id !== "string") return [];
return entries;
} finally { closeSync(fd); }
}
Migration: v1 → v2 → v3
v1 → v2: Add id/parentId Tree Structure
function migrateV1ToV2(entries: FileEntry[]): void {
const ids = new Set<string>();
let prevId: string | null = null;
for (const entry of entries) {
if (entry.type === "session") { entry.version = 2; continue; }
entry.id = generateId(ids);
entry.parentId = prevId;
prevId = entry.id;
// Compaction: firstKeptEntryIndex → firstKeptEntryId
if (entry.type === "compaction") {
const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
if (typeof comp.firstKeptEntryIndex === "number") {
const targetEntry = entries[comp.firstKeptEntryIndex];
if (targetEntry && targetEntry.type !== "session") {
comp.firstKeptEntryId = targetEntry.id;
}
delete comp.firstKeptEntryIndex;
}
}
}
}
v2 → v3: hookMessage role → custom
function migrateV2ToV3(entries: FileEntry[]): void {
for (const entry of entries) {
if (entry.type === "session") { entry.version = 3; continue; }
if (entry.type === "message") {
const msgEntry = entry as SessionMessageEntry;
if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") {
(msgEntry.message as { role: string }).role = "custom";
}
}
}
}
Session Discovery & Listing
Find Most Recent Session
export function findMostRecentSession(sessionDir: string, cwd?: string): string | null {
const files = readdirSync(sessionDir)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => join(sessionDir, f))
.map((path) => ({ path, header: readSessionHeaderForDiscovery(path) }))
.filter((file): file is { path: string; header: SessionHeader } =>
file.header !== null &&
(!cwd || sessionCwdMatches(getSessionHeaderCwd(file.header), cwd))
)
.map(({ path }) => ({ path, mtime: statSync(path).mtime }))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
return files[0]?.path || null;
}
Concurrent Session Info Loading (for Listing)
const MAX_CONCURRENT_SESSION_INFO_LOADS = 10;
async function buildSessionInfosWithConcurrency(
files: string[],
onLoaded: () => void,
): Promise<(SessionInfo | null)[]> {
const results: (SessionInfo | null)[] = new Array(files.length).fill(null);
const inFlight = new Set<Promise<void>>();
let nextIndex = 0;
const startNext = () => {
const index = nextIndex++;
const file = files[index];
if (!file) return;
let task = buildSessionInfo(file)
.then((info) => { results[index] = info; })
.catch(() => { results[index] = null; })
.finally(() => { inFlight.delete(task); onLoaded(); });
inFlight.add(task);
};
while (nextIndex < files.length || inFlight.size > 0) {
while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) {
startNext();
}
if (inFlight.size > 0) await Promise.race(inFlight);
}
return results;
}
References
- GitHub - earendil-works/pi — packages/coding-agent/src/core/session-manager.ts
- Pi Official Docs: Session Management
- JSON Lines Format Specification
- Append-only Data Structure Design Patterns
- UUID v7 Time-ordered Properties
Next Up
Part 6: Tool System — Definition, Execution, Parallel/Sequential, Before/After Hooks
How are 8 core tools defined?
ToolDefinitionvsAgentTooldifference?createToolDefinition/createToolFactory pattern? How doesexecutionMode: "parallel" | "sequential"decide? How dobeforeToolCall/afterToolCallintercept and modify? How doeswithFileMutationQueueserialize file writes?truncateHead/Line/Tailoutput truncation strategies?
Glossary
Loading...