Skip to content

Session Resumption

Session resumption is the ability to continue a previous conversation with full context intact. It sounds simple — reload the messages and keep going — but the details are where implementations diverge. What happens to tool call results? How do you handle a conversation that was compacted before it was saved? What if the user wants to fork from a specific point rather than continue at the end? Each reference implementation answers these questions differently. For on-disk schema details behind these resume semantics, see Session Directory Layout. For chat compaction behavior that affects what can be resumed, see Chat History.

Commit: b9050e1d

Aider’s resume mechanism is the simplest: it parses a Markdown chat history file back into messages and optionally triggers background summarization.

--restore-chat-history (args.py:290-294)

Boolean flag, default False. When set, Aider reads the chat history file on startup and populates done_messages with the parsed history.

The core logic lives in base_coder.py:519-523:

if not self.done_messages and restore_chat_history:
history_md = self.io.read_text(self.io.chat_history_file)
if history_md:
self.done_messages = utils.split_chat_history_markdown(history_md)
self.summarize_start()

The guard not self.done_messages prevents double-loading if messages already exist from a previous coder handoff.

utils.split_chat_history_markdown() (utils.py:148-196) is a state machine that reconstructs message objects from Aider’s chat log format:

def split_chat_history_markdown(text, include_tool=False):
messages = []
user = []
assistant = []
tool = []
lines = text.splitlines(keepends=True)

The parsing rules:

  • Lines starting with # are skipped (section headers)
  • Lines starting with #### are user messages (the #### prefix is stripped)
  • Lines starting with > are tool output (the > prefix is stripped)
  • All other lines are assistant response text
  • Role transitions flush the current buffer into a message

Tool messages are filtered out by default (include_tool=False), which means tool execution results are lost on resume. The model sees the user’s request and the assistant’s response but not the intermediate tool invocations.

summarize_start() (base_coder.py:1005-1023) kicks off a background thread to compress the restored history:

def summarize_start(self):
if self.summarizer_thread:
return
if not self.main_model.can_prefill:
return
self.summarizer_thread = threading.Thread(target=self._summarize_thread)
self.summarizer_thread.start()

The thread runs ChatSummary.summarize_real() which recursively splits the message list (keeping the recent 50%), summarizes the head with the weak model, and produces a condensed history. If summarization fails, the full history is kept.

  • User prompts (text only)
  • Assistant responses (full text)
  • Tool execution results (filtered by default)
  • File contents at the time of each turn
  • Repo map state
  • Edit format metadata
  • Token counts and cost tracking

Aider has no session listing or selection UI. The chat history file is a single file per project directory (.aider.chat.history.md). There is no concept of multiple named sessions, forking, or branching.


Commit: 4ab44e2c5

Codex uses event-sourced JSONL rollout files for session persistence. Resume replays the event log to reconstruct the full conversation state, preserving tool call results and metadata.

Two commands in cli/src/main.rs:186-221:

Resume(ResumeCommand) {
session_id: Option<String>, // UUID or thread name
last: bool, // --last: continue most recent session
all: bool, // --all: show all sessions (no CWD filter)
}
Fork(ForkCommand) {
// Same structure as Resume, but creates a new session from checkpoint
}

codex --resume opens an interactive session picker if no session ID is specified. codex --resume --last skips the picker and continues the most recent session.

tui/src/resume_picker.rs:114-219 implements an interactive session browser:

  • Filtering: by provider, session source (CLI-only by default), and CWD
  • Sorting: by CreatedAt (default) or UpdatedAt (Tab to toggle)
  • Search: type to filter by title/content
  • Preview: shows first user message, timestamps, git branch, working directory
  • Pagination: cursor-based, loads pages on demand via RolloutRecorder::list_threads()
  • Deduplication: seen_paths HashSet prevents showing the same session twice across pages (line 546)

RolloutRecorder::find_resume_path() (recorder.rs:310-350) searches for a resumable session:

  1. First checks the state database (codex-state crate) for indexed sessions
  2. Falls back to file-system scanning of ~/.codex/sessions/ directories
  3. select_resume_path() (recorder.rs:950-964) filters by CWD match — if the session’s working directory matches the current CWD, it is eligible
fn select_resume_path(page: &ThreadsPage, filter_cwd: Option<&Path>) -> Option<PathBuf> {
match filter_cwd {
Some(cwd) => page.items.iter().find_map(|item| {
if item.cwd.as_ref().is_some_and(|session_cwd| cwd_matches(session_cwd, cwd)) {
Some(item.path.clone())
} else {
None
}
}),
None => page.items.first().map(|item| item.path.clone()),
}
}

