Skip to content

Session Directory Layout

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.


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.

FileDefault PathFlag
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.

aider/io.py:1117–1136append_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:41

User messages are formatted with a #### prefix (aider/io.py:775–789):

#### Fix the off-by-one in the parser loop

Assistant responses are appended verbatim as markdown. Edit blocks appear inline:

src/parser.rs
```rust
<<<<<<< SEARCH
for i in 0..n {
=======
for i in 0..=n {
>>>>>>> REPLACE

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

.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:44
fix the parser bug
# 2025-06-14 09:34:11
add tests for edge cases

This file feeds the up-arrow command history in the interactive prompt. It is separate from chat history and does not contain model responses.

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

Commit: 4ab44e2c5 (codex-rs workspace)

Codex uses an append-only JSONL index for session metadata and separate rollout files for conversation content.

~/.codex/ # or $CODEX_HOME
├── session_index.jsonl # append-only session metadata log
└── sessions/
└── <uuid>/ # one directory per session
└── rollout.jsonl # conversation turns for this session

Home 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)
}

~/.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.

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

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.


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.

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

Path 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/opencode
const cache = path.join(xdgCache!, app) // ~/.cache/opencode
const config = path.join(xdgConfig!, app) // ~/.config/opencode
const state = path.join(xdgState!, app) // ~/.local/state/opencode

All directories are created at startup with fs.mkdir(..., { recursive: true }) (global/index.ts:28–34).

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 cache
sqlite.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.

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.

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.

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.

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.


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.

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

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 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 should follow OpenCode’s SQLite approach for its query power and transactional safety, while addressing the single-database limitation with a project-local option.

Two modes:

  1. Global: single ~/.local/share/openoxide/openoxide.db for all projects (default, matches OpenCode)
  2. Project-local: .openoxide/sessions.db in 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.

[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 runtime
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.

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.

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.