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.
src/session/session-manager.tssrc/session/messages.tssrc/session/session-storage.tssrc/session/history-storage.tssrc/session/blob-store.ts
Default managed session file location:
~/.gjc/agent/sessions/v2-<52-char-base32-sha256>/<timestamp>_<sessionId>.jsonl
The v2-… component is a fixed-width SHA-256/base32 digest of the native canonical workspace identity (identity version 1); it is not a reversible or injective user-facing encoding. The binding file .gjc-managed-session-scope.v2.json records the canonical identity and digest. Existing bindings must be regular, canonically encoded files that agree with the resolved identity; a mismatch or unsafe path fails closed.
Identity is platform-specific:
- POSIX paths and supported local aliases that resolve to the same native directory identity share the same v2 scope.
- On Windows, equivalent supported local path spellings (including drive-letter/case aliases) resolve through the native identity API before the scope is derived.
- UNC/network workspaces are unsupported and return a
network_unsupportedresolution result; no SMB share is needed or assumed by this design.
The default managed writer creates new data only in v2 scopes. It never writes new legacy-layout data. --session-dir is an explicit storage/lookup override and is not a request to derive the default managed scope.
Legacy encoded directories are discovered only after validating each candidate's header and workspace identity. With session.directoryMigration: "copy-retain" (the default), an eligible legacy session is copied into the v2 scope without replacing an existing destination; the legacy source is retained. Set session.directoryMigration: "disabled" to leave legacy candidates unmigrated. Migration is lazy and guarded by a managed lock, binding checks, no-follow/owner-only path checks, and source identity validation; conflicts, unsafe artifacts, or changed sources fail rather than guessing.
Migration does not automatically clean up legacy files, copied files, locks, artifacts, or abandoned data. A migration tombstone records a completed/retired source so repeated scans do not reinterpret it as a new migration request; it is not evidence that the old data was deleted. Artifact copying is bounded and rejects symlinks, hard links, excessive depth, file count, or size.
Managed storage enforces owner-only directory/file security and refuses unsafe symlinks or malformed bindings on the paths it verifies. This is a local storage-integrity boundary, not authentication, authorization, encryption, or a guarantee against a hostile concurrent local actor/race outside the verified operations. Callers must still protect the agent directory and session contents.
Path-backed descendant stores reject a final pathname symlink even when it points to the original directory with the same device and inode. A derived subtree store owns its newly retained native authority: closing the store releases that authority once, and failed construction releases it before returning an error. Directly supplied authorities remain borrowed and caller-owned.
GC retirement receipt publication rejects a candidate exceeding 64 MiB of canonical UTF-8 JSON, including its trailing newline, before candidate codec serialization or receipt-buffer allocation. Strict authority and continuation validation still applies to admitted receipts. Stored committed histories have per-target quotas below, plus a distinct 50,000-entry receipt-directory limit shared by every transcript in one scope; neither limit proves writer quiescence or physical retirement.
Managed artifact rollback removes only the exact captured named artifact or an attempt's issued subtree identity; newly observed .gjc-* or .removing names are not deletion authority. Attempt cleanup releases its own retained child on every outcome without closing the borrowed parent or independent authorities. Native tree removal can durably detach the exact tree into recovery quarantine and report cleanup_pending; direct discard propagates that outcome. At the post-publication staging cleanup boundary, authorized pending cleanup preserves the completed artifact mapping and reservation rather than rolling back published files, including for empty-artifact staged sessions. Publication and identity failures still fail normally. Canonical absence or returned quarantine evidence does not prove physical reclamation or authorize a residue sweep.
Existing cleanup-pending history consumers replay incrementally with fresh per-replay limits: 50,000 inventory entries and receipt reads each, 100,000 charged work units, 64 MiB per receipt and 512 MiB of cumulative receipt bytes. Inventory is admitted before retaining entries, and no-follow descriptor size is admitted before allocation; canonical positive safe-integer attempts replay in numeric, contiguous order. Protocol inspection preserves retained-file authority and reports managed_gc_journal_capacity_exceeded rather than treating exhaustion as absence or corruption. The same explicit code survives SDK session.list saved-session omission diagnostics. These replay limits do not establish operation-wide nested-work or append admission, independent writer quiescence or physical reclamation.
Each transcript's committed GC retirement history admits at most 50,000 distinct persisted state files and 512 MiB of unique stored receipt bytes, with the same 64 MiB individual-receipt limit. Separately, a scope's shared receipt directory admits at most 50,000 retirement-receipt entries across all transcript targets. Readers, publishers, prepared-target discovery, and protocol inspectors use streamed bounded inventories and no-follow bounded reads; an over-limit target or shared directory fails the whole inspection rather than disappearing from discovery. Replay keeps incremental continuation evidence, checks canonical numeric contiguous attempts, and rejects immutable-field changes or authority expansion, including shrink then reintroduction. Publishers hold a scope-wide lease while admitting the projected new receipt and per-transcript bytes before candidate codec serialization, buffer allocation, or publication, preventing concurrent appends for distinct transcripts from overshooting the shared directory-entry limit. An idempotent nonappend does not consume another stored slot. These stored-history limits are distinct from the fresh per-replay inventory/read/work limits above; no aggregate operation-wide cumulative read, transcript/hash, nested-work, or append-reservation budget is installed. None of these limits establish writer quiescence or physical reclamation.
Leased and read-only GC receipt readers admit stored-history bytes from the original no-follow descriptor's size and link count before allocating receipt buffers. Reads cannot expand beyond that admitted size, and final descriptor and named-file generation checks reject growth or replacement. GC reader cleanup preserves the original capture failure when closing also fails. These checks do not establish operation-wide traversal admission or writer quiescence.
Separately, managed-session descriptor preflight and bounded range reads preserve the original metadata, pathname, or range error when closing their actual file descriptor also fails; close-only errors after a successful read still propagate. This narrow error-precedence behavior does not complete or expand whole-GC inspection capacity, capture, or protocol admission, nor does it imply completion of broader task-owner work.
Read-only receipt reads, discovery calls, and each protocol-inspector invocation separately admit the shared count/collect inventory family within 100,000 work units and 50,000 aggregate physical entry observations. Both passes, unrelated entries, repeated inventories and internal discovery consume the same invocation budget; acquisition, read attempts including EOF/errors, retention and metadata checks reserve work before proceeding. These units are conservative admitted actions, not exact syscall counts or elapsed time. Each stream has a one-entry buffer and closes on refusal; a fresh independent invocation gets a fresh budget. Multiple passes can exhaust admission below 50,000 distinct stored files and report managed_gc_journal_capacity_exceeded. Genuine leased receipt reader/publication paths preserve their stored quotas and ownership checks without this new post-publication inspection cap.
Standalone read-only receipt lookup reuses its admitted initial count for final verification only after fresh directory evidence matches. It still performs a fresh collecting stream and checks counts, names, types and final directory evidence. This removes one redundant count-only stream without changing quotas or leased, discovery, protocol-inspector or destructive paths.
This inventory admission does not cover separate cleanup-receipt replay, artifact-manifest or native-tree enumeration, aggregate file reads/hashing/nested work, or future writer reservations before serialization. Existing authority and discovery fences remain required; exhaustion is not absence, reclamation, quiescence or deletion permission.
Discovery retains each processed scope's original binding and receipt inventory, including authenticated absence, until its terminal consistency check. A new populated scope or journal inserted into an already-processed scope causes refusal instead of silent target omission or adoption of newly observed authority. Original readers are closed on success and refusal.
Exchange-placeholder capture and cleanup verification use a bounded native empty-directory proof. It retains no-follow root/parent handles, stops at the first child instead of traversing or hashing foreign payload, and rechecks the original native identity and metadata. An unexpected child or replacement refuses cleanup without changing it. This proof does not establish physical reclamation, revoke live writers, or bound other GC traversal paths.
On Linux filesystems where the exact POSIX ACL xattr operation returns ENOTSUP/EOPNOTSUPP, GJC treats that result only as proof that the filesystem cannot store that ACL attribute. The ACL gate still requires the same opened object to pass effective-owner, exact 0700 directory or 0600 file mode, safe-type, no-follow traversal, and identity/replacement checks. Permission denial, I/O errors, present or malformed ACL data, and unknown results remain failures. Managed descriptors use close-on-exec and are not delegated as authority to subprocesses. This compatibility rule does not change explicit --session-dir, macOS ACL, or Windows DACL policy.
Managed migration-lock release checks its retained descriptor before native security verification. On Linux, a closed or reused descriptor can be replaced only by reopening the same lock file with no-follow, owner-only security and original file identity checks; the original attempt id must still own the lock before its released record is written. A successor or an insecure replacement is never release authority. Repeated release is inert and cannot retire a successor.
Managed replacement receipts bind the exact staging attempt to a publisher (PID, kernel process incarnation, host, and JS-isolate owner). Both pending-receipt promotion and canonical-receipt retirement defer to a live or unobservable publisher. A stopped process is still an owner; age alone is never permission to reconcile. The publishing isolate releases its local attempt only after the transaction exits. A different process or isolate requires positive owner-exit or PID-reuse evidence before recovering its receipt. A foreign host remains unresolved. Existing orphan receipts still follow the exact receipt-identity recovery protocol; a missing pending name is accepted only when the canonical receipt proves the same file object and bytes.
Deploy this protocol at an owner-coordinated checkpoint for every writer and reconciler sharing a managed scope: older writers do not honor the publisher field. Updating one binary does not update already-running processes or clear their latched persistence errors. Preserve live owners, descendants, locks, transcripts, and ambiguous commit evidence; do not delete guards, refresh an expected inode blindly, or retry an uncertain write. A successful SDK query is not a durable-append or recovery verdict.
Blob store location:
~/.gjc/agent/blobs/<sha256>
Terminal breadcrumb files are written under:
~/.gjc/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
SessionEntryvalues or v4/v5 append-only patch records.header_patchrecords update header metadata andentry_patchrecords replace a message payload when replay metadata is sanitized. - Entries and patch records are append-only at runtime; branch navigation moves a pointer (
leafId) rather than mutating existing entries.
{
"type": "session",
"version": 5,
"id": "1f9d2a6b9c0d1234",
"timestamp": "2026-02-16T10:20:30.000Z",
"cwd": "/work/pi",
"title": "optional session title",
"titleSource": "auto",
"starredPatchVersion": 1,
"starred": true,
"parentSession": "optional lineage marker"
}Notes:
versionis optional in v1 files; absence means v1.starredPatchVersion: 1declares that the transcript can contain standalone star patches. It lets discovery skip star scans for older v5 transcripts while keeping their existing title/cwd patch lookup bounded.starredis optional and defaults tofalse./starand/unstarpersist changes through append-onlyheader_patchrecords; writers checkpoint the current star state beside later title/cwd patches so reverse discovery remains bounded. Starring adds no star-aware branch to ID resolution,--continue, deletion, or retention pruning and never protects a session. Like every transcript append, the metadata write updates the file's ordinary modification time, which those existing policies may already consume.parentSessionis an opaque lineage string. Current code writes either a session id or a session path depending on flow (fork,forkFrom,createBranchedSession, or explicitnewSession({ 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:
messagethinking_level_changeservice_tier_changecompactionbranch_summarycustomcustom_messagelabelttsr_injectionsession_initmode_changemcp_tool_selectiondiscovered_builtin_tool_selection
Stores an AgentMessage directly.
{
"type": "message",
"id": "a1b2c3d4",
"parentId": null,
"timestamp": "2026-02-16T10:21:00.000Z",
"message": {
"role": "assistant",
"provider": "anthropic",
"model": "anthropic-model-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": "service_tier_change",
"id": "c1d2e3f4",
"parentId": "b1c2d3e4",
"timestamp": "2026-02-16T10:21:45.000Z",
"serviceTier": "flex"
}serviceTier can also be null.
{
"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. content can be a string or text/image content blocks, and attribution records whether the user or agent initiated it.
{
"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 },
"attribution": "agent"
}{
"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": "mcp_tool_selection",
"id": "d2e3f4a5",
"parentId": "c2d3e4f5",
"timestamp": "2026-02-16T10:28:30.000Z",
"selectedToolNames": ["server.tool"]
}{
"type": "discovered_builtin_tool_selection",
"id": "e2f3g4h5",
"parentId": "d2e3f4a5",
"timestamp": "2026-02-16T10:28:31.000Z",
"selectedToolNames": ["search_tool_bm25"],
"mutationCorrelationId": "4c2b9c60-20d7-4a18-8d2a-8edc1f892b89"
}selectedToolNames is the explicit discovered built-in selection. mutationCorrelationId is optional and correlates adjacent MCP and discovered built-in selection records from one mutation.
{
"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: 5.
Applied when header version is missing or < 2:
- Adds
idandparentIdto each non-header entry. - Reconstructs a linear parent chain using file order.
- Migrates compaction field
firstKeptEntryIndex->firstKeptEntryIdwhen present. - Sets header
version = 2.
Applied when header version < 3:
- For
messageentries: rewrites legacymessage.role === "hookMessage"to"custom". - Sets header
version = 3.
Applied when header version < 4:
- Sets header
version = 4. - Introduces append-only
header_patchandentry_patchrecords.
Applied when header version < 5:
- Sets header
version = 5. - Separates MCP (
mcp_tool_selection) and discovered built-in (discovered_builtin_tool_selection) selection authority. The legacy v4 combined built-in field remains readable. - Patch records replay for v4 and v5 transcripts. Headers with a version greater than 5 are rejected before replay.
- v1-v4 transcripts remain readable without mutation during read-only inspection and strict resume selection. Patch records replay for v4 and v5 transcripts; headers with a version greater than 5 are rejected before replay.
- Mutable loads migrate v1-v4 entries in memory but do not rewrite on read. Migration and the complete v5 rewrite are deferred until the first authorized persistence.
- v5 sessions load without a migration rewrite. Once v5 data exists, do not roll back to a v4 writer: v4 writers cannot preserve v5 selection authority.
MCP and discovered built-in authority are independent. Constructor toolNames establishes authority only for the domain it names; currently essential built-ins remain baseline policy and never become discovered-built-in authority. A list containing only non-essential built-ins does not suppress configured or exact-config MCP defaults, and a list containing only MCP tools does not suppress built-in baselines. An explicit empty list clears both applicable domains. Explicit new-session names and empty clears are persisted as separate domain entries; omitted selections, essential baselines, and configured/exact baselines are not authoritative and are not persisted. Resume reconstructs state without appending authority entries.
A combined activation appends an MCP entry first and a discovered-built-in entry second. Both entries carry the same optional mutationCorrelationId; older entries without this field remain valid.
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 stringid) -> 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
parentIdis currentleafId. - The new entry becomes the new
leafId. branch(entryId)moves onlyleafId; existing entries remain unchanged.resetLeaf()setsleafId = null; next append creates a new root entry (parentId: null).branchWithSummary()sets leaf to branch target and appends abranch_summaryentry.
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).
buildSessionContext(entries, leafId, byId?) resolves what is sent to the model.
Algorithm:
- Determine leaf:
leafId === null-> return empty context.- explicit
leafId-> use that entry if found. - otherwise fallback to last entry.
- Walk
parentIdchain from leaf to root and reverse to root->leaf path. - Derive runtime state across path:
thinkingLevelfrom latestthinking_level_change(default"off")serviceTierfrom latestservice_tier_change- model map from
model_changeentries (role ?? "default") - fallback
models.defaultfrom assistant message provider/model if no explicit model change - deduplicated
injectedTtsrRulesfrom allttsr_injectionentries - selected MCP discovery tools from latest
mcp_tool_selection - mode/modeData from latest
mode_change(default mode"none")
- Build message list:
messageentries pass throughcustom_messageentries becomecustomAgentMessages viacreateCustomMessagebranch_summaryentries becomebranchSummaryAgentMessages viacreateBranchSummaryMessage- if a
compactionexists on path:- emit compaction summary first (
createCompactionSummaryMessage) - emit path entries starting at
firstKeptEntryIdup to the compaction boundary - emit entries after the compaction boundary
- emit compaction summary first (
custom, session_init, service_tier_change, mcp_tool_selection, and ttsr_injection entries do not inject model context directly.
SessionManager.create/open/continueRecent/forkFrom-> persistent mode (persist = true).SessionManager.inMemory-> non-persistent mode (persist = false) withMemorySessionStorage.
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 callsfsync().- 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.
Before persisting entries:
- Large strings are truncated to
MAX_PERSIST_CHARS(500,000 chars) with notice:"[Session persistence truncated large content]"
- Transient fields
partialJsonandjsonlEventsare removed. - If object has both
contentandlineCount, line count is recomputed after truncation. - Image blocks in
contentarrays with base64 length >= 1024 are externalized to blob refs:- stored as
blob:sha256:<hash> - raw bytes written to blob store (
BlobStore.put)
- stored as
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
Verified memory deletion rejects owner-bearing transcripts, including replayed header patches, and owner-retirement targets before changing transcript or spill keys. An absent transcript does not prove physical owner retirement; the memory backend cannot execute that protocol. Owner-store opening and deletion-evidence capture also require the final manifest to retain the requested session association, not merely the same owner locator.
SessionStorageWriter exposes writeLine, flush, fsync, close, getError.
Rollback snapshots are authenticated by the issuing live manager; copied, modified, or foreign objects cannot redirect restoration. Explicit persisted-file identity checks support already-admitted cold transcripts above 128 MiB without hashing the entire file: each check requests at most 8,585,217 bytes and compares the canonical pathname, session ID, file identity, size, and modification/change timestamps across bounded reads. This metadata-dependent fingerprint is not whole-file cryptographic coverage or one retained descriptor; unsampled changes preserving every compared field remain outside its detection boundary. Rejected cold restoration leaves the current session installed.
Defined in session-manager.ts:
getRecentSessions(sessionDir, limit)-> lightweight metadata for UI/session pickerfindMostRecentSession(sessionDir)-> newest by mtimelist(cwd, sessionDir?)-> sessions in one project scopelistAll()-> sessions across all project scopes under~/.gjc/agent/sessions
Metadata extraction reads only a prefix (readTextPrefix(..., 4096)) where possible.
HistoryStorage (history-storage.ts) is a separate SQLite subsystem for prompt recall/search, not session replay.
- DB:
~/.gjc/agent/history.db - Table:
history(id, prompt, created_at, cwd) - FTS5 index:
history_ftswith 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.