Skip to content

Transformer Math

Module 63 · AI Engineering

💾 Session Persistence

Close the terminal, reopen it, type --resume — the conversation continues exactly where you left off

Status:

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
  • /resume creates 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.

💡 Tip · Sessions are typically 50KB-5MB depending on conversation length. Long coding sessions with many tool results can grow larger because tool_result content (file contents, grep output) is stored verbatim in the message array.

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

✨ Insight · Input history (arrow-key recall) is stored separately from sessions in ~/.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.

Quick Check

What does /resume actually do under the hood?

📐

Key Code Patterns

Session Storage (TypeScript pseudocode)

typescript
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)

typescript
// 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)

typescript
// 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

No session persistence
Messages only (no auxiliary state)
📊

Real-World Numbers

MetricValue
Session JSON size50KB - 5MB typical
Storage location~/.claude/projects/<project>/
Resume latency<500ms
Input history entriesLast 100 stored (MAX_HISTORY_ITEMS)
State domains persisted7+ (messages, files, cost, todos, ...)
Accumulated sessionsHundreds over weeks of use
✨ Insight · Session files accumulate over time and are never automatically deleted. Heavy users can have hundreds of session files totaling 100MB+. A pruning strategy (delete sessions older than 30 days, or keep only the last 50) would help, but risks deleting sessions users want to resume.
🧠

Key Takeaways

What to remember for interviews

  1. 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. 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.
  3. 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.
  4. 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.
  5. 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

Difficulty:
Company:

Showing 4 of 4

Design a session persistence system for an AI agent that handles multi-domain state.

★★★
Anthropic

How would you handle session corruption or migration when the schema changes?

★★☆
Google

What's the tradeoff between saving every turn vs saving on exit?

★☆☆
OpenAI

Design a session persistence format that allows resuming a conversation after a crash, including in-flight tool calls.

★★★
Anthropic