Skip to content

Session Import & Export

A coding agent accumulates valuable state during a session: the conversation thread, tool outputs, file diffs, approval decisions. That state is trapped in one process on one machine. Session import and export solve the portability problem — how do you move a conversation out of the tool for backup, share it with a colleague for review, or restore it on a different machine to continue work?

The hard part is not serialization itself. It is defining what constitutes a “complete” session. A conversation alone is useless without the context that shaped it: which files were in scope, what edits were applied, which model was used, what sandbox policy was active. Export too little and the imported session is incoherent. Export too much and you leak secrets or create files that are impossibly large.

There is also the replay vs. snapshot distinction. A session replay re-executes commands to reconstruct state (like a database WAL). A session snapshot captures the final state directly (like a database dump). Each has tradeoffs: replays are deterministic but slow; snapshots are fast but may drift if the underlying codebase changed.

Pin: b9050e1d5faf8096eae7a46a9ecc05a86231384b

Aider takes a minimalist approach. There is no formal import/export system for full sessions. Instead, Aider provides three orthogonal persistence mechanisms that, combined, approximate session portability.

Every user input and LLM response is appended to .aider.chat.history.md in the git root. The format is structured Markdown:

  • Lines starting with #### are user messages
  • Undecorated lines following a user message are assistant responses
  • Lines starting with > are tool output or error messages
  • Lines starting with # are metadata headers (timestamps, session start markers)

The parser lives in aider/utils.py:148-196, in split_chat_history_markdown(). It scans line-by-line, accumulating content into {role, content} dicts. The include_tool parameter controls whether > lines are captured or skipped.

Appending happens in aider/io.py:1117-1137 via append_chat_history(). Each write opens the file, appends, and closes — no buffering, no batching. This makes the format crash-safe but not atomic across multi-line writes.

The --restore-chat-history CLI flag triggers restoration on startup. In aider/coders/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()

Parsed messages populate done_messages, which are prepended to the LLM context on the next request. If the history is long, summarize_start() triggers background summarization via the weak model (see Token Budgeting).

This is a replay of context, not a replay of actions. The LLM sees the conversation history but no file edits are re-applied. The assumption is that the git working tree already reflects those edits.

The /save and /load slash commands (aider/commands.py:1451-1509) provide file-context portability. /save <filename> writes a command script:

/drop
/add file1.py
/add src/lib.py
/read-only reference.md
/read-only /absolute/path/external.txt

/load <filename> reads such a file and executes each line through the command runner, skipping blanks and #-prefixed comments. This reconstructs the file context (which files the agent can read and edit) but not the conversation.

Key limitation: /save captures the file list, not the conversation. You need both /save and --restore-chat-history to approximate a full session restore.

Aider supports manual sharing via GitHub Gists. Users copy .aider.chat.history.md into a Gist and access it at https://aider.chat/share/?mdurl=<gist_raw_url>. The web renderer parses the same Markdown format. This is read-only — there is no import from a share URL.

The Python API supports programmatic session setup:

from aider.coders import Coder
from aider.models import Model
from aider.io import InputOutput
io = InputOutput(
chat_history_file=".aider.custom.history.md",
input_history_file=".aider.custom.input.history",
yes=True
)
coder = Coder.create(
main_model=Model("gpt-4-turbo"),
fnames=["file1.py"],
io=io,
restore_chat_history=True
)
coder.run("continue the work from before")

This enables external tooling to construct sessions programmatically, but relies on the file-based history format.

  • No structured export format (JSON, JSONL, etc.)
  • No import from another Aider instance’s state
  • No session IDs or naming
  • No fork/branch of conversations
  • No share-URL import

Pin: 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476

Codex takes the opposite extreme: a full event-sourcing system with append-only JSONL rollout files, a session index, and CLI subcommands for resume and fork.

Sessions are stored as JSONL in ~/.codex/sessions/. Each file is named:

rollout-{ISO8601_TIMESTAMP}-{THREAD_UUID}.jsonl

For example: rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl.

Each line is a RolloutItem (defined in codex-rs/protocol/src/protocol.rs:1916-1922):

pub enum RolloutItem {
SessionMeta(SessionMetaLine), // Git info, cwd, model provider, CLI version
ResponseItem(ResponseItem), // Historical response items
Compacted(CompactedItem), // Context compaction markers
TurnContext(TurnContextItem), // Turn metadata (turn_id, model context window)
EventMsg(EventMsg), // User messages, agent responses, tool calls
}

Event types include UserMessageEvent, AgentMessageEvent, AgentReasoningEvent, ExecCommandEndEvent, PatchApplyEndEvent, McpToolCallEndEvent, TurnStartedEvent, TurnCompleteEvent, TurnAbortedEvent, and ThreadRolledBackEvent.

A separate session_index.jsonl at the Codex home root maps human-readable names to thread UUIDs. Each entry is a SessionIndexEntry (codex-rs/core/src/rollout/session_index.rs):

pub struct SessionIndexEntry {
pub id: ThreadId,
pub thread_name: String,
pub updated_at: String, // RFC3339 timestamp
}

