💾 Session Persistence
Close the terminal, reopen it, type --resume — the conversation continues exactly where you left off
When you close your terminal and type /resume, the agent picks up exactly where it left off — same conversation, same file context, same pending tasks. Behind this is a session persistence system that serializes multi-domain state to JSON and reconstructs it on demand.
- Sessions stored as JSONL in
~/.claude/projects/<project>/<id>.jsonl - Contains: message history, file snapshots, model, cost tracking, timestamps, working directory
/resumecreates a new QueryEngine with saved messages as initialMessages
Session Lifecycle
What you are seeing
The complete lifecycle of a session: creation, state accumulation during the conversation, persistence to disk, and reconstruction via /resume. Notice the multi-domain state — it's not just messages.
What to try
Trace what gets saved beyond just messages. File history, todos, cost tracking, and working directory are all persisted. On resume, each domain is reconstructed independently.
# Session lifecycle
CREATE session abc-123
cwd: /Users/dev/myproject
model: claude-opus-4
# During conversation...
Turn 1: user asks to fix bug
+ message history (2 msgs)
+ file_history: [src/auth.ts]
+ cost: $0.12
Turn 5: user asks to add tests
+ message history (10 msgs)
+ file_history: [src/auth.ts, test/auth.test.ts]
+ todos: ["add edge case test"]
+ cost: $0.87
SAVE to ~/.claude/projects/-Users-dev-myproject/abc-123.jsonl
Size: 128KB (events appended line-by-line)
# Later...
RESUME abc-123
1. Read JSONL from disk
2. Deserialize 10 messages → initialMessages
3. Restore file_history, todos, cost, cwd
4. Create new QueryEngine
Ready in 340ms
The Intuition
The Stakes
You have been debugging for 2 hours — 50 tool calls, 30 files read, the agent understands your whole codebase context. Your laptop dies.
Without persistence
Everything gone. Start over from scratch. All accumulated context lost.
With session JSON
claude --resume→ conversation continues exactly where you left off, file history intact, cost tracking preserved.
Session Storage
Sessions are stored as JSONL files (one JSON event per line) under ~/.claude/projects/<sanitized-cwd>/<sessionId>.jsonl. Each line appends a new event — the message array, model used, cumulative cost, files touched, working directory, and timestamps are all recorded in this append-only transcript. The session ID is a UUID.
Multi-Domain State
The key insight is that a session isn't just messages. It's a multi-domain snapshot: conversation history, file history (which files were read or modified), attribution data, extracted todo items, worktree state, model overrides, and cost tracking. Each domain is serialized independently and reconstructed on resume.
The /resume Flow
When you type /resume, the system reads the session JSON, deserializes the message array, and creates a new QueryEngine with those messages as initialMessages. The API sees these as prior conversation context. Then auxiliary state is restored: file history, todos, cost counters. A new session ID is created that inherits the old context (adoption).
~/.claude/history.jsonl. It persists across all sessions — your previous inputs are always available regardless of which session you resume.Todo Extraction
On resume, the system parses the conversation transcript to find pending items — things the agent said it would do but hasn't completed. This is extracted from message content, not stored as a separate field, making it resilient to schema changes.
Schema Versioning and Forward Compatibility
Session files accumulate over weeks of use. When Claude Code ships a new release that adds fields to the session schema (e.g., adding worktree_statein v2), older session files won't have that field. The loader handles this with a schema_version field in every session JSON. On load, it checks the version and runs a migration chain: v1 sessions get default values for new fields, renamed fields are remapped, and removed fields are dropped. The migration is lazy — it runs at load time, not as a bulk migration of all files on install. This avoids the risk of a failed bulk migration corrupting hundreds of session files. For corruption recovery, the loader uses a two-pass strategy: first try full deserialization; if that throws, fall back to extracting just the messages array (the most valuable part) and discarding auxiliary state. A corrupt session that loses its todo list is far better than one that fails to load at all.
Session Adoption and Context Chaining
When you /resume a session, a new session ID is created that stores a parentId pointer to the original. The old session file is never modified — it stays as a read-only historical record. This means you can resume the same session multiple times (e.g., two different continuation branches after the same starting point) without conflicts. Cost tracking starts fresh in the new session but can display cumulative cost across the chain by walking the parentIdpointers. The design mirrors git's immutable commit graph: each session is a commit, each resume is a branch.
What does /resume actually do under the hood?
Key Code Patterns
Session Storage (TypeScript pseudocode)
import fs from "fs";
import path from "path";
import os from "os";
// Sessions live under ~/.claude/projects/<sanitized-cwd>/<sessionId>.jsonl
const PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
function getSessionPath(projectDir: string, sessionId: string): string {
return path.join(PROJECTS_DIR, projectDir, `${sessionId}.jsonl`);
}
class SessionStorage {
// Persist session: append one JSON event per line (JSONL)
save(projectDir: string, sessionId: string, state: SessionState): void {
const filePath = getSessionPath(projectDir, sessionId);
const event = {
id: sessionId,
messages: serializeMessages(state.messages),
model: state.model,
cost_usd: state.totalCost,
file_history: state.filesTouched,
created_at: state.createdAt,
updated_at: new Date().toISOString(),
cwd: state.workingDirectory,
};
fs.appendFileSync(filePath, JSON.stringify(event) + "\n");
}
// Reconstruct session state: read all lines, use last event
restore(projectDir: string, sessionId: string): QueryEngine {
const filePath = getSessionPath(projectDir, sessionId);
const lines = fs.readFileSync(filePath, "utf-8").trim().split("\n");
const data = JSON.parse(lines[lines.length - 1]!);
// Reconstruct multi-domain state
const engine = new QueryEngine({
initialMessages: deserializeMessages(data.messages),
model: data.model,
cwd: data.cwd,
});
// Restore auxiliary state
restoreFileHistory(data.file_history);
restoreAttribution(data);
extractPendingTodos(data.messages);
return engine;
}
// List recent sessions for selection
listRecent(projectDir: string, limit: number = 20): SessionMetadata[] {
const dir = path.join(PROJECTS_DIR, projectDir);
const files = fs.readdirSync(dir)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtime }))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.slice(0, limit);
return files.map((f) => this.readMetadata(f.f));
}
}Input History (separate from sessions)
// Arrow-key recall of previous inputs — persists across all sessions
class InputHistory {
// ~/.claude/history.jsonl — shared across all projects/sessions
private historyPath: string = path.join(os.homedir(), ".claude", "history.jsonl");
private entries: string[] = this.load();
private cursor: number = this.entries.length;
add(inputText: string): void {
this.entries.push(inputText);
this.save(); // append to file immediately
}
// Up/down arrow through history
navigate(direction: "up" | "down"): string {
if (direction === "up") {
this.cursor = Math.max(0, this.cursor - 1);
} else {
this.cursor = Math.min(this.entries.length - 1, this.cursor + 1);
}
return this.entries[this.cursor];
}
private load(): string[] {
if (fs.existsSync(this.historyPath)) {
// Each line is a JSON object; extract the display field for arrow-key recall
return fs.readFileSync(this.historyPath, "utf-8")
.split("\n")
.filter(Boolean)
.map((line) => { try { return JSON.parse(line).display ?? line; } catch { return line; } });
}
return [];
}
private save(): void {
// Keep last 100 entries (MAX_HISTORY_ITEMS in source)
const recent = this.entries.slice(-100);
fs.writeFileSync(this.historyPath, recent.map((e) => JSON.stringify({ display: e })).join("\n") + "\n");
}
}Session Adoption (resume creates new ID)
// Resume creates a NEW session that inherits old context
function resumeSession(oldSessionId: string): Session {
const storage = new SessionStorage();
const oldData = storage.restore(oldSessionId);
// New session ID — the old session is read-only now
const newSessionId = generateUuid();
// The new session starts with old messages as context
const newSession = new Session({
id: newSessionId,
parentId: oldSessionId, // adoption link
initialMessages: oldData.messages,
model: oldData.model,
cwd: oldData.cwd,
});
// Cost tracking starts fresh for the new session,
// but we can show cumulative cost across the chain
newSession.inheritedCost = oldData.cost_usd;
return newSession;
}Break It — See What Happens
Real-World Numbers
| Metric | Value |
|---|---|
| Session JSON size | 50KB - 5MB typical |
| Storage location | ~/.claude/projects/<project>/ |
| Resume latency | <500ms |
| Input history entries | Last 100 stored (MAX_HISTORY_ITEMS) |
| State domains persisted | 7+ (messages, files, cost, todos, ...) |
| Accumulated sessions | Hundreds over weeks of use |
Key Takeaways
What to remember for interviews
- 1A session is multi-domain state, not just messages: conversation history, file history, todos, cost tracking, model, working directory, and worktree state are all persisted.
- 2/resume deserializes the message array and injects it as initialMessages into a fresh QueryEngine — the API sees prior context without replaying any API calls.
- 3Session adoption creates a new session ID with a parentId pointer to the original, keeping the old file as an immutable record — the same immutable graph model as git commits.
- 4Schema versioning with lazy migration (run at load time, not on install) handles breaking changes; a two-pass fallback extracts just the messages array if full deserialization fails.
- 5Input history (arrow-key recall) is stored separately in ~/.claude/history.jsonl and persists across all sessions, keeping the last 100 entries regardless of which session is active.
Further Reading
- Event Sourcing Pattern — Martin Fowler — storing state as a sequence of events, the pattern behind session replay.
- SQLite Write-Ahead Logging — The WAL mechanism that enables concurrent reads during writes — relevant to session checkpoint design.
- Redis Persistence: RDB vs AOF — Two persistence strategies (snapshot vs append-only) that mirror the session save tradeoffs.
- Claude Code (source) — Open-source reference for session persistence, /resume, and multi-domain state reconstruction.
- CQRS and Event Sourcing (Microsoft Azure Docs) — Command/Query Responsibility Segregation with event sourcing — the pattern behind session replay: store events, not snapshots, then replay to reconstruct state.
- SQLite: The Appropriate Uses for SQLite — SQLite's own guide on when it outperforms full client-server databases — explains why embedded SQLite is the right call for local session storage.
- tmux Session Management — The gold standard for terminal session persistence — background processes, detach/attach, and named sessions; the UX model Claude Code's /resume mirrors.
Interview Questions
Showing 4 of 4
Design a session persistence system for an AI agent that handles multi-domain state.
★★★How would you handle session corruption or migration when the schema changes?
★★☆What's the tradeoff between saving every turn vs saving on exit?
★☆☆Design a session persistence format that allows resuming a conversation after a crash, including in-flight tool calls.
★★★