OpenCode
← all harnesses · parser: crates/core/src/session/opencode.rs
The first of the two harnesses that keep everything in one database rather than one file per session. That single fact is behind most of what is unusual here: the caching key, the connection pool, and why the mtime of the file on disk is useless as a freshness signal.
Where it lives
$OPENCODE_DATA_DIR, defaulting to the platform data directory plus opencode
(~/.local/share/opencode on Linux). Any file matching opencode*.db in it is
read: stable releases use opencode.db and nonstandard channels suffix the
name, so a machine can hold several. A session migrated between channels appears
in both, and list_sessions keeps the most recently updated copy.
Its configuration lives somewhere else — $OPENCODE_CONFIG_DIR, the platform
config directory — which is where --install-hooks writes the plugin, and where
access.rs looks for opencode.json.
The format
SQLite, read strictly read-only (SQLITE_OPEN_READ_ONLY | NO_MUTEX). Three
tables matter:
| Table | What cctop takes |
|---|---|
session | id, directory, title, model (JSON, .id inside), time_created, time_updated, and aggregate cost / tokens_* columns |
message | data, a JSON blob per message: role, modelID, tokens{input,output,cache{read,write},reasoning}, cost, time.created |
part | data, a JSON blob per message part: type: "tool", tool, state{input,status,time{start,end}}, callID |
Discovery reads the session table alone, so it stays a single query per
database however many sessions are in it.
Traps
cost: 0 does not mean free. OpenCode computes a cost for providers it has
rates for and writes zero for the rest — a local proxy, a gateway, any
OpenAI-compatible endpoint. Taken at face value that reads as “this was free”,
so assistant_usage falls back to pricing the tokens against LiteLLM
(FallbackRates) whenever the reported cost is zero and there were tokens.
A cost OpenCode did compute is authoritative and is never second-guessed.
The file’s mtime says nothing about a session. Every session shares one
WAL-backed database, so its size and mtime move when any of them is written.
cache::disk_key therefore keys OpenCode on the session’s own id plus the
time_updated discovery already read — the one stamp that moves for the session
that changed and no other. Before that, OpenCode sessions went uncached
entirely: on a 2019-session machine, 1478 of them were OpenCode and they were
84% of all extraction time, repeated in full on every run.
session::effective_mtime_ms makes the same choice for the same reason.
Zeroed turns sit at the end of many sessions. A turn aborted or failed
before the provider answered is still recorded with every count at zero, so
context_of walks back up to 40 assistant rows rather than reading the newest
one and giving up.
Connections are pooled per thread, and never revalidated. Every read of the
database goes through one thread-local handle — discovery, extraction, the
activity dot, the context window, the last tool, and the transcript scan behind
search — because a connection open of a database this size is not cheap whatever
the query costs. Opening per read used to mean several thousand opens of a 555 MB
database per walk, costing 240,000 context switches and nine seconds of kernel
time, almost all of it in file locking. They are thread-local rather than shared
because NO_MUTEX forbids cross-thread use and a shared connection behind a
mutex would serialise the one path that most needs not to be.
ponytail: a connection is held for the life of the thread, so a database
swapped out wholesale underneath cctop is read from the old file until restart.
delete writes through its own connection, and must be able to. It is the
one read-write path here, and it opens a second connection on top of the pooled
read-only one rather than dropping it first — the pooled handle is what makes the
deletion’s own aftermath cheap to read back. It works because a SQLite handle
holds its lock only while a statement is running, and because the deleting
connection sets foreign_keys so the cascade takes the session’s messages and
parts with it. a_delete_lands_under_the_handle_the_pool_is_holding pins both
halves of that on a rollback-journal database, where WAL’s reader-writer
tolerance would otherwise hide a regression.
write cannot report what it removed. tool_delta counts lines for edit
from the old and new strings and parses apply_patch properly, but a write
that replaces a file wholesale leaves no record of what was there, so no removal
count is guessed.
Cost
Reported — the only figures on the table that are not cctop’s arithmetic, apart
from Pi’s — with the LiteLLM fallback above for the turns OpenCode priced at
nothing. If no assistant message carries usage at all, the session row’s
aggregate columns stand in. See What the cost figures mean.
Context
extract_context sums the newest usable assistant message’s tokens.input,
tokens.cache.read and tokens.cache.write: what the next turn has to resend,
cached or not. Output is excluded because the next input count already contains
it.
The ceiling is LiteLLM’s max_input_tokens for the model, or nothing.
OpenCode records no window size of its own, and a guessed denominator would
misreport CTX% for every model that did not match it — so a model LiteLLM has
never heard of shows a blank context cell. That is the qualification behind the
README’s ✓.
Liveness
opencode --session <id> (or -s) puts the id on the command line, so the
match is exact — proc::session_value. The binary may be opencode,
opencode-cli, or node running a script whose stem is opencode.
Activity state does not come from a transcript tail, because there is no
transcript: extract_activity_state reads the newest message row instead. An
assistant message carrying an error whose text matches a list of transport
failures — timeout, rate limit, overloaded, connection, queue is full — is an
API error; a message with a part naming an input-request tool is waiting on
you.
delete is the one destructive path that writes to a harness’s own store:
DELETE FROM session with foreign keys on, which takes the messages and parts
with it.