Sessions are stored as JSONL files at:

~/.codex/sessions/YYYY/MM/DD/rollout-{timestamp}-{uuid}.jsonl

Each line is a RolloutLine:

struct RolloutLine {
timestamp: String, // RFC 3339
item: RolloutItem {
SessionMeta { meta, git },
EventMsg { ... }, // UserMessage, AssistantResponse, ExecCommandEnd, etc.
}
}

When a session is resumed (codex.rs:1041-1051):

InitialHistory::Resumed(resumed_history) => (
resumed_history.conversation_id,
RolloutRecorderParams::resume(
resumed_history.rollout_path.clone(),
event_persistence_mode,
),
)

The ResumedHistory struct contains:

  • conversation_id: the original thread UUID (preserves prompt cache key)
  • rollout_path: path to the JSONL file
  • history: Vec<ResponseItem> — the full conversation replayed from the JSONL

The rollout recorder opens the existing file in append mode. New events are appended; old events are never modified. This is the append-only event-sourcing pattern.

metadata::builder_from_items() (rollout/metadata.rs) reconstructs session metadata (token counts, timestamps, tool usage statistics) from the replayed items. This metadata is used by the TUI for display but doesn’t affect the model — the model receives the raw ResponseItem history.

Fork (codex --fork) creates a new session with a new ThreadId but copies the entire history from the source session up to the fork point. The forked_from_id field preserves lineage:

RolloutRecorderParams::new(
conversation_id,
forked_from_id, // Optional<ThreadId>
session_source,
base_instructions,
dynamic_tools,
event_persistence_mode,
)
  • Complete message history (user prompts, assistant responses)
  • Tool execution results (command outputs, exit codes, durations)
  • File diffs and patch applications
  • Session metadata (CWD, git branch/SHA, timestamps)
  • Dynamic tools registered during the session
  • Base instructions at session creation time
  • Ghost snapshots (stripped from prompt but preserved in rollout)
  • TUI layout and scroll position
  • In-progress approval dialogs
  • Partially streamed responses (if the process crashed mid-stream)

If a session was auto-compacted before being saved, the rollout file contains the compaction result — the summarized history replaces the original messages. Resumed sessions continue from the compacted state.


Commit: 7ed449974

OpenCode stores sessions in SQLite and resumes by querying the database directly. There is no replay step — messages are stored as final state.

The TUI presents a session list. The Session.list() generator (session/index.ts:508-545) queries the database:

export function* list(input?: {
directory?: string,
roots?: boolean, // Filter to root sessions only (no forks)
start?: number, // Filter by timestamp
search?: string, // LIKE query on title
limit?: number, // Max results (default 100)
}) {
const conditions = [eq(SessionTable.project_id, project.id)]
// ... build conditions
const rows = Database.use((db) =>
db.select().from(SessionTable)
.where(and(...conditions))
.orderBy(desc(SessionTable.time_updated))
.limit(limit)
.all()
)
for (const row of rows) yield fromRow(row)
}

Sessions are always filtered to the current project and sorted by most recently updated.

Session.messages() (session/index.ts:492-506) loads messages with their associated parts:

export const messages = fn(
z.object({
sessionID: Identifier.schema("session"),
limit: z.number().optional(),
}),
async (input) => {
const result = [] as MessageV2.WithParts[]
for await (const msg of MessageV2.stream(input.sessionID)) {
if (input.limit && result.length >= input.limit) break
result.push(msg)
}
result.reverse()
return result
},
)

MessageV2.stream() uses an async generator that queries MessageTable and PartTable via Drizzle ORM, streaming results to avoid loading everything into memory at once. The reverse() at the end converts from database order (newest first) to chronological order.

MessageV2.toModelMessages() converts stored messages back into the format the LLM expects. Each MessageV2.WithParts contains:

  • info: metadata (role, timestamps, parentID, error state, finish reason)
  • parts: array of MessagePart variants (text, tool call, tool result, reasoning, file diff, etc.)

The conversion handles:

  • Compacted parts (flagged with time.compacted) — these are stripped or replaced with summaries
  • Tool results with metadata — formatted back into the provider-specific shape
  • Reasoning parts — included or excluded based on model capabilities

Session.fork() (session/index.ts:210-250) clones a session up to a specific message:

