Sessions, store & rollout
A session is one continuous conversation with the agent: your messages, its replies, every tool call in between. This page is about what happens to that conversation after it scrolls off your screen — where it is written, how it is loaded back, and how it survives a Ctrl+C, a laptop reboot, or a fork.
In the memory layer vocabulary this is the episodic layer: specific past events, stored with their timestamps, in the order they happened. It is the one layer that keeps everything and forgets nothing — which is exactly why it needs three different shapes on disk.
Two logs, two readers
The same turn gets written down twice — a third shape (the thread store, below) exists but nothing writes to it anymore:
| Log | Reader | Shape | Mutable? |
|---|---|---|---|
messages.jsonl | the model — replayed into the next prompt | conversation messages with parts | yes, rewritten by compaction |
events.jsonl | you, debugging — replay, traces, analytics | append-only event log | never |
The distinction that matters most is the last column. The message log must be editable, because compaction trims old turns out of it so the next request is smaller. The rollout log must be append-only, because its whole value is being the record of what actually happened — including the turns compaction later erased. One file cannot be both, so there are two.
~/.freecode/
├── sessions/<project-dir>/<sessionId>/
│ ├── meta.json title, provider, model, status, counts
│ ├── messages.jsonl one message per line, appended as produced
│ └── context-cache.json files requested across turns
├── state/
│ ├── freecode.db SQLite: threads, turns, tool_calls
│ └── store.json JSON fallback when SQLite is unavailable
└── rollout/sessions/<sessionId>/
└── events.jsonl append-only audit logEverything lives under ~/.freecode/, never in the project directory. Sessions
then survive git clean, don’t need .gitignore entries, and one backup covers
every project.
Why project directories have those strange names
sessions/ is keyed by a flattened project path (store/path-formatter.ts):
/home/ayan/Project/opencode → home__ayan__Project__opencode
/home/ayan/Project/my-project → home__ayan__Project__my_h_project
C:\Users\john\projects\myapp → C__Users__john__projects__myappSegments join with __, literal hyphens escape to _h_, and Windows-illegal
characters (< > : " | ? *) are stripped — which is what turns C:\ into C.
The escaping exists so the transform is reversible: parseSessionDirName()
recovers the original path, so a directory listing is enough to answer “which
project was this?” without opening any file.
Persistent memory now keys on the same transform (
mem-store.tsimportsformatSessionDirName), so the two stores agree on what identifies a project.
A session’s life
| Step | IPC method | What happens |
|---|---|---|
| Start | session.start | UUID, meta.json written, empty messages.jsonl created (server.ts:341) |
| Send | session.send | the loop runs; each message is appended as it is produced |
| Resume | session.resume | metadata + full message log returned; the loop reloads them into history |
| Switch | session.switch | changes which session is current; nothing is loaded |
| Fork | session.fork | copies the log into a new session with parentId set |
| Archive / delete | session.archive / session.delete | status changes only |
| Share | session.export / import / upload / download | JSON payload to a sync endpoint or local remote dir |
Two behaviours in that table are worth knowing before you trust them:
Delete is a soft delete. deleteSession() sets status: "deleted"
(session/store.ts:337) and leaves the files in place; list() filters those
rows out unless you ask for them explicitly. Nothing on disk is removed, which is
recoverable but also means “delete” does not reclaim space or erase content.
Resume restores the model, not just the messages. SessionMeta.model is
carried into SessionContext and back into the live session (server.ts:889),
because a resumed session silently reverting to the provider’s default model is a
change you would only notice in the bill.
What a message looks like
{"id":"msg-1","role":"user","parts":[{"type":"text","content":"hello"}],"timestamp":1700000000000}
{"id":"msg-2","role":"assistant","parts":[{"type":"tool","tool":{"name":"read","args":{"path":"/foo.ts"}},"result":"…"}],"timestamp":1700000001500}Parts are text, code, tool, or image. Two optional fields carry meaning:
interrupted— set on the message that was in flight when you hit Ctrl+C.usage— the provider’s token counts for the request that produced this message. It is present on at most one message per provider response (session/store.ts:45), because the loop persists a single response as several messages — text, then one per tool call. Attaching usage to each would multiply the session’s apparent cost; attaching it to one makes a plain sum correct. Per-request cache-hit ratios cannot be reconstructed from a daily total, which is why they are stored per message at all.
Loading it back
loadHistory() (loop.ts:338) maps stored messages back into the loop’s
in-memory Message[]. One line in there is load-bearing:
id: `tool-${msg.id}-${partIndex}`,The provider’s original tool_use id is not persisted, so it has to be
re-derived. It must include the part index: one assistant message can carry
several tool calls, and keying them all tool-<msgId> made those ids collide,
which history pruning then read as a single result and applied one decision to all
of them. The derivation is deterministic, so ids stay stable across reloads —
which matters, because pruning state is keyed by them.
The same function guards args: a bare string is truthy, so part.tool?.args || {} happily let malformed arguments from an older session round-trip back to the
provider. It now checks for a plain object instead.
Interrupts
InterruptHandler (session/interrupt.ts) owns SIGINT:
- First Ctrl+C → the in-flight assistant message is marked
interruptedand the session status becomesinterrupted. - Second Ctrl+C within 1 second →
process.exit(1), no cleanup.
The interrupt target is set when the assistant message is appended
(loop.ts:2575), so the mark always lands on the message that was actually being
written rather than on whatever is last in the file.
On the next resume, if the last message carries interrupted, the manager
appends a synthetic user message — “Continue from where you left off.”
(session/manager.ts:101) — so the model gets an explicit instruction instead of
being handed a truncated transcript and left to guess.
Compaction rewrites the log
Compaction is the one thing allowed to edit messages.jsonl, and it does so
through applyCompaction() (session/compact-apply.ts):
MemoryService.compact()produces a summary, which lives in the system prompt.- The stored log is trimmed to the last N user turns and rewritten in place via
replaceMessages().
keepLastNUserTurns() slices from a user message, never mid-turn. A history
that begins with an assistant reply to a question the model can no longer see is
not a valid conversation, and some providers reject it outright. The trim is what
makes compaction persist: without it, the next turn’s loadHistory() would load
the full log again and undo the work.
The rollout log is untouched by any of this — compact.occurred is recorded there
with before/after token counts, so the erased turns remain reconstructible.
Forking
fork() (session/store.ts:436) creates a new session, sets parentId, copies
turnCount, and replays every message into the new log. A full copy, not a
reference: the two histories diverge from that point and neither can corrupt the
other. Cheap, because sessions are text.
Rollout: the append-only record
Everything the agent does emits an event to
~/.freecode/rollout/sessions/<id>/events.jsonl. Every event shares a base shape
(rollout/types.ts:16):
| Field | Purpose |
|---|---|
id | ULID — timestamp-prefixed, so lexical order is chronological order |
seq | sequence within the aggregate, resumed from the file’s line count on restart so it stays unique across process restarts |
aggregateID | what this is a record of — usually the session |
timestamp | wall clock |
Nineteen event types, grouped by what they record:
| Group | Events |
|---|---|
| Turn | turn.started, turn.aborted |
| Tools | function.call, function.output, function.denied |
| Model | model.request, model.first_token, model.response, model.error |
| Context | compact.occurred, context.overflow |
| Extensions | subagent.start, subagent.stop, skill.invoked, hook.triggered, hook.blocked |
| Loop health | redirect.triggered, redirect.skipped |
| Failure | parse.error |
redirect.triggered records that the loop was advised to change approach, and
which events the advice was formed on — but never the advice text. See
trajectory redirection.
A refused call is not a failed call
function.denied records a tool call the model made that never ran — blocked
by a hook, the agent mode, a permission rule, or you saying no. Its source field
says which of those it was, so “the mode forbids this” and “the user declined” do
not read as the same event.
It exists because without it a refusal left no trace at all: loop.ts returns
before recordFunctionCall, so there was no function.call/function.output
pair for buildTrace to fold. A model burning six turns retrying a command its
mode forbids looked, in the log, like a model that did nothing — which is the one
shape loop health most needs to see. All four refusal sites now exit through a
single denyToolCall().
It is deliberately not a function.call with a failed output. The tool did not
execute, so counting it as one would put attempted mutations into changedFiles
and let an eval’s expectTool be satisfied by work that never happened. The same
split runs through the fold: Trace.toolSpans means tools that ran, and a
denial goes to Trace.deniedSpans instead.
The one ordering decision that matters
model.request is written before the provider call, not after
(recorder.ts:339). That is deliberate, and it is the difference between an
observable agent and an opaque one.
Before these events existed, a stalled request left turn.started with no
successor — which is indistinguishable in the log from a turn that simply ended.
“The agent is hung” and “the agent is done” looked identical after the fact. With
the request written first, an unterminated request is itself the evidence: a
model.request with no matching model.response or model.error is a hang, and
model.first_token separates “the provider never started” from “it died
mid-stream”.
model.error carries a kind for the same reason: stall (went silent past its
budget), abort (you cancelled), or provider (it returned an error) are three
different problems that used to look like one.
Reading it back
replaySession() (rollout/replay.ts:44) folds the event stream back into
structured state — turns, their tool calls, outputs, durations, compaction totals,
parse errors. It is a pure fold over an append-only log, which is the property
that makes event sourcing worth the extra file: the reconstruction is total and
repeatable, and a new question about old sessions can be answered by writing a new
fold rather than by having logged the answer at the time.
rollout/trace.ts is the second fold, producing spans and marking any unterminated
request in_flight or (past the hang threshold) hung. That is what
freecode trace renders — see Tracing & observability.
Recording is on by default and disabled wholesale in tests
(effect/layers.ts:158), so a test run never litters ~/.freecode.
The thread store
store/ is a relational view of the same data: threads → turns → tool calls,
behind a ThreadStore interface with two implementations.
getThreadStore()
├── try SQLite (better-sqlite3, then sql.js) → ~/.freecode/state/freecode.db
└── otherwise JSON file → ~/.freecode/state/store.jsonThe fallback exists because SQLite is a native module: it fails to build on some
platforms and is awkward inside a single-file compiled binary. Rather than make
the agent unusable there, the store degrades to a JSON file with the same
interface — slower and unindexed, but sessions still work. Selection happens once,
lazily, at first use (thread-store.ts:28), and the dynamic import() means the
SQLite module is never even loaded when it isn’t needed.
Unwired. The full API — turns, tool calls,
searchThreads,getTurnItemsView, goal tracking — is implemented on both backends, but nothing in the live path calls it.SessionManagerused to callthreadStore.create()when a session started; that write had no reader anywhere (session listing has always readmeta.jsonfiles instead), so it was removed rather than left as a write with no purpose. Treat the schema as built and waiting, not as a source of truth — wiring it up means picking a real reader first.
Remote sync
session.export POSTs { meta, messages } to a sync endpoint (configurable via
syncEndpoint in ~/.freecode/config.json, default https://sync.freecode.dev)
and returns a URL with an expiry; session.import pulls one back and recreates it
locally as a new session. store/remote.ts implements a local variant of the same
export format under ~/.freecode/remote/, and can carry memory entries alongside
the transcript.
Nothing syncs automatically. Export is an explicit action, because a session transcript contains file contents and command output from your machine.
Known gaps
- Delete defaults to a status flag, not erasure. Files stay on disk unless the
caller opts in —
session.deletenow accepts{ sessionId, purge: true }over IPC, which removes the session directory outright. The default is unchanged on purpose: a status flag is recoverable, which matters more often than it doesn’t. No CLI flag yet for it (the CLI’ssession deletealready always purges, via a separate path incli/utils/sessions.ts).
Where to look
| You want | File |
|---|---|
| Session files, meta, JSONL append/replace | apps/core/src/session/store.ts |
| Start / resume / fork / export | apps/core/src/session/manager.ts |
| Ctrl+C handling | apps/core/src/session/interrupt.ts |
| The compaction rewrite | apps/core/src/session/compact-apply.ts |
| Project-path flattening | apps/core/src/store/path-formatter.ts |
| Backend selection, thread API | apps/core/src/store/thread-store.ts |
| Event definitions | apps/core/src/rollout/types.ts |
| Writing events | apps/core/src/rollout/recorder.ts |
| Reading and folding them | apps/core/src/rollout/history.ts, replay.ts, trace.ts |
| Loading history into the loop | apps/core/src/agent/loop.ts (loadHistory) |
Related: Compaction for what trims the message log, Memory for the semantic layer built on top of these episodes, and Tracing & observability for reading the rollout log.