Adding a harness
There is no trait to implement. A parser is a module in crates/core/src/session/ with a
handful of free functions, and the rest of the work is registering it at the
places that match on Provider — of which there are more than you would guess,
because most of them exist to keep an unsupported capability reading as
“unavailable” rather than as zero.
The order below is roughly the order in which things start working.
1. The provider
Provider in crates/core/src/pricing.rs. Add the variant, its
as_str (that string is the stable key: it prefixes Session::key, and
fleet.rs parses it back when reading another machine’s rows over ssh), and
records_tool_outcomes.
Answer that last one honestly. false means the transcript cannot say a call
failed, and it is what makes ERR% print ─ instead of a reassuring 0%. A
harness that records outcomes badly is still true; a harness that records none
is false.
2. Where its data lives
crates/core/src/config.rs. A LazyLock<PathBuf> per root, reading
an environment override first and falling back to the platform convention.
Follow the existing spelling: the override names the same directory the harness
itself would take, so a user who has already moved their sessions does not have
to tell cctop twice.
Then a *_roots() function through roots_across_homes, so $CCTOP_HOMES and
a root sweep find the other users’ copies too. Anything that reads the single
LazyLock directly will silently ignore them.
3. The parser
A new crates/core/src/session/<name>.rs. The functions the rest of the code will call:
#![allow(unused)]
fn main() {
pub fn list_sessions() -> Vec<Session>; // discovery
pub fn extract(path: &Path) -> SessionData; // the numbers
pub fn extract_context(session: &Session) -> Option<ContextUsage>; // optional
pub fn extract_last_tool(session: &Session) -> String; // optional
pub fn delete(session: &Session) -> std::io::Result<()>;
}
Two of the eight take a session id as well as a path — opencode::extract(path, id) and windsurf::extract(path, id) — because their file holds every session
at once. That is the only shape variation.
What each one owes:
list_sessionsmust be cheap. It runs on every full walk, over every session that has ever existed on the machine, so it reads a bounded head and tail rather than whole files (extract::read_first_lines,read_last_lines), and caches what cannot change in aSTATIC_CACHE. Pi is the one exception, and its page says why.extractmay be expensive; it is cached and fanned out across cores. It must returnSessionData::errorrather than partial totals when a read fails, because the cache refuses to persist an errored extraction and a fabricated$0.00would be indistinguishable from a free plan.extract_contextreturnsNonewhere the harness records no window, and that must stayNonerather than a guess: the denominator sets every percentage in the UI and where the auto-compaction marker sits.deletemay refuse. Windsurf’s returnsUnsupportedwith a message saying to delete it from the editor.
For JSONL, use extract::for_each_jsonl rather than reading lines yourself —
it handles oversized lines, salvaging their usage instead of dropping the
turn’s cost, and it skips the truncated tail a crashed writer leaves. Summarise
tool arguments through extract::tool_detail, and rename your harness’s tool
names into the common vocabulary at parse time, the way Codex’s
normalise_exec_tool does; one activity renderer serves all eight.
Where the harness reports a cost of its own, price it through FallbackRates
when it reports zero against non-zero tokens. Every harness that supports
arbitrary providers writes 0 for the ones it has no rates for, and that is not
the same as free.
4. Wiring it in
| File | What to add |
|---|---|
session/mod.rs | the rayon::join in list_all; effective_mtime_ms; Session::resume_argv; is_waiting_for_input_event; Surface::label |
cache.rs | disk_key (only if the file does not back one session alone), the extract dispatch, and the trace span name |
loader.rs | read_tail’s three dispatches, plus any provider-specific liveness inference |
proc.rs | could_be_agent, the per-provider process test, and how a session id appears on the command line |
doctor.rs | a row in sources(), so a missing directory reports as “not installed” rather than as zero sessions |
access.rs | rules files, config files, and the hook::Harness mapping if there is one |
session/search.rs | how / searches its transcripts — the default line scan, or a query if the store is shared |
serve/chat.rs | a conversation reader, if you want the browser view; Windsurf is the only one without one today, and it says so rather than looking empty |
cli.rs | the --list grouping and the --json account block |
hook/installers.rs | Harness and its configs, if the harness has hooks to install into |
ui/table.rs has a test asserting every provider appears in the empty state.
It will fail until you add yours, which is the intended reminder.
5. What to document
- The README’s capability table. A
─there is a claim about the harness, not about cctop, so it needs to be true. docs/costs.md— the path table and the environment-override list.docs/troubleshooting.md— the same override list, under “My sessions are missing”.- A page in this directory, and a row in its index. Say what you could not extract and why; that is the half a future reader cannot reconstruct from the code, because absent code leaves no trace.
Pricing needs nothing if the harness reports its own costs or its models are in LiteLLM. A built-in rate table is only worth adding for models LiteLLM lags on.
6. Tests
Each parser’s mod tests writes a fixture into std::env::temp_dir(), parses
it, and removes it. Keep to that: it is fast, needs no fixture directory in the
crate, and cargo publish has a size limit.
Write the test for the trap, not for the happy path. The existing ones are
almost all regressions — cached input not double-billed, a $set patch reading
identically to the whole-file shape, a batched exec counting more than one
call — and each names in its doc comment what went wrong.
Then run the gate the way CI does, from CLAUDE.md.