Session Directory Layout
Feature Definition
Section titled “Feature Definition”A session is the unit of continuity in an AI coding agent. It encompasses the conversation history, model metadata, file diffs, tool execution logs, and any state needed to resume an interrupted interaction. How a tool stores that session on disk determines how it answers three operational questions:
- Resumability: can a user pick up where they left off after a crash or restart?
- Branching: can a session be forked to explore an alternative fix strategy?
- Queryability: can tooling (scripts, TUIs, dashboards) list, search, or inspect sessions without loading the full storage engine?
The three reference tools take divergent approaches: Aider uses a single append-only Markdown file, Codex uses an append-only JSONL index, and OpenCode uses a SQLite relational database. Each choice reflects a different trade-off between implementation simplicity, query power, and crash safety.
Aider Implementation
Section titled “Aider Implementation”Commit: b9050e1d5faf8096eae7a46a9ecc05a86231384b
Aider does not have an explicit session abstraction. History is a flat text file; there is no session ID, no branching, and no cross-session index.
File Locations
Section titled “File Locations”| File | Default Path | Flag |
|---|---|---|
| Chat history | <git-root>/.aider.chat.history.md | --chat-history-file |
| Input history | <git-root>/.aider.input.history | --input-history-file |
| LLM debug log | (disabled by default) | --llm-history-file |
Path resolution (aider/args.py:274–276):
default_chat_history_file = ( os.path.join(git_root, ".aider.chat.history.md") if git_root else ".aider.chat.history.md")If no git repository is found, the file lands in the current working directory.
Chat History Format
Section titled “Chat History Format”aider/io.py:1117–1136 — append_chat_history() opens the file in append mode on every write:
with self.chat_history_file.open("a", encoding=self.encoding, errors="ignore") as f: f.write(text)There is no write buffering and no batch commit — every significant event (user input, assistant response, file edit summary) appends immediately.
Session start is marked by a timestamp header (aider/io.py:336):
# aider chat started at 2025-06-14 09:32:41User messages are formatted with a #### prefix (aider/io.py:775–789):
#### Fix the off-by-one in the parser loopAssistant responses are appended verbatim as markdown. Edit blocks appear inline:
src/parser.rs```rust<<<<<<< SEARCHfor i in 0..n {=======for i in 0..=n {>>>>>>> REPLACEBecause the file grows by appending, a “session” is really a time-bounded segment within the file, delimited by timestamp headers. There is no mechanism to jump to a specific session by ID — users must grep or scroll.
Input History Format
Section titled “Input History Format”.aider.input.history uses the readline/prompt_toolkit history format: one entry per line, with entries separated by blank lines and prefixed with # iso8601-timestamp:
# 2025-06-14 09:32:44fix the parser bug# 2025-06-14 09:34:11add tests for edge casesThis file feeds the up-arrow command history in the interactive prompt. It is separate from chat history and does not contain model responses.
What Is Not Stored
Section titled “What Is Not Stored”Aider does not persist:
- Model name or version used per session
- Token counts or cost
- Tool execution metadata
- File diffs or before/after snapshots
- Session identifiers that would enable programmatic lookup
Codex Implementation
Section titled “Codex Implementation”Commit: 4ab44e2c5 (codex-rs workspace)
Codex uses an append-only JSONL index for session metadata and separate rollout files for conversation content.
Directory Structure
Section titled “Directory Structure”~/.codex/ # or $CODEX_HOME├── session_index.jsonl # append-only session metadata log└── sessions/ └── <uuid>/ # one directory per session └── rollout.jsonl # conversation turns for this sessionHome directory resolution (codex-rs/utils/home-dir/src/lib.rs:12–61):
pub fn find_codex_home() -> std::io::Result<PathBuf> { if let Ok(s) = std::env::var("CODEX_HOME") { return Ok(PathBuf::from(s)); } let mut p = home_dir()?; p.push(".codex"); Ok(p)}Session Index
Section titled “Session Index”~/.codex/session_index.jsonl is the global registry. Every time a session is created or renamed, one JSON object is appended (codex-rs/core/src/rollout/session_index.rs:49–64):
pub async fn append_session_index_entry( codex_home: &Path, entry: &SessionIndexEntry,) -> std::io::Result<()> { let path = session_index_path(codex_home); let mut file = tokio::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .await?; let mut line = serde_json::to_string(entry)?; line.push('\n'); file.write_all(line.as_bytes()).await?; file.flush().await?; Ok(())}Each line is a SessionIndexEntry (session_index.rs:19–24):
pub struct SessionIndexEntry { pub id: ThreadId, // UUID (e.g. "7f3a2b1c-...") pub thread_name: String, // Human-readable session name pub updated_at: String, // RFC3339 timestamp}Because the file is append-only, renaming a session does not rewrite old entries — it appends a new entry with the same UUID. Lookup scans from the end of the file to find the most recent entry for a given ID or name (session_index.rs:149–161):
fn scan_index_from_end_by_id(path, thread_id) -> Option<SessionIndexEntry> { scan_index_from_end(path, |entry| entry.id == *thread_id)}fn scan_index_from_end_by_name(path, name) -> Option<SessionIndexEntry> { scan_index_from_end(path, |entry| entry.thread_name == name)}The scan uses 8,192-byte chunks read from the file tail, walking backward through newline boundaries. This is efficient for recent sessions but degrades linearly as the index grows.
Rollout Files
Section titled “Rollout Files”Each session’s conversation is stored in ~/.codex/sessions/<uuid>/rollout.jsonl. Individual turn records include the model request, response, and tool call results. The exact schema is defined in codex-rs/core/src/rollout/.
Session Identification
Section titled “Session Identification”Sessions are identified by UUID (ThreadId::new() generates a new UUID v4 on session creation). The session index maps UUIDs to human-readable names. Thread names are not enforced unique — the index always resolves to the most recently appended entry for a given name.
OpenCode Implementation
Section titled “OpenCode Implementation”Commit: 7ed449974
OpenCode uses a single SQLite database, managed via the Drizzle ORM, with WAL journal mode for concurrent read safety. This is the most structured of the three approaches and the only one with explicit support for session branching.
Directory Structure
Section titled “Directory Structure”OpenCode follows the XDG Base Directory Specification:
~/.local/share/opencode/ # $XDG_DATA_HOME/opencode├── opencode.db # Primary SQLite database├── opencode.db-shm # WAL shared memory (auto-managed)└── opencode.db-wal # WAL write-ahead log (auto-managed)
~/.cache/opencode/ # $XDG_CACHE_HOME/opencode~/.config/opencode/ # $XDG_CONFIG_HOME/opencode (user config)~/.local/state/opencode/ # $XDG_STATE_HOME/opencode (logs)└── log/ └── opencode.logPath definitions (packages/opencode/src/global/index.ts:6–11):
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"const app = "opencode"const data = path.join(xdgData!, app) // ~/.local/share/opencodeconst cache = path.join(xdgCache!, app) // ~/.cache/opencodeconst config = path.join(xdgConfig!, app) // ~/.config/opencodeconst state = path.join(xdgState!, app) // ~/.local/state/opencodeAll directories are created at startup with fs.mkdir(..., { recursive: true }) (global/index.ts:28–34).
Database Initialization
Section titled “Database Initialization”packages/opencode/src/storage/db.ts:28 opens the database:
const Database = { Path: path.join(Global.Path.data, "opencode.db")}WAL mode configuration (db.ts:73–78):
sqlite.run("PRAGMA journal_mode = WAL")sqlite.run("PRAGMA synchronous = NORMAL")sqlite.run("PRAGMA busy_timeout = 5000")sqlite.run("PRAGMA cache_size = -64000") // 64 MB page cachesqlite.run("PRAGMA foreign_keys = ON")sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")WAL mode allows multiple concurrent readers while one writer is active. busy_timeout = 5000 retries for 5 seconds before returning SQLITE_BUSY — critical for TUI + background indexer coexistence.
Session Table Schema
Section titled “Session Table Schema”packages/opencode/src/session/session.sql.ts:11–35:
export const SessionTable = sqliteTable("session", { id: text().primaryKey(), // Descending timestamp ID for sort order project_id: text().notNull(), // Groups sessions by project root parent_id: text(), // Non-null if this session is a fork slug: text().notNull(), // URL-safe identifier (e.g. "quirky-yak-42") directory: text().notNull(), // Absolute path to working directory title: text().notNull(), // Session title (user-editable) version: text().notNull(), // OpenCode version string share_url: text(), // Share link if published to cloud summary_additions: integer(), // Total lines added across all edits summary_deletions: integer(), // Total lines deleted across all edits summary_files: integer(), // Number of files modified summary_diffs: text({ mode: "json" }), // JSON array of FileDiff objects revert: text({ mode: "json" }), // Pre-edit snapshot reference for undo permission: text({ mode: "json" }), // Serialized PermissionRuleset time_created: integer(), // Unix ms timestamp time_updated: integer(), // Unix ms timestamp time_compacting: integer(), // Checkpoint for message compaction time_archived: integer(), // Set when session is archived})The id field uses a descending timestamp format (e.g., "0196a3f2-...") so that ORDER BY id ASC produces newest-first ordering without an explicit ORDER BY time_created DESC. This is the ULID-inspired pattern.
The parent_id field enables explicit session branching: fork a session at any point, try an alternative approach, discard if needed. Cascade delete is configured (session.sql.ts references) so deleting a parent removes all child forks.
Message and Part Tables
Section titled “Message and Part Tables”Messages and their content parts are stored in separate tables linked by foreign key.
MessageTable (session.sql.ts:37–48):
export const MessageTable = sqliteTable("message", { id: text().primaryKey(), session_id: text().notNull().references(SessionTable.id, {onDelete: "cascade"}), time_created: integer(), data: text({ mode: "json" }).notNull(), // Full MessageV2.Info as JSON})PartTable (session.sql.ts:50–62):
export const PartTable = sqliteTable("part", { id: text().primaryKey(), message_id: text().notNull().references(MessageTable.id), session_id: text().notNull(), time_created: integer(), data: text({ mode: "json" }).notNull(), // MessageV2.Part as JSON})Parts are the granular unit of content: a single message may contain a text part, a tool-invocation part (with input and output), a file part (with diff), and a reasoning part. Storing parts in a separate table enables efficient streaming updates — each part is inserted as it arrives, rather than rewriting the entire message JSON blob.
Message Content Structure
Section titled “Message Content Structure”Each message’s data field (packages/opencode/src/session/message.ts:131–189) contains:
{ role: "user" | "assistant", parts: MessageV2.Part[], metadata: { time: { created: number, completed?: number }, assistant?: { modelID: string, providerID: string, cost: number, tokens: { input: number, output: number, cache_read: number, cache_write: number }, path: string[], // Tool call chain (for multi-agent sessions) }, tool: Record<string, ToolSnapshot>, snapshot?: string, // Git tree OID for undo error?: { message: string, data: unknown }, }}The metadata.assistant.cost and token fields provide per-message cost accounting. Summing across all messages in a session gives the total session cost.
Session Identification and Lookup
Section titled “Session Identification and Lookup”Sessions are identified by two keys:
id: descending timestamp ID (primary, always unique)slug: human-readable URL-safe string (e.g.,"jolly-river-99")
Both are queryable. The slug is used in the TUI and shareable URLs; the ID is used for programmatic access and foreign key references.
Project scoping: all queries for the session list filter by project_id (derived from the git root or working directory). Sessions from other projects do not appear in the list.
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”Aider: Single-File Concurrency
Section titled “Aider: Single-File Concurrency”append_chat_history() opens the file, writes, and closes on every call. Two parallel Aider instances in the same project will interleave their appends, producing a corrupted history file. Aider does not take a file lock. This is only a problem in the rare case of multiple simultaneous instances in the same directory.
Codex: Index Bloat
Section titled “Codex: Index Bloat”Because session renames and updates append new lines rather than modifying existing ones, the session_index.jsonl file grows without bound. A heavily-used installation (hundreds of sessions, many renames) produces an index file that is expensive to scan from the tail. The reverse-chunk scan helps, but does not eliminate the O(n) worst case when looking up old sessions by name.
Codex: No Message Compaction
Section titled “Codex: No Message Compaction”Codex’s rollout JSONL files are also append-only. There is no compaction or truncation mechanism for very long sessions. A session with thousands of turns produces a large JSONL file that must be fully deserialized to reconstruct the conversation state.
OpenCode: Single Database File Across Projects
Section titled “OpenCode: Single Database File Across Projects”All OpenCode sessions for all projects live in a single opencode.db. The project_id column partitions them, but the file grows globally. A user with many large projects accumulates a single multi-GB SQLite file. WAL mode helps with concurrent access, but SQLite’s single-writer constraint means that a long-running compaction operation (triggered by message summary generation) blocks all session writes.
OpenCode: SQLite WAL Checkpoint Timing
Section titled “OpenCode: SQLite WAL Checkpoint Timing”WAL mode accumulates changes in the WAL file until a checkpoint copies them to the main database. The PRAGMA wal_checkpoint(PASSIVE) at startup triggers a non-blocking checkpoint. If the process crashes before a checkpoint, the WAL file must be replayed on next open — this is safe but adds startup latency. On macOS and Linux, the WAL file is typically kept under 1,000 pages by automatic checkpointing; on systems under heavy write load, it can grow larger.
OpenCode: Effects Pattern and Rollback
Section titled “OpenCode: Effects Pattern and Rollback”OpenCode uses an “effects pattern” (session/index.ts:594) where database writes are batched and executed together after all in-memory state has been validated. If a validation step fails, no writes occur. This prevents partial session state from being committed to the database. The downside is that long-running validation (e.g., permission resolution across many tools) delays the commit, which delays the TUI update.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”OpenOxide should follow OpenCode’s SQLite approach for its query power and transactional safety, while addressing the single-database limitation with a project-local option.
Storage Strategy
Section titled “Storage Strategy”Two modes:
- Global: single
~/.local/share/openoxide/openoxide.dbfor all projects (default, matches OpenCode) - Project-local:
.openoxide/sessions.dbin the git root (opt-in for teams)
Project-local mode allows the session history to be committed to the repository (if desired) and avoids cross-project database contention.
Crate Selection
Section titled “Crate Selection”[dependencies]rusqlite = { version = "0.31", features = ["bundled", "blob"] }# bundled: static link, no system SQLite dependency# blob: large content stored as BLOB, not TEXT
tokio-rusqlite = "0.5"# async wrapper for rusqlite, prevents blocking the tokio runtimeSchema
Section titled “Schema”CREATE TABLE session ( id TEXT PRIMARY KEY, -- ULID project_id TEXT NOT NULL, -- SHA256 of canonical project root path parent_id TEXT REFERENCES session(id) ON DELETE CASCADE, title TEXT NOT NULL, directory TEXT NOT NULL, model_id TEXT NOT NULL, time_created INTEGER NOT NULL, -- Unix ms time_updated INTEGER NOT NULL, revert_oid TEXT -- git2 tree OID for undo);
CREATE TABLE message ( id TEXT PRIMARY KEY, -- ULID session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool')), time_created INTEGER NOT NULL, content BLOB NOT NULL -- MessagePack encoded content);
CREATE INDEX idx_session_project ON session(project_id, time_created DESC);CREATE INDEX idx_message_session ON message(session_id, time_created ASC);MessagePack (via the rmp-serde crate) is used instead of JSON for message content. MessagePack is 30–50% smaller for typical message payloads and encodes/decodes faster than JSON. The BLOB storage type in SQLite avoids JSON parsing overhead on every read.
XDG Compliance
Section titled “XDG Compliance”use directories::ProjectDirs;
pub fn data_dir() -> PathBuf { ProjectDirs::from("com", "openoxide", "openoxide") .map(|dirs| dirs.data_dir().to_owned()) .unwrap_or_else(|| PathBuf::from("~/.local/share/openoxide"))}
pub fn config_dir() -> PathBuf { ProjectDirs::from("com", "openoxide", "openoxide") .map(|dirs| dirs.config_dir().to_owned()) .unwrap_or_else(|| PathBuf::from("~/.config/openoxide"))}The directories crate handles XDG on Linux, ~/Library/Application Support on macOS, and %APPDATA% on Windows.
Session Branching
Section titled “Session Branching”Implement session fork as a single SQL operation:
pub async fn fork_session( db: &Database, source_id: &SessionId, branch_title: &str,) -> Result<SessionId> { let new_id = SessionId::new(); // new ULID db.execute( "INSERT INTO session (id, project_id, parent_id, title, directory, model_id, time_created, time_updated) SELECT ?, project_id, ?, ?, directory, model_id, ?, ? FROM session WHERE id = ?", params![new_id, source_id, branch_title, now_ms(), now_ms(), source_id], )?; // Copy messages up to the fork point db.execute( "INSERT INTO message (id, session_id, role, time_created, content) SELECT ulid(), ?, role, time_created, content FROM message WHERE session_id = ? ORDER BY time_created ASC", params![new_id, source_id], )?; Ok(new_id)}The ON DELETE CASCADE on parent_id means deleting a parent session recursively deletes all forks — important for the /cleanup command.