Skip to content

Session Storage and Entry Model

This document is the source of truth for how coding-agent sessions are represented, persisted, migrated, and reconstructed at runtime.

Covers:

  • Session JSONL format and versioning
  • Entry taxonomy and tree semantics (id/parentId + leaf pointer)
  • Migration/compatibility behavior when loading old or malformed files
  • Context reconstruction (buildSessionContext)
  • Persistence guarantees, failure behavior, truncation/blob externalization
  • Storage abstractions (FileSessionStorage, MemorySessionStorage) and related utilities

Does not cover /tree UI rendering behavior beyond semantics that affect session data.

Default session file location:

~/.xcsh/agent/sessions/--<cwd-encoded>--/<timestamp>_<sessionId>.jsonl

<cwd-encoded> is derived from the working directory by stripping leading slash and replacing /, \\, and : with -.

Blob store location:

~/.xcsh/agent/blobs/<sha256>

Terminal breadcrumb files are written under:

~/.xcsh/agent/terminal-sessions/<terminal-id>

Breadcrumb content is two lines: original cwd, then session file path. continueRecent() prefers this terminal-scoped pointer before scanning most-recent mtime.

Session files are JSONL: one JSON object per line.

  • Line 1 is always the session header (type: "session").
  • Remaining lines are SessionEntry values.
  • Entries are append-only at runtime; branch navigation moves a pointer (leafId) rather than mutating existing entries.
{
"type": "session",
"version": 3,
"id": "1f9d2a6b9c0d1234",
"timestamp": "2026-02-16T10:20:30.000Z",
"cwd": "/work/pi",
"title": "optional session title",
"parentSession": "optional lineage marker"
}

Notes:

  • version is optional in v1 files; absence means v1.
  • parentSession is an opaque lineage string. Current code writes either a session id or a session path depending on flow (fork, forkFrom, createBranchedSession, or explicit newSession({ parentSession })). Treat as metadata, not a typed foreign key.

All non-header entries include:

{
"type": "...",
"id": "8-char-id",
"parentId": "previous-or-branch-parent",
"timestamp": "2026-02-16T10:20:30.000Z"
}

parentId can be null for a root entry (first append, or after resetLeaf()).

SessionEntry is the union of:

  • message
  • thinking_level_change
  • model_change
  • compaction
  • branch_summary
  • custom
  • custom_message
  • label
  • ttsr_injection
  • session_init
  • mode_change

Stores an AgentMessage directly.