The index is append-only — renaming a session appends a new entry; the latest entry for a given ID wins. Lookups scan from the end for O(1) recent access.

The RolloutRecorder (codex-rs/core/src/rollout/recorder.rs) runs as an async Tokio task. It receives events through an mpsc channel:

enum RolloutCmd {
AddItems(Vec<RolloutItem>),
Persist { ack: oneshot::Sender<()> },
Flush { ack: oneshot::Sender<()> },
Shutdown { ack: oneshot::Sender<()> },
}

Items are batched and flushed to disk after every significant change. There is no explicit “save” command — persistence is continuous and automatic.

Codex provides first-class CLI subcommands for session management:

Resume (codex-rs/cli/src/main.rs):

codex resume # Interactive picker
codex resume --last # Continue most recent session
codex resume my-thread # Resume by name
codex resume 123e4567-... # Resume by UUID
codex resume --all # Show all sessions (cross-cwd)

Fork (codex-rs/cli/src/main.rs):

codex fork # Interactive picker
codex fork --last # Fork most recent
codex fork session-uuid # Fork specific session

Resume loads the rollout file and reconstructs turns via ThreadHistoryBuilder (thread_history.rs). The builder walks all RolloutItems sequentially, matching events to turns by turn_id, handling out-of-order completions, rollback events, and abort markers.

Fork creates a new session with a fresh UUID but records forked_from_id in the RolloutRecorderParams::Create variant for lineage tracking.

The list module (codex-rs/core/src/rollout/list.rs) scans ~/.codex/sessions/ and extracts metadata from three sources:

  1. Filename — timestamp and thread ID
  2. File mtime — last modification time
  3. JSONL head/tail — first SessionMeta and first UserMessage

Results are paginated via ThreadsPage with cursor support, ordered newest-first. A hard scan cap of 10,000 files prevents runaway I/O on machines with extensive history.

Each ThreadItem includes cwd, git_branch, git_sha, git_origin_url, source (CLI vs. VSCode), model_provider, and cli_version — enough metadata to filter by project context.

  • No explicit export-to-file command (the JSONL files are the export)
  • No import from external formats
  • No web-based sharing
  • No share URLs

The JSONL format is human-readable with jq or fx, so external tooling can parse sessions without Codex itself.

Pin: 7ed449974864361bad2c1f1405769fd2c2fcdf42

OpenCode has the most complete import/export story, with structured CLI commands, web-based sharing via Durable Objects, and a fork system with parent-child lineage.

Sessions live in SQLite (via Drizzle ORM). The key tables (packages/opencode/src/session/session.sql.ts):

SessionTable: id, project_id, parent_id (nullable, for forks), slug, directory, title, version, share_url (nullable), summary_additions/summary_deletions/summary_files (integers), summary_diffs (JSON array of FileDiff[]), revert (JSON object), permission (JSON PermissionNext.Ruleset), timestamps.

MessageTable: id, session_id, data (JSON-serialized MessageV2.Info), time_created.

PartTable: id, message_id, session_id, data (JSON-serialized MessageV2.Part), time_created.

SessionShareTable (share.sql.ts): session_id, id (share identifier), secret (access token), url (public share URL).

opencode export [sessionID] (packages/opencode/src/cli/cmd/export.ts:9-88) serializes a session to stdout as prettified JSON:

{
"info": { /* Session.Info fields */ },
"messages": [
{
"info": { /* Message fields */ },
"parts": [ /* Part fields */ ]
}
]
}

If no sessionID is provided, the user is prompted to select from recent sessions. The output includes the full message thread with all parts (text, tool calls, tool results, file diffs).