export const fork = fn(
z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message").optional(),
}),
async (input) => {
const original = await get(input.sessionID)
const title = getForkedTitle(original.title)
const session = await createNext({
parentID: input.sessionID,
directory: original.directory,
title,
})
const msgs = await messages({ sessionID: input.sessionID })
const idMap = new Map<string, string>()
for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
const newID = Identifier.ascending("message")
idMap.set(msg.info.id, newID)
const parentID = msg.info.role === "assistant" && msg.info.parentID
? idMap.get(msg.info.parentID) : undefined
const cloned = await updateMessage({
...msg.info,
sessionID: session.id,
id: newID,
...(parentID && { parentID }),
})
for (const part of msg.parts) {
await updatePart({
...part,
id: Identifier.ascending("part"),
messageID: cloned.id,
sessionID: session.id,
})
}
}
return session
},
)

Key details:

  • messageID parameter allows forking from a specific point (messages with ID >= messageID are excluded)
  • idMap tracks old-to-new message ID mapping for parentID resolution
  • All parts are cloned individually with new IDs
  • The parentID on the session preserves the fork lineage

Sessions form a tree:

  • Root sessions have parentID: null
  • Forked sessions reference their source via parentID
  • Session.children() queries for all forks of a session
  • Session.remove() recursively deletes children before the parent (cascade)

When a session is compacted, a time_compacting timestamp is set on the session row. During message loading, parts with time.compacted set are handled differently — they may be replaced with summary text or excluded from the model context entirely. This allows resumed sessions to continue from the compacted state without replaying the full history.

OpenCode supports importing shared sessions. The import creates a new root session with cloned messages, effectively acting as a remote fork. The share.url field on the original session tracks where it was shared.

  • Full message history with all parts (text, tool calls, tool results, reasoning, diffs)
  • Session metadata (directory, permissions, timestamps)
  • Fork lineage (parentID chain)
  • Compaction state (which parts have been summarized)
  • TUI scroll position and layout
  • Active permission dialogs
  • LSP server state (must reinitialize on resume)

Claude Code’s session resumption combines conversation continuation (--continue/--resume), session forking (--continue --fork-session), checkpoint-based rewind, and persistent memory that survives across sessions regardless of conversation state.

Claude Code offers two continuation modes:

  • claude --continue — continue the most recent conversation in the current project
  • claude --resume — display a session picker to choose from past conversations

Sessions are identified by session_id (visible in the status line JSON data and in the transcript file path). Sessions are scoped to a project (derived from the git root or working directory).

Each session produces a transcript file at a path exposed via transcript_path in the status line data. The transcript preserves all messages including compaction artifacts, allowing post-session analysis even when in-context messages have been compacted.

claude --continue --fork-session creates a new session branching from the current one. The fork preserves the source session intact while starting a new conversation from that point:

  • The original session remains unmodified
  • The fork gets a new session_id
  • Both sessions continue independently from the fork point

This differs from the rewind menu’s “Summarize from here” which stays in the same session and compresses context.

The checkpoint system (one checkpoint per user prompt, tracking file editing tool changes) provides session-level undo without requiring a full session save/restore:

ActionEffect on ConversationEffect on Files
Restore code and conversationRewind to checkpoint messageRevert files to checkpoint state
Restore conversationRewind to checkpoint messageKeep current files
Restore codeKeep current conversationRevert files to checkpoint state
Summarize from hereCompress from checkpoint forwardNo file changes

Checkpoints persist across sessions (30-day TTL, configurable). Only file editing tool changes are tracked — bash command file modifications are invisible to the checkpoint system.

Claude Code has persistent memory that survives session boundaries independently of conversation history:

Auto memory (~/.claude/projects/<project>/memory/):

  • MEMORY.md — index file, first 200 lines loaded into system prompt at every session start
  • Topic files (debugging.md, api-conventions.md, etc.) — loaded on demand
  • Claude reads and writes these during sessions
  • Per-project (derived from git root); git worktrees get separate directories

CLAUDE.md hierarchy (six levels, loaded at session start):

  • Managed policy → Project → Project rules → User → Project local → Auto memory
  • Loaded regardless of which session is resumed — same instructions apply to all sessions in a project
  • Project rules support path-scoped activation via glob patterns in YAML frontmatter

This means session resumption in Claude Code is less critical than in Aider/Codex/OpenCode because key learnings are persisted in memory files rather than solely in conversation history. A new session in the same project starts with all accumulated knowledge from previous sessions via auto memory.

When resuming a session that was compacted:

  • The conversation continues from the compacted state
  • The compaction summary provides context for everything before the compaction point
  • The original pre-compaction messages are preserved in the transcript (not loaded into context)
  • Server-side compaction blocks are passed through to the API, which auto-drops content before them
  • Full conversation history (with compaction markers)
  • Checkpoints (file state snapshots, 30-day TTL)
  • Auto memory (no TTL, persists indefinitely)
  • CLAUDE.md hierarchy (re-loaded from disk)
  • Session metadata (project, directory, costs)
  • TUI state (scroll position, layout)
  • Active permission dialogs
  • Status line script state (re-executes on first message)
  • In-progress tool executions (if session crashed mid-tool)
AspectAiderCodexOpenCodeClaude Code
Resume mechanism--restore-chat-history flag--resume / --resume --lastSession list in TUI--continue / --resume
Session pickerNone (single file per project)Interactive browser with search/filterTUI session listSession picker (project-scoped)
ForkNot supported--fork (copy JSONL, new thread ID)Session.fork() (clone messages with new IDs)--continue --fork-session
Tool results on resumeLost by defaultFully preserved (JSONL replay)Fully preserved (SQLite)Preserved (with compaction markers)
Cross-session memoryNoneNoneNoneAuto memory + CLAUDE.md hierarchy
Session undoNoneNoneNoneCheckpoint rewind (4 actions)
Compaction on resumeImmediate summarization if too largeCompacted state in JSONLLoad from last compaction boundaryServer-side compaction blocks
Prompt cache reuseLost on resumePreserved (conversation_id)Lost on resumeDepends on compaction block caching
File state trackingNot trackedGhost snapshots in rolloutNot trackedCheckpoints (file editing tools only)

Tool output is filtered by default during Markdown parsing. This means the model loses context about what commands were run and what their outputs were. For debugging workflows where the tool output is critical context, this is a significant limitation. The include_tool=True parameter exists but isn’t exposed to users.

Codex’s append-only JSONL is robust against crashes (partial writes produce an incomplete final line that can be skipped), but corruption from disk issues requires manual editing. There is no automatic recovery or checksum verification.

OpenCode uses SQLite in WAL mode. If the application crashes between a write and a WAL checkpoint, the changes are in the WAL file but not the main database. On next startup, SQLite automatically replays the WAL, so data is preserved — but if the WAL file is deleted (e.g., by a cleanup script), recent writes are lost.

Codex preserves the conversation_id across resume, which means the OpenAI server-side prompt cache may still be warm. This is a significant performance advantage — the first API call after resume can hit the cache. Aider and OpenCode both lose any prompt cache on resume because they reconstruct the conversation from scratch.

Both Codex and OpenCode support forking from a specific point, but the behavior differs:

  • Codex copies the JSONL file and appends to it (cheap, but the fork contains the full source history)
  • OpenCode clones individual messages with new IDs (more expensive, but creates a clean independent session)

Neither implementation tracks file system state at the fork point — if the user’s files have changed since the original session, the resumed conversation may reference stale file contents.

If a session was compacted (summarized) before saving, the resumed session starts from the compacted state. The model has no way to “un-compact” — it sees only the summary, not the original detailed history. This is usually fine, but can be surprising if the user expects to see their full conversation on resume.

Codex filters sessions by CWD on resume. If the user navigates to a different directory, their sessions won’t appear. OpenCode filters by project ID, which is more stable (derived from the git root or directory path). Both approaches have edge cases with monorepos or moved projects.

OpenCode’s Session.remove() recursively deletes child sessions. If a user deletes a parent session, all forks are lost. This matches the intent (parent is the “source of truth”) but can be surprising. Codex avoids this by treating each rollout file as independent.

Auto Memory Can Diverge from Session State

Section titled “Auto Memory Can Diverge from Session State”

Claude Code’s auto memory persists independently of sessions. If Claude saves a learning to memory during session A, then the user rewinds session A past that point, the memory file still contains the learning. This creates a divergence where memory reflects a state that was “undone” in the conversation. There’s no mechanism to revert auto memory writes alongside checkpoint rewinds.

Checkpoints auto-clean after 30 days. If a user tries to resume a session older than 30 days, the conversation is available but checkpoint rewind is not. This is undocumented in the session list — there’s no indicator that a session has lost its rewind capability.

Claude Code’s fork creates an independent session — there’s no merge-back mechanism. If the user explores two approaches via fork and wants to combine insights, they must manually copy information between sessions or rely on auto memory to capture learnings.


Combine the best of both approaches:

  • SQLite for session metadata, message storage, and fast queries (OpenCode pattern)
  • Append-only event log in the database for crash recovery (Codex pattern)

Use rusqlite with WAL mode. Schema:

CREATE TABLE sessions (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES sessions(id),
project_id TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
parent_id TEXT,
role TEXT NOT NULL, -- "user", "assistant", "system"
created_at INTEGER NOT NULL,
compacted_at INTEGER, -- NULL if not compacted
data BLOB NOT NULL -- MessagePack-encoded content
);
CREATE TABLE parts (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
session_id TEXT NOT NULL,
type TEXT NOT NULL, -- "text", "tool_call", "tool_result", "reasoning", "diff"
created_at INTEGER NOT NULL,
compacted_at INTEGER,
data BLOB NOT NULL
);
  1. User selects session from TUI picker (query sessions table, filter by project)
  2. Load messages via streaming query (ORDER BY created_at ASC)
  3. For each message, load associated parts
  4. Convert to model message format, respecting compaction markers
  5. Reconstruct system prompt (environment + instructions)
  6. Continue conversation with full context
pub async fn fork(session_id: &str, at_message: Option<&str>) -> Result<Session> {
let source = Session::get(session_id).await?;
let forked = Session::create(CreateParams {
parent_id: Some(session_id),
directory: source.directory,
title: format!("{} (fork)", source.title),
}).await?;
let messages = Message::stream(session_id).await;
let mut id_map = HashMap::new();
for msg in messages {
if let Some(cutoff) = at_message {
if msg.id >= cutoff { break; }
}
let new_id = generate_id();
id_map.insert(msg.id.clone(), new_id.clone());
// Clone message and parts with new IDs
}
Ok(forked)
}
pub fn list(params: ListParams) -> impl Iterator<Item = SessionInfo> {
// Query sessions table with:
// - project_id filter (always)
// - directory filter (optional)
// - roots_only filter (parent_id IS NULL)
// - search filter (LIKE on title)
// - ORDER BY updated_at DESC
// - LIMIT (default 100)
}
  • openoxide-session — Session CRUD, message storage, fork, resume
  • openoxide-db — SQLite wrapper with WAL mode, migrations
  • rusqlite — SQLite binding
  • rmp-serde — MessagePack serialization for message/part data
  • SQLite over JSONL — queries, indexing, and atomic operations are worth the complexity
  • MessagePack for part data — more compact than JSON, schema-flexible
  • Cascade deletes with confirmation — warn before deleting sessions with children
  • Preserve conversation_id — enables prompt cache reuse on resume (Codex insight)
  • Stream-based message loading — avoid loading entire sessions into memory
  • Compaction markers on parts, not messages — finer-grained control over what the model sees
  • Persistent memory separate from sessions — auto memory survives session rewind/deletion (Claude Code insight)
  • Checkpoint system with TTL — per-prompt file state snapshots with configurable expiry (Claude Code insight)
  • Fork as independent session — new session_id, full message clone, no merge-back (Claude Code/Codex insight)
  • Server-side compaction preference — when provider supports it, prefer over client-side (Claude Code insight)

Add a checkpoint table alongside sessions:

CREATE TABLE checkpoints (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
message_id TEXT NOT NULL REFERENCES messages(id),
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL -- created_at + ttl_days * 86400
);
CREATE TABLE checkpoint_files (
id TEXT PRIMARY KEY,
checkpoint_id TEXT NOT NULL REFERENCES checkpoints(id) ON DELETE CASCADE,
path TEXT NOT NULL,
content_hash TEXT NOT NULL,
content BLOB -- NULL if file was deleted, content if modified
);

Checkpoint creation: before each file edit tool execution, snapshot the current file state. Group all edits within a single user prompt under one checkpoint.

Auto Memory Integration (from Claude Code)

Section titled “Auto Memory Integration (from Claude Code)”
pub struct AutoMemory {
pub dir: PathBuf, // ~/.openoxide/projects/<project>/memory/
pub index: MemoryIndex, // MEMORY.md, first N lines loaded at startup
pub topic_files: Vec<PathBuf>, // loaded on demand
}
impl AutoMemory {
/// Load index file (first 200 lines) into system prompt
pub fn load_index(&self) -> String { ... }
/// Agent writes a learning to memory
pub async fn save(&self, topic: &str, content: &str) -> Result<()> { ... }
/// Agent reads a topic file on demand
pub async fn read_topic(&self, name: &str) -> Result<String> { ... }
}

Memory persists across sessions and is loaded at every session start, ensuring accumulated knowledge survives session deletion, rewind, and compaction.