{
"type": "message",
"id": "a1b2c3d4",
"parentId": null,
"timestamp": "2026-02-16T10:21:00.000Z",
"message": {
"role": "assistant",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"content": [{ "type": "text", "text": "Done." }],
"usage": { "input": 100, "output": 20, "cacheRead": 0, "cacheWrite": 0, "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0 } },
"timestamp": 1760000000000
}
}
{
"type": "model_change",
"id": "b1c2d3e4",
"parentId": "a1b2c3d4",
"timestamp": "2026-02-16T10:21:30.000Z",
"model": "openai/gpt-4o",
"role": "default"
}

role is optional; missing is treated as default in context reconstruction.

{
"type": "thinking_level_change",
"id": "c1d2e3f4",
"parentId": "b1c2d3e4",
"timestamp": "2026-02-16T10:22:00.000Z",
"thinkingLevel": "high"
}
{
"type": "compaction",
"id": "d1e2f3a4",
"parentId": "c1d2e3f4",
"timestamp": "2026-02-16T10:23:00.000Z",
"summary": "Conversation summary",
"shortSummary": "Short recap",
"firstKeptEntryId": "a1b2c3d4",
"tokensBefore": 42000,
"details": { "readFiles": ["src/a.ts"] },
"preserveData": { "hookState": true },
"fromExtension": false
}
{
"type": "branch_summary",
"id": "e1f2a3b4",
"parentId": "a1b2c3d4",
"timestamp": "2026-02-16T10:24:00.000Z",
"fromId": "a1b2c3d4",
"summary": "Summary of abandoned path",
"details": { "note": "optional" },
"fromExtension": true
}

If branching from root (branchFromId === null), fromId is the literal string "root".

Extension state persistence; ignored by buildSessionContext.

{
"type": "custom",
"id": "f1a2b3c4",
"parentId": "e1f2a3b4",
"timestamp": "2026-02-16T10:25:00.000Z",
"customType": "my-extension",
"data": { "state": 1 }
}

Extension-provided message that does participate in LLM context.

{
"type": "custom_message",
"id": "a2b3c4d5",
"parentId": "f1a2b3c4",
"timestamp": "2026-02-16T10:26:00.000Z",
"customType": "my-extension",
"content": "Injected context",
"display": true,
"details": { "debug": false }
}
{
"type": "label",
"id": "b2c3d4e5",
"parentId": "a2b3c4d5",
"timestamp": "2026-02-16T10:27:00.000Z",
"targetId": "a1b2c3d4",
"label": "checkpoint"
}

label: undefined clears a label for targetId.

{
"type": "ttsr_injection",
"id": "c2d3e4f5",
"parentId": "b2c3d4e5",
"timestamp": "2026-02-16T10:28:00.000Z",
"injectedRules": ["ruleA", "ruleB"]
}
{
"type": "session_init",
"id": "d2e3f4a5",
"parentId": "c2d3e4f5",
"timestamp": "2026-02-16T10:29:00.000Z",
"systemPrompt": "...",
"task": "...",
"tools": ["read", "edit"],
"outputSchema": { "type": "object" }
}
{
"type": "mode_change",
"id": "e2f3a4b5",
"parentId": "d2e3f4a5",
"timestamp": "2026-02-16T10:30:00.000Z",
"mode": "plan",
"data": { "planFile": "/tmp/plan.md" }
}

Current session version: 3.

Applied when header version is missing or < 2:

  • Adds id and parentId to each non-header entry.
  • Reconstructs a linear parent chain using file order.
  • Migrates compaction field firstKeptEntryIndex -> firstKeptEntryId when present.
  • Sets header version = 2.

Applied when header version < 3:

  • For message entries: rewrites legacy message.role === "hookMessage" to "custom".
  • Sets header version = 3.
  • Migrations run during session load (setSessionFile).
  • If any migration ran, the entire file is rewritten to disk immediately.
  • Migration mutates in-memory entries first, then persists rewritten JSONL.

loadEntriesFromFile(path) behavior:

  • Missing file (ENOENT) -> returns [].
  • Non-parseable lines are handled by lenient JSONL parser (parseJsonlLenient).
  • If first parsed entry is not a valid session header (type !== "session" or missing string id) -> returns [].

SessionManager.setSessionFile() behavior:

  • [] from loader is treated as empty/nonexistent session and replaced with a new initialized session file at that path.
  • Valid files are loaded, migrated if needed, blob refs resolved, then indexed.

The underlying model is append-only tree + mutable leaf pointer:

  • Every append method creates exactly one new entry whose parentId is current leafId.
  • The new entry becomes the new leafId.
  • branch(entryId) moves only leafId; existing entries remain unchanged.
  • resetLeaf() sets leafId = null; next append creates a new root entry (parentId: null).
  • branchWithSummary() sets leaf to branch target and appends a branch_summary entry.

getEntries() returns all non-header entries in insertion order. Existing entries are not deleted in normal operation; rewrites preserve logical history while updating representation (migrations, move, targeted rewrite helpers).

Context Reconstruction (buildSessionContext)

Section titled “Context Reconstruction (buildSessionContext)”

buildSessionContext(entries, leafId, byId?) resolves what is sent to the model.

Algorithm:

  1. Determine leaf:
    • leafId === null -> return empty context.
    • explicit leafId -> use that entry if found.
    • otherwise fallback to last entry.
  2. Walk parentId chain from leaf to root and reverse to root->leaf path.
  3. Derive runtime state across path:
    • thinkingLevel from latest thinking_level_change (default "off")
    • model map from model_change entries (role ?? "default")
    • fallback models.default from assistant message provider/model if no explicit model change
    • deduplicated injectedTtsrRules from all ttsr_injection entries
    • mode/modeData from latest mode_change (default mode "none")
  4. Build message list:
    • message entries pass through
    • custom_message entries become custom AgentMessages via createCustomMessage
    • branch_summary entries become branchSummary AgentMessages via createBranchSummaryMessage
    • if a compaction exists on path:
      • emit compaction summary first (createCompactionSummaryMessage)
      • emit path entries starting at firstKeptEntryId up to the compaction boundary
      • emit entries after the compaction boundary

custom and session_init entries do not inject model context directly.

  • SessionManager.create/open/continueRecent/forkFrom -> persistent mode (persist = true).
  • SessionManager.inMemory -> non-persistent mode (persist = false) with MemorySessionStorage.

Writes are serialized through an internal promise chain (#persistChain) and NdjsonFileWriter.

  • append* updates in-memory state immediately.
  • Persistence is deferred until at least one assistant message exists.
    • Before first assistant: entries are retained in memory; no file append occurs.
    • When first assistant exists: full in-memory session is flushed to file.
    • Afterwards: new entries append incrementally.

Rationale in code: avoid persisting sessions that never produced an assistant response.

  • flush() flushes writer and calls fsync().
  • Atomic full rewrites (#rewriteFile) write to temp file, flush+fsync, close, then rename over target.
  • Used for migrations, setSessionName, rewriteEntries, move operations, and tool-call arg rewrites.
  • Persistence errors are latched (#persistError) and rethrown on subsequent operations.
  • First error is logged once with session file context.
  • Writer close is best-effort but propagates the first meaningful error.

Data Size Controls and Blob Externalization

Section titled “Data Size Controls and Blob Externalization”

Before persisting entries:

  • Large strings are truncated to MAX_PERSIST_CHARS (500,000 chars) with notice:
    • "[Session persistence truncated large content]"
  • Transient fields partialJson and jsonlEvents are removed.
  • If object has both content and lineCount, line count is recomputed after truncation.
  • Image blocks in content arrays with base64 length >= 1024 are externalized to blob refs:
    • stored as blob:sha256:<hash>
    • raw bytes written to blob store (BlobStore.put)

On load, blob refs are resolved back to base64 for message/custom_message image blocks.

SessionStorage interface provides all filesystem operations used by SessionManager:

  • sync: ensureDirSync, existsSync, writeTextSync, statSync, listFilesSync
  • async: exists, readText, readTextPrefix, writeText, rename, unlink, openWriter

Implementations:

  • FileSessionStorage: real filesystem (Bun + node fs)
  • MemorySessionStorage: map-backed in-memory implementation for tests/non-persistent sessions

SessionStorageWriter exposes writeLine, flush, fsync, close, getError.

Defined in session-manager.ts:

  • getRecentSessions(sessionDir, limit) -> lightweight metadata for UI/session picker
  • findMostRecentSession(sessionDir) -> newest by mtime
  • list(cwd, sessionDir?) -> sessions in one project scope
  • listAll() -> sessions across all project scopes under ~/.xcsh/agent/sessions

Metadata extraction reads only a prefix (readTextPrefix(..., 4096)) where possible.

Section titled “Related but Distinct: Prompt History Storage”

HistoryStorage (history-storage.ts) is a separate SQLite subsystem for prompt recall/search, not session replay.

  • DB: ~/.xcsh/agent/history.db
  • Table: history(id, prompt, created_at, cwd)
  • FTS5 index: history_fts with trigger-maintained sync
  • Deduplicates consecutive identical prompts using in-memory last-prompt cache
  • Async insertion (setImmediate) so prompt capture does not block turn execution

Use session files for conversation graph/state replay; use HistoryStorage for prompt history UX.