opencode import <file> (packages/opencode/src/cli/cmd/import.ts:66-170) accepts two input types:

  1. Local JSON file — reads and parses directly
  2. Share URL (https://opncd.ai/share/<slug>) — fetches from the share API at ${baseUrl}/api/share/${slug}/data

The share API returns a flat array of session, message, and part objects. transformShareData() (import.ts:34-64) restructures this into the nested format expected by the local database:

// Share API: [session, message, message, part, part, ...]
// Local: { info: session, messages: [{ info: message, parts: [] }, ...] }

Database insertion uses onConflictDoNothing() to handle duplicate imports gracefully. Share URLs are not re-imported — the imported session starts as unshared.

Session.share() (packages/opencode/src/session/index.ts:321-335) creates a share via HTTP POST to /api/share and receives { id, url, secret }. The share is stored in SessionShareTable and the share_url field is set on the session.

After creation, fullSync() pushes all session data to the share server. Ongoing changes are propagated via event subscriptions with 1000ms debounced batching:

  • Session.Event.Updated syncs session info
  • MessageV2.Event.Updated syncs messages and model metadata
  • MessageV2.Event.PartUpdated syncs message parts
  • Session.Event.Diff syncs file diffs

The share server uses Cloudflare Durable Objects (packages/function/src/api.ts:188-216). The web endpoint at /s/[id] (packages/web/src/pages/s/[id].astro) renders the shared session with a Solid.js <Share> component. Pages are set to noindex, nofollow.

Session.unshare() (index.ts:337-347) removes the share, clears share_url, and deletes from SessionShareTable.

Auto-sharing is supported: if Flag.OPENCODE_AUTO_SHARE is set or config.share === "auto", new parent sessions (not forks) are automatically shared on creation. Sharing can be disabled entirely via OPENCODE_DISABLE_SHARE.

Session.fork() (packages/opencode/src/session/index.ts:210-250) creates a new session branched from a specific message in the conversation:

  • Creates new session via createNext() with parentID set
  • Title derived from original: "Original (fork #1)", "Original (fork #2)", etc.
  • Copies all messages up to the optional messageID cutoff
  • Rebuilds message parent references for the cloned thread
  • Does not copy share_url — forks start unshared

The TUI provides fork-from-timeline (dialog-fork-from-timeline.tsx:34-56): the user selects a message in the conversation, and the fork is created from that point with file attachments inherited.

Parent-child relationships are tracked via parent_id in SessionTable. Session.children() queries by this field. Session.list() filters out children by default (isNull(SessionTable.parent_id)).

Aider’s Markdown history format is fragile. If the assistant output contains lines starting with #### or > , the parser misattributes them. There is no escaping mechanism. Codex’s JSONL avoids this entirely — each line is self-describing JSON.

Exporting conversation without file state creates an illusion of portability. Importing an OpenCode session into a different project (where the referenced files don’t exist) produces a coherent-looking but useless conversation — the LLM will hallucinate about files it can’t access. Codex mitigates this by recording git_sha and git_origin_url in session metadata, enabling the importer to verify they’re on the right codebase.

Chat histories routinely contain file contents, environment variable values, and tool outputs that may include secrets. Aider’s .aider.chat.history.md is a plaintext file in the working tree — it can be accidentally committed. OpenCode’s share system is more dangerous: fullSync() pushes the entire conversation to a remote server. The OPENCODE_DISABLE_SHARE escape hatch exists for this reason.

Forking a conversation at message N means discarding messages N+1 through end. But those later messages may have applied file edits. Codex records PatchApplyEndEvent in the rollout, so a fork preserves the record of what happened. OpenCode’s fork copies messages but not filesystem snapshots — the working tree may be in a state that doesn’t match the forked conversation’s context.

Codex’s ThreadHistoryBuilder must handle out-of-order events. A command execution started in turn 3 may complete during turn 4. The builder routes completions by turn_id, not by position in the event stream. Without this, replayed sessions would misattribute tool outputs to the wrong turn.

Codex’s session discovery scans the filesystem and reads JSONL head/tail for metadata. At 10,000 session files, this becomes noticeable. The hard scan cap prevents runaway I/O but means old sessions become undiscoverable without direct UUID access.

Use JSONL event-sourcing, following Codex’s pattern. Each session is a single .jsonl file in ~/.openoxide/sessions/. The filename encodes creation timestamp and session UUID.

Define a SessionEvent enum in Rust:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SessionEvent {
Meta(SessionMeta),
UserMessage(UserMessage),
AssistantMessage(AssistantMessage),
ToolCall(ToolCallEvent),
ToolResult(ToolResultEvent),
PatchApplied(PatchEvent),
TurnBoundary(TurnBoundary),
Compacted(CompactionMarker),
Rollback(RollbackEvent),
}

Use serde_json for serialization. Write events through a tokio::sync::mpsc channel to a dedicated writer task, flushing after each batch. Use BufWriter with explicit flush() after writes.

Maintain session_index.jsonl mapping UUIDs to human-readable names. Append-only, latest-entry-wins semantics. Use rev_lines or memory-mapped reverse scanning for fast lookups.

openoxide resume [--last | --all | <name-or-uuid>]
openoxide fork [--last | --all | <uuid>] [--at-message <msg-id>]
openoxide export <session-id> [--format json|jsonl] [--output <file>]
openoxide import <file-or-url>

export should support both JSON (nested, human-readable) and JSONL (raw events, lossless). import should accept both formats plus URLs pointing to shared sessions.

Defer web sharing to post-MVP. For now, export to a file and manual transfer is sufficient. When sharing is implemented, use a similar Durable Object or S3-backed approach with secret-gated access.

  • serde + serde_json for serialization
  • tokio::sync::mpsc for the recorder channel
  • tokio::fs for async file I/O
  • uuid (v4) for session IDs
  • chrono for RFC3339 timestamps
  • clap for CLI subcommands

Fork reads the source session’s JSONL, copies events up to the specified message, and writes them as the initial content of a new JSONL file with a fresh UUID. Record forked_from: Option<Uuid> in the SessionMeta event for lineage tracking. Also record git_sha and git_origin_url so imports can verify codebase compatibility.

On import, verify:

  1. Schema version compatibility (reject future versions, migrate old ones)
  2. Referenced file paths exist (warn but don’t block)
  3. No duplicate session UUID already in the index
  4. Git context matches if git_origin_url is present (warn on mismatch)

Use serde_json::from_str with #[serde(deny_unknown_fields)] on a versioned wrapper to catch schema mismatches early.