Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

cctop

crates.io CI Linux license

htop for your AI coding agents. One terminal screen listing every Claude Code, Codex, Cursor, Devin, Gemini CLI, Mistral Vibe, OpenCode, Pi and Windsurf session on your machine, and on your other machines: what each is doing, what it has cost, and which one is waiting on you. You can open any of them in a tab and type into it.

Quick start: install with the one line under Install, then run cctop. Nothing to configure first.

Every screenshot on this page is cctop drawing made-up sessions. Want it moving? A recording, or the same in a terminal with asciinema play.

It reads what the agents already write to disk, so it sees sessions it did not start, including ones that ended weeks ago.

Install

Linux only, WSL included. There is no macOS or Windows build.

curl -fsSL https://raw.githubusercontent.com/flolep2607/cctop/main/install.sh | sh

The script picks the x86_64 or aarch64 release, checks its checksum and puts one static binary in /usr/local/bin (or ~/.local/bin). It then offers to install sshfs, which only cctop sandbox needs; Enter says no.

Other ways to install

From the release archive, by hand (swap in aarch64 on ARM):

d=$(mktemp -d)
curl -fsSL https://github.com/flolep2607/cctop/releases/latest/download/cctop-x86_64-unknown-linux-musl.tar.gz | tar xz -C "$d"
sudo install -m755 "$d/cctop" /usr/local/bin/cctop && rm -rf "$d"

With cargo:

cargo binstall cctop           # the release binary
cargo install cctop --locked   # compile it; needs Rust 1.89 or newer

From source

git clone https://github.com/flolep2607/cctop && cd cctop
cargo build --release          # binary in target/release/cctop

Checksums and updating (cctop --update) are in Installing cctop.

First steps

1. Run it

cctop

It finds your sessions, prices them and draws the table. On its first run it also installs its hooks into every agent it finds a config directory for, so Claude Code, Codex, Gemini CLI, Cursor and OpenCode report their state as it happens instead of cctop inferring it from disk. cctop --hooks-status shows what is installed, and cctop doctor says what is wrong when something looks off.

The keys to start with:

Key
↑ ↓move between sessions
Entereverything you can do to the selected session, in one menu
/filter on anything in a row
Rresume the selected session in a tab of its own
stype a line into a running agent
tnew tab: an agent or a shell, here or on another machine
Alt+bjump to the tab that is waiting on you
F12 or Alt+1back to the dashboard; the tab keeps running
,settings
?every key
qquit

Agents in cctop’s tabs run in its built-in multiplexer, so they keep running after you quit and are back in their tabs next time.

2. Use it from your phone

Press B in the dashboard for the browser panel, then t to serve the same table, and a page per session, over a public Cloudflare link (l serves it on this machine only). c shows the link as a QR code to scan. Or run cctop serve --tunnel directly. Whoever holds the link can drive your agents; a second, read-only link is printed beside it.

For a link that stays the same across runs, connect your own free Cloudflare account once (it needs a domain whose DNS is on Cloudflare):

cctop tunnel setup --browser

Details, Cloudflare Access logins and invites: In a browser.

3. Several machines, one table

Run cctop tunnel setup --browser on each machine, against the same Cloudflare account, and keep each one serving on its tunnel (B then t, or cctop serve --tunnel). Each then shows the others’ sessions in its table, with the machine’s name on the row. No ssh route is needed. R opens a remote session in a tab here, and t starts a new agent on any of them.

Over ssh instead: cctop --host devbox adds another machine’s sessions, read-only.

Features

Every machine, one table

Machines connected to one Cloudflare account find each other: their sessions are rows here, with the machine in the HOST column. R on one of them opens it in a tab on this screen, and t starts a new agent or a shell over there. A tab on another machine says how it gets there: ☁ through a sibling’s tunnel, ⇄ over ssh.

A remote tab is built for a link that is slow or flaky:

  • Local echo. Each key would wait a round trip through Cloudflare (around 120 ms) before its letter appeared. cctop draws the letter at once, underlined, and lets the far side’s echo confirm it, as mosh does. predictive_echo in settings: auto (on above 60 ms), always or off.
  • Resume on drop. When the connection drops (the tunnel moves, the network changes) the tab gets back to the same terminal and is sent what it missed. A drop shorter than two seconds shows nothing at all.

A new tab, anywhere, set up the way you want

Three steps of the New tab menu: the list of agents with codex set to gpt-5-codex and YOLO; the place field offering local projects, a sibling machine named desk and an ssh host; and codex's options for model, permissions and worktree

t (or Alt+n) lists the agents installed here and a shell. c changes where it starts: a directory here, a machine on your Cloudflare account, or an ssh host. → opens the agent’s options before it starts: its model, which account it spends, its permission mode (YOLO is drawn in red) and a fresh git worktree. The line under them is the exact command Enter runs.

The web dashboard

cctop serve in a browser: the session list with project, title, model, cost and context per row

cctop serve (or B in the dashboard) puts the same sessions in a browser, with a page per session for its conversation, its changes and its tools, a workspace of live terminals, and analytics. It follows your system’s light or dark theme.

The same page at phone width, in the dark and the light theme

On a phone it is one column. Over your Cloudflare tunnel, the agent waiting on you can reach you wherever you are.

Your tunnel, inside Settings

, opens every setting on one filterable page. Type /tunnel and the Cloudflare tunnel is right there as rows: whether it is connected, its hostnames, who Cloudflare Access lets in (an email, or a whole @domain, read or full), and the button that deletes it with its DNS records.

Graphs in braille, octants or sextants

The charts are braille by default, since every font has it. graph_glyphs in settings switches them to solid octants (Unicode 16) or sextants, if your terminal font has them; the setting’s row draws a sample of each before you choose.

Updates you can trust

cctop updates itself when it starts, once it knows a newer release exists, and cctop --update does it on demand. The archive comes from a mirror on Cloudflare’s edge, or from GitHub if the mirror cannot serve it, and is checked against the ed25519 signature the release was published with, whichever one served it. Then it tells you what changed. A cargo install or a distro package is left to whatever installed it.

And everything else

  • Cost. Tokens times published rates, per session, hour, day and model.
  • Context. CTX% shows how full each context window is; the Context panel shows what fills it.
  • Waiting on you. The status dot goes amber when an agent needs a reply, and turns to ✓ when a turn ended while you were looking elsewhere.
  • Stuck work. ERR% is the share of a session’s tool calls that failed.
  • Collisions. The ! column warns when two agents edit the same files in one checkout.
  • Terminals. Tabs are real terminals you can type into, split and drag.
  • Handoff. O hands a session’s context to a different agent or account.
  • What the money bought. cctop optimize finds spend that bought nothing, cctop compare puts your models side by side on your own work, and cctop yield checks which sessions’ work reached the default branch.
  • Recall. cctop recall "<query>" finds what past sessions said about something; cctop --install-mcp gives the agents the same.

Note

Costs are estimates: tokens times published per-token rates. Subscription plans (Claude Max, Pro, Team) bill differently, so the $ column will not match your invoice. --plan max shows bundled usage as incl. What the cost figures mean.

Which agents it reads, and what each one records
AgentCostTokensContextToolsLive process
Claude Codeestimated✓✓ full breakdown✓✓
Codexestimated✓✓✓✓
OpenCodereported✓✓✓✓
Pireported✓─✓✓
Gemini CLIestimated✓─✓─
Devin─✓✓✓✓
Mistral Vibeestimated✓✓✓✓
Cursor──✓✓inferred
Windsurf───✓─

A ─ marks something the harness does not record. Each provider’s page has the detail.

Commands
cctop                 # interactive UI
cctop --list          # print a table and exit
cctop --json          # dump full session data as JSON
cctop --statusline    # one line for a status bar: "3 working · 1 waiting · $4.12/h"
cctop --plan max      # treat Claude usage as bundled
cctop --host devbox   # also show another machine's sessions, read over ssh
cctop sandbox devbox:/srv/api   # Claude here, its commands and files on devbox
cctop doctor          # check this installation and say what is wrong with it
cctop serve           # the same table in a browser
cctop tunnel setup    # your own Cloudflare tunnel, for a link that stays the same
cctop claude          # start an agent on a pty cctop can watch and type into
cctop --update        # update now, rather than at the next start

cctop --help has the rest.

Documentation

All of it, searchable, at flolep2607.github.io/cctop.

More: what it cost you for · did the spend ship · what the subscription bought · what the cost figures mean · provider by provider · integrations

Installing cctop

← back to the README

cctop runs on Linux, including WSL. It reads Linux process tables and drives agents over ptys and unix sockets, and there is no macOS or Windows build.

cctop is a single binary. It links no system libraries and needs no runtime, so “installing” it means putting one file on your PATH. That includes the multiplexer its tabs’ agents run in: rmux is built in, so there is no rmux or tmux to install alongside it.

Let the script do it

curl -fsSL https://raw.githubusercontent.com/flolep2607/cctop/main/install.sh | sh

It works out your architecture from uname -m, fetches that archive from the latest release, verifies it against the .sha256 published beside it, and installs the binary into /usr/local/bin — or ~/.local/bin if that directory is not yours to write, printing the line to add to your profile when the fallback is not on your PATH either. sudo is used only if the install needs it, and a sudo that cannot run (no tty, no askpass, no sudo at all) falls back rather than stopping.

Then, if sshfs is missing, it offers to install it — what cctop sandbox needs — with [y/N], so pressing Enter changes nothing. The question is asked on the terminal even under curl | sh, and skipped where there is none. To answer it in advance:

curl -fsSL https://raw.githubusercontent.com/flolep2607/cctop/main/install.sh | CCTOP_WITH_SSHFS=1 sh
curl -fsSL https://raw.githubusercontent.com/flolep2607/cctop/main/install.sh | sh -s -- --with-sshfs

(CCTOP_WITH_SSHFS=0 or --without-sshfs to skip it.) It uses whichever of apt-get, dnf, yum, pacman, zypper and apk it finds, through sudo unless you are root, and prints the command before running it. A failed sshfs install is reported and does not undo the cctop one.

Nothing is unpacked until the checksum has been checked, and a mismatch stops the install instead of warning about it afterwards. To read it before running it, curl -fsSLO it and look.

Download a binary by hand

A release ships two archives, x86_64 and aarch64, both statically linked against musl — so either runs on any distro, glibc or not. Grab the one for your architecture from the latest release and put cctop somewhere on your PATH:

# x86_64
d=$(mktemp -d)
curl -fsSL https://github.com/flolep2607/cctop/releases/latest/download/cctop-x86_64-unknown-linux-musl.tar.gz | tar xz -C "$d"
sudo install -m755 "$d/cctop" /usr/local/bin/cctop && rm -rf "$d"
# aarch64
d=$(mktemp -d)
curl -fsSL https://github.com/flolep2607/cctop/releases/latest/download/cctop-aarch64-unknown-linux-musl.tar.gz | tar xz -C "$d"
sudo install -m755 "$d/cctop" /usr/local/bin/cctop && rm -rf "$d"

The archive holds one file, called cctop, which is why each of these unpacks into a temporary directory rather than into whatever directory you are standing in. Run one inside a cctop checkout and tar stops at Cannot open: File exists; the install that follows then finds the crate directory where it expected the binary, reports omitting directory 'cctop', and leaves nothing on your PATH.

Every archive ships with a .sha256 file next to it, named for the archive rather than after it — cctop-x86_64-unknown-linux-musl.sha256, not cctop-x86_64-unknown-linux-musl.tar.gz.sha256, which is a 404:

d=$(mktemp -d)
curl -fsSL -O --output-dir "$d" https://github.com/flolep2607/cctop/releases/latest/download/cctop-x86_64-unknown-linux-musl.sha256
curl -fsSL -O --output-dir "$d" https://github.com/flolep2607/cctop/releases/latest/download/cctop-x86_64-unknown-linux-musl.tar.gz
(cd "$d" && sha256sum -c cctop-x86_64-unknown-linux-musl.sha256)

Staying up to date

A downloaded binary has no package manager behind it, so cctop --update fetches the newest release for your architecture and replaces the running executable in place:

cctop --update

Replacing the binary needs write access to the directory it lives in. The install above puts it in /usr/local/bin with sudo, so updating it needs sudo too:

sudo cctop --update

An update that lands prints what it brought, one heading per version crossed, so a machine three versions behind sees all three rather than only the newest:

Updated 0.7.0 -> 0.7.3.

What changed since 0.7.0:

  0.7.1
    - Codex accounts per subscription, and a tab you can close without losing
      your place

  0.7.2
    - Fix the login hint for a named account, and add a run skill that drives
      the TUI

  0.7.3
    - the signals between cctop and its agents stop getting lost

Full notes: https://github.com/flolep2607/cctop/releases

The notes come from the GitHub releases themselves, fetched after the binary is already in place — so a network that fails at that moment costs you the summary and not the update, and you get the link instead.

cctop checks for a new release once an hour in the background and, when one exists, says so in the footer. The next time you start it, it installs that release before opening: the version was already known, so nothing is waited on to discover it, and the download is the only pause. It then prints what changed, waits for you to press Enter, and starts as the new version.

Startup is the only moment it will do this. Nothing is open yet — no terminal held, no agent hosted, no pane to lose — so the new binary can simply take the place of the old one. That is not true of any later moment, which is why an update that becomes available while cctop is running waits for the next start rather than interrupting the one you are in.

A release counts once its binary for your machine is attached, so cctop never offers one it cannot fetch yet. It installs a given version over a given binary at most once: if that version is installed and the old one still starts, it says so on each start instead of downloading again. Each update it attempts leaves a line in update.log in the cache directory, which is the thing to attach to a report about one that went wrong.

To start on the version you already have, pass --no-auto-update. To stop it happening at all, set "auto_update": false in ui-prefs.json under your cache directory (~/.cache/cctop); cctop --update still works whenever you want it. cctop claude and the other agent aliases skip it too — you are waiting on an agent, and a download between the command and the agent starting is not what you asked for.

If cargo installed cctop, it asks instead of acting:

cctop 0.7.6 is out, and cargo installed this one (0.7.5) — replacing the binary
here would leave `cargo install --list` naming a version that is gone.
Run `cargo install cctop --version 0.7.6 --locked`? It builds from source. [y] update / [n] not now:

Answering y runs exactly that command, with cargo’s own output on screen, and starts the new version when it finishes. The objection was never the file — ~/.cargo/bin is writable — but the bookkeeping, and cargo install is what keeps cargo’s record straight. It builds from source, so it takes minutes, which is why it is a question and not a default. With cargo-binstall on your PATH the command offered is cargo binstall cctop --version … --locked --no-confirm instead, which fetches the release archive in seconds and keeps the same record. Answering n is remembered against that version and the question waits for the next release; cctop --update asks again whenever you want it.

A package manager other than cargo is left alone entirely — cctop will not touch a binary something else is responsible for. Update it the way you installed it.

A cargo build output is left alone as well, for bookkeeping of a quieter kind: cargo’s fingerprint sits beside target/debug/cctop and would go on calling whatever replaced it the build it produced — the update would land, and cargo build would report the file fresh and never put the real one back. cargo install cctop --force is how the release reaches PATH.

With cargo

cargo binstall cctop

cargo-binstall downloads the release archive, the same static binary the script installs, and records it the way cargo install would — so nothing has to compile. Without binstall, cargo builds it from crates.io:

cargo install cctop

Or straight from the repository, without waiting for a release:

cargo install --git https://github.com/flolep2607/cctop

From source

git clone https://github.com/flolep2607/cctop
cd cctop
cargo build --release

The binary lands at target/release/cctop. It links no system libraries and needs no runtime — a single file you can copy anywhere.

Building requires Rust 1.89 or newer (the code uses let-chains and std’s file locks).

Reading the table

← back to the README

One row per session, whether it is running now or ended weeks ago. Columns drop from the right as the terminal narrows, worst-informing first, so the ones that say which session this is survive to the last. Hover any header for what it means, click one to sort by it, or press F6 for the sort list.

Hide columns you never read with $CCTOP_COLUMNS_HIDE, a comma-separated list of the keys below — CCTOP_COLUMNS_HIDE=tok_rate,mem.

The columns

ColumnKeyWhat it says
(dot)statusWhether the session is running, and what it is doing — see below
LASTactiveTime since the session last did anything
DURdurationFirst to last activity
$costEstimated cost — see What the cost figures mean
$/1Hcost_hourEstimated cost in the last 60 minutes, rolling
$/24Hcost_todayEstimated cost since local midnight
CTX%ctxContext window used, as a share of the auto-compact threshold. COMPCT while one is happening
CPU%cpuCPU across the session’s process tree
MEMmemResident memory across the session’s process tree
TOOLStoolsTool invocations
ERR%errorsShare of those calls that failed — see below
TOKENStokensInput plus output
TOK/mtok_rateToken rate, smoothed
MODELmodelModel in use
HARNESSharnessThe host application — Cursor, a terminal CLI — as distinct from the model. ─ rather than a guess
PERMpermHow much it asks before acting — see below
!conflictAnother agent is on the same ground — see below
HOSThostWhich machine, when reading more than one. Hidden otherwise
USERuserWhose session it is, when watching every user. Shown only while more than one user’s sessions are on the table
PROFILEprofileWhich Claude or Codex login the session was read from, blank for the harnesses that have no such thing. Shown only when there is more than one
BRANCHbranchBranch checked out in the working directory, @<commit> when detached, ─ when not a repository
PROJECTprojectThe session’s title if it has one, otherwise its working directory

The status dot

The left status dot is green while an agent is working, amber after its latest response is waiting for your input, and red when the newest transcript event is an API error. A hollow grey dot is a stopped session, and a filled ◉ is the session that rang in the last 30 seconds.

A live session whose turn ended while you were not looking at it wears a ✓ in the accent colour instead of its amber dot: done, unseen. Amber says the prompt is yours, which is true of most agents most of the time; the check says this one has news since you last looked. It is cleared by looking, which means one of two things — its row is the selected one while the dashboard is on screen, or its pane is the focused pane of the tab you are on. A split’s other pane does not count, for the same reason it still lights up the tab bar: you are not reading it. The tab bar carries the same ✓ after the tab’s label, and Alt+b goes there once nothing is blocked on a question.

The mark belongs to this cctop alone. A tab’s name and colour follow it into every cctop on the machine, but having read a reply on your laptop says nothing about the cctop on your phone, so seen-ness is kept in memory and not written anywhere. It also only marks a turn it watched end: a cctop started after an agent went quiet has no news to report about it.

A session past one of the alert_* thresholds you have set wears that alert in place of its dot for as long as it stays past it: $ for its cost or burn rate, a red ! for an error loop, ◌ for a working agent that has written nothing for a while. See alerts.

Is it me, or is it them?

A red dot says a request failed. It cannot say why, and the answer is often somewhere else: a vendor incident, a dead proxy, an expired credential.

So cctop reads Anthropic’s and OpenAI’s status pages (status.claude.com, status.openai.com) every two minutes, in the background, and sets what they say against the running sessions that are failing. The footer says something only when there is something to say:

  • ⚠ Anthropic incident: Elevated errors on Claude Opus — 2 of yours failing · ! when the vendor reports an incident, with the count when it touches yours;
  • ⚠ 2 failing, Anthropic reports all clear: probably this machine · ! when sessions fail while the page says all systems are operational.

! opens the vendor’s own account: each open incident, where they are with it, since when, their latest update, and the components affected. r in it checks again now. Sessions are matched to a vendor by model first and harness second, so a Cursor session running Opus counts against Anthropic, and one running a model cctop cannot place is left out rather than blamed on a guess. When a page cannot be reached — offline, say — the footer stays silent and the panel says the page was unreachable. footer_hide = "provider" leaves the line off.

PERM — how much a session asks

PERM is how much a session asks before it acts: ask, edits (writes files unasked), plan (cannot act at all), or a red BYPASS for one started with --dangerously-skip-permissions. Read from the transcript, and kept current by the session’s own hooks when it has them. ─ means the harness does not record it — today Claude Code and Devin do. Devin’s is its --permission-mode, read out of the session row in its database, and its smart mode reports nothing rather than picking the nearest bucket.

ERR% and compaction cadence — sessions that are not getting anywhere

A session can be busy and expensive without getting anywhere, and the columns that measure how hard it is working all read higher when that happens. Two that read the other way:

ERR% is the share of a session’s tool calls the transcript reported as failed. A few is ordinary — a grep that found nothing, a build that caught a mistake. A quarter of them is an agent retrying something that will not work, paying full price for each attempt. Sort by it with F6 to put those at the top; the Info panel gives the two numbers behind the rate, and Tool Activity marks the individual calls with ✗.

It reads ─ where the harness records no per-call outcome — Cursor, Pi and Windsurf — rather than 0%, which would claim a clean run cctop cannot see. Claude, Codex, Devin, Gemini and OpenCode all report one. Devin’s comes from its database rather than its transcript: a step records that a call was made, and the call’s own row records how it ended.

Compaction cadence is under the Context panel’s chart. The sawtooth in that chart is already the shape of a session living on compactions, but three of them over two days is a long conversation while three in twenty minutes is a session that will spend the rest of the day rebuilding a window it keeps refilling — and the chart draws those identically. From three compactions on, the panel says how often: ↺ one compaction every 15m. Claude Code only, since no other transcript records that a compaction happened.

! — when two agents are in one repository

Two agents editing one checkout is not a merge conflict. Git would at least announce that. It is one of them writing a file the other is still holding in context, and the loser finds out when the work is already gone. cctop is the only thing on the machine that can see both of them, so it is the only thing that can say so while it still helps.

The ! column is that warning:

⚠another running agent has written a file this session also wrote
·another running agent is in the same repository, and has not touched your files
(blank)nobody else is here

Sort by it with F6, and the Info panel names the peer and lists the files. The footer carries the ⚠ case only — agents share repositories all day and nothing has gone wrong yet, whereas two of them writing one file means an edit has already been lost or is about to be.

Some files are exempt, on one rule: nothing exempt is work that can be silently lost. Being plain text is not the test — source code is plain text, and losing a line of it is why the warning exists.

prose.md .markdown .rst .adoc .org .txt
machine output.lock .sum .log, plus package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml

Prose is what two agents are supposed to be writing at once — a changelog, a notes file, a memory index — edited by section rather than rewritten whole. A ⚠ that fires every time both of them append to MEMORY.md is one that gets read past, and it takes the src/ui.rs case with it.

Machine output is nobody’s handwriting. Two agents in one checkout both running cargo add or npm install write a lock file every time, and the fix for a bad one is to regenerate it; a real disagreement about a dependency surfaces in git, which announces it properly. npm and pnpm spell their lock files .json and .yaml, so those two are named outright — the extensions themselves stay contested, because two agents editing one package.json is exactly the case the warning is for.

The exemption is per file, not per session: share a notes file and a source file and the source file is still reported.

The unit of comparison is the repository root, not the working directory. A linked worktree carries its own .git, so two agents in two worktrees of one repository are editing two sets of files on disk and are not reported; two agents started from different subdirectories of one checkout are. Comparing directories gets both of those backwards, and the second is the arrangement git worktree exists to provide.

The repository is the unit for the · case — the neighbourhood. A shared file is stronger evidence and is not limited by it: two sessions holding one path on disk are racing for it wherever they were launched from. An agent working a parent checkout can write into a nested checkout another agent owns, and an edit landing outside the repository has no ground to compare at all.

Four limits worth knowing. Two agents rewriting one README whole can lose an edit, and the prose exemption above will not say so. Only running sessions are compared — a session that has stopped may well have left uncommitted work behind, but nothing it does from here can race anyone. Only the last 32 files each session wrote are watched, so a path it finished with an hour and forty edits ago is not treated as contested. And a Codex apply_patch covering several files summarises as first.rs (+3 more) in the transcript, so only the first of them is recovered.

Agents can ask this themselves through check_conflicts — see Letting agents see each other.

Telling the second agent

The ! column warns you, and by the time you look the second agent has usually made its edit. With the hooks installed, cctop can tell that agent instead, at the moment it reaches for the file. It is off by default; turn it on on the settings tab (,) or in config.toml:

[settings]
warn_agents = true

From then on every file write a hook sees is noted in a small ledger beside the hook sockets — which file, which session, when, and the agent process that made it. When a session is about to write a file that a different agent, still running, wrote in the last 30 minutes, its hook answers with a note the model reads:

cctop: another agent that is still running on this machine wrote this file recently.
- /home/you/proj/src/ui.rs — 3m12s ago, by Claude Code session 1a2b3c4d working in /home/you/proj
Two agents editing one file do not merge: whichever writes last silently replaces
the other's work. Re-read the file before changing it again, and settle who finishes
first — or move one of you into a separate git worktree.
harnesstold onthrough
Claude CodePreToolUse of Write, Edit, MultiEdit, NotebookEditadditionalContext, which reaches the model with the tool result
Gemini CLIAfterTool of write_file, replaceadditionalContext — BeforeTool has no way to add context
Codexnot toldits apply_patch writes are recorded, so the others hear about them

It is advice and nothing more: the answer carries no permission decision, so it cannot block, prompt or deny, and the hook still exits 0 inside its 250ms deadline. Anything that goes wrong on the way — no ledger yet, a mangled one, another hook holding its lock — is the same silence as the setting being off.

It needs no cctop running: the hooks keep the ledger between themselves. It makes the same exemption as the ! column (prose and lock files are never mentioned), a session is never warned about its own writes, and a writer whose process has exited no longer counts. Codex is recorded but never answered, and Cursor neither, because neither documents what it does with a hook’s JSON on a tool call — a guess there is how a monitor becomes an error on every edit.

Finding a session

/ filters the table as you type, on everything a row is: its label or title, the full working directory (not just the abbreviation the column has room for), the git branch, the model, the harness, the provider and the session id. The cell that matched is underlined, so it is clear why a row survived the filter. n and N step through matches, Esc clears, and ↑/↓ inside the prompt bring back a search you ran before — the last twenty are remembered across runs.

user:<name> narrows to one person’s sessions when cctop is watching every user, without the name also matching every title that happens to contain it. Part of a name is enough, the rest of the query still applies (user:ana flaky test), and two user: terms show either user’s rows.

Tab widens the search to the transcripts themselves, which is how you find the session where something was actually discussed rather than one whose name happens to mention it. Transcript matches are added to the metadata matches rather than replacing them, and the line each one was found on is shown under the prompt.

A row found by a transcript match opens at the match: i on it puts the term on screen with the reader’s own highlighting, a third of the way down rather than at the top of a conversation it may be thousands of lines below. The footer says “term” 3 of 12 · n N, so n and N walk the rest. If the reader cannot see the term — the table matches a word with separators folded away, or corrects a typo, and the reader matches what is drawn — the footer says so rather than moving you anywhere.

This reads every transcript on disk, so it is opt-in, it waits for a pause in typing and for a query of at least three characters, and it runs on cctop’s background thread pool — the table stays live throughout, and the footer says +transcripts… while a scan is out. Results are remembered per query, so refining a search re-reads only what it must: a session cleared for refacto cannot hold refactor, so each letter of a query narrows what there is left to read. Two limits are worth knowing: transcripts store their text as JSON, so a phrase containing a quote or a newline is escaped on disk and will not match; and a single transcript file is scanned up to 64 MiB.

The tree view

T groups the table the way htop’s tree mode groups processes: each repository gets a heading, each checkout of it a heading beneath that, and the sessions hang off the checkout they were started in. When the rows shown belong to more than one user — root watching every user — each user gets a heading above their repositories, so two people in the same repository are two groups that fold separately.

   LAST       $  BRANCH  PROJECT
●    4s  $18.40          ▾ ~/cctop  4 sessions, 1 waiting, 3 running
●    4s  $11.02  main    ├─ ▾ cctop (main)  2 sessions, 1 waiting, 1 running
●    4s   $9.10  main    │  ├─ Improve super cctop
○    3h   $1.92  main    │  └─ Release 0.17.8
●   12s   $7.38  tree    └─ ▾ .claude/worktrees/agent-a9c7  2 sessions, 2 running
●   12s   $5.01  tree       ├─ Tree view for the table
●    1m   $2.37  tree       └─ Search tiers

The glyphs are drawn in the PROJECT column, so every other column stays under its header and sorting by one still works.

A repository is recognised by its git common directory, which every worktree of it shares — so a linked worktree lands under the repository it was taken from rather than as a stranger. A repository seen through only one checkout skips the second level. A directory outside any repository is a group of its own, and a row from another machine is grouped by host and directory, since the far filesystem is not there to ask.

F starts new parallel work from any of those rows. It asks for a branch name, runs git worktree add at <repo>/.claude/worktrees/<branch> — where Claude Code’s own --worktree puts them — and opens the launcher in the new checkout, so the next agent you pick starts there and shows up under the repository’s heading. A new branch forks from the checkout under the cursor; a branch that already exists is checked out instead. .claude/worktrees/ gets a .gitignore of its own, so the checkouts never appear as untracked files in the repository they came from.

A heading carries what adds up: how many sessions are under it, how many of those are waiting on you and how many are running, their total cost, the latest activity, and for a checkout the branch it has out. Its dot is the loudest state beneath it, so a folded heading still shows red when something inside is asking a question.

Enter, Space or e on a heading folds and unfolds it. They do nothing else there — a heading has no menu and cannot be marked — while ←/→ stay on the bottom panels on every row, heading or not. Subagents still expand under their session with e, one level further in. Sessions are never nested under each other: no harness records that one session started another.

Filtering shows the sessions that match with the headings above them, and a heading’s totals count only what is shown. The sort applies inside each group, and groups are ordered by the same column applied to the group. Where the column adds up — $, $/1H, $/24H, TOKENS, TOK/m, TOOLS, CPU%, MEM — that is the group’s total, so a cost sort puts the repository that has cost the most first, three $4 agents ahead of one $9 one. Where it does not — age, context, a model name — it is the group’s best-placed session. Either way a group is never split. b unfolds whatever is hiding the session that rang. The view and the folds are remembered across runs.

Idle sessions, and the memory they hold

An agent left open keeps its whole process tree: a claude process is a few hundred megabytes, and an MCP server it started is often another hundred. On a shared machine that is where the RAM goes — twenty sessions a few days old is ten gigabytes that nobody is typing into.

I narrows the table to those: sessions that still have a process but have written nothing for idle_after hours (6 unless , says otherwise). They are sorted by the memory of their process tree, the root and every child — the figure the Processes panel adds up — and the MEM column stays on screen however narrow the terminal. LAST is how long each has been quiet. The table’s title says what a stop would give back:

╭ Sessions (14/340) — idle ≥6h: 12 sessions, 5.4G reclaimable ──────╮

K in this view stops them. With rows marked it stops the marked ones; with none marked it stops every one the view shows, since the view is already the selection. A confirmation lists each with its idle time and memory, and the total. Some are left running, and are named with the reason:

  • working — its own hooks say a turn is under way;
  • asking a question — a permission prompt or an elicitation is waiting on you;
  • busy (N% CPU) — nothing in the transcript, but the process tree is using at least 5% of a core, which is what a long build or a background shell looks like from outside;
  • on host — a row from another machine, which cctop reads and does not signal.

Stopping is SIGTERM to the agent, the same as Ctrl+K, never SIGKILL: the agent writes its transcript out and takes its children with it, so R resumes any of them later. What cctop cannot see is a prompt typed into one and not sent, which goes with it — the confirmation says so.

I again, or Esc, puts the table back the way it was sorted. When a stop would give back more than a gigabyte the overview says so beside the agents’ total, as Agent mem 10035 MB · 5.4G idle (I), so the view does not have to be remembered to be found.

The row menu

Every action below has a key, and the keys are worth learning. But you have to know they exist first, and a key that quietly declines — because the row is on another machine, or is a subagent, or has no process left to signal — teaches nothing about why.

Enter on a row opens everything you can do to it:

╭ Improve super cctop ─────────────────────────────────────╮
│ Resume in a tab                                        R │
│ Restart it in its tab    it is not running in a tab here │
│ Attach to it                                           a │
│ Type into it               no local process to type into │
│ Hand off to another agent                              O │
│ Show its subagents                                     e │
│ Mark for a batch action                            space │
│ ──────────────────────────────────────────────────────── │
│ Terminate the agent                    it is not running │
│ Delete the transcript                                  d │
╰ ↑↓ Enter · Esc ──────────────────────────────────────────╯

Entries that cannot run stay on the list, greyed, with the reason where their key would be — one or the other, never both, since pressing the key of a refused entry only repeats the refusal. They are not hidden: a menu that changed shape from row to row would teach you less than one that says why.

The cursor never lands on a refusal, so Enter always does something. The letters stay live inside the menu, so Enter R and a plain R are the same two keystrokes — the menu shows the shortcuts rather than replacing them. Clicking works too. Esc, or a click outside, closes it.

The settings tab

Everything cctop can be told to do differently is on one tab, at the right end of the workspace bar. , goes there, clicking Settings goes there, and Alt+←/Alt+→ step onto it like any other tab. It is not a popup: it is a tab, so it holds the whole frame below the bar rather than a small box over the table, and the bar stays clickable while you are on it.

It is in three parts, because the values live in three places:

PartLives inWhat is on it
config.toml · [settings]the file you wrotethe theme, the alerts, how long a session counts as idle, hidden columns
Remembered · viewui-prefs.jsonthe tree, the filters, the cost floor, which panel opens, the account a new tab launches under
config.toml · [keys]the file you wroteevery dashboard keybind

/ filters the page, matched against a row’s name, what it does and its current value — so alert, quit and 83.5 all find something. Enter changes the row under the cursor: a toggle flips, a choice turns to the next one, a keybind waits for the key you press next, and a number opens a field. ⌫ puts it back to its default, and e opens config.toml in your editor.

The middle part is the one that is not in config.toml. Those are choices cctop remembers for itself in ui-prefs.json, and they are on the page because a setting you cannot see is a setting you cannot find: until now the only way to reach them was to remember which key pressed which one.

Every key

KeyAction
EnterEverything you can do to this row, in one menu (see above)
↑, ↓/jMove between sessions
PgUp, PgDnPage up / down
Ctrl+U, Ctrl+DHalf a page up / down
g, GJump to first / last
Home, EndJump to first / last
n, NNext / previous search match (wraps)
wToggle notifications (see below)
WShare the agent’s terminal to a browser (an agent in a cctop tab, see Driving agents)
bJump to the session that rang last
←, →, TabMove between bottom panels, Shift+Tab the other way
1–9Jump to a panel directly; 9 is Preview, the selected row’s tab live (see The bottom panels)
Shift+↑/↓Scroll inside the active panel
Shift+Home/EndJump to the top / bottom of that panel
fFollow mode: keep the selection centered
/ or F3Filter sessions by text (see below)
S, F6, >, <Sort-by panel
F7Filter by age (1d / 1w / 1mo)
#Cost floor: only sessions costing ≥ $X
,Settings and keybinds (see below)
`Show only running sessions
IIdle view: live sessions quiet for idle_after hours, most memory first (see above)
[, ]Move through the Tool Activity tool filter
vToggle inline diffs for edits
LToggle the Tool Activity live filter
TTree view: group by repository and worktree (see above)
FFork a git worktree and launch an agent in it (see above)
+Add a Claude account, by running its login or setup-token in a tab
SpaceMark / unmark the selected session
D, KDelete / terminate all marked sessions (with confirmation); in the idle view K stops the idle ones
UClear all marks
h or F8Agent integration: what reports to cctop, and install it
!Provider status: whether a vendor outage explains the failing sessions (see above)
iRead the conversation, full-screen (see Panels)
yCopy resume command or transcript path
e, EShow its subagents / show all subagents; on a tree heading, e folds and unfolds instead
dDelete the selected session (not running)
Ctrl+KTerminate the selected live session (with confirmation)
sType a line into the selected session’s terminal (see below)
RResume the selected session in a tab of its own (see below)
Ctrl+RRestart every agent tab on the same session, skipping any mid-turn
OHand the selected session’s context off to a different agent (see below)
aOpen that session’s terminal in a tab and drive it
AOpen the agent this cctop launched itself, rather than one it found on disk
tNew tab: run an agent, a shell, or Claude on an ssh host (see below)
r or F5Refresh now
o, cWhat the spend was and not got back (optimize), and how each model did (compare) — see what it cost you for
YYour year with the agents, a full-screen card at a time: hours, streaks, the clock you keep, top models and projects, spend at list price. ← → step, [ ] change year. Hours, days and projects reach back through Claude Code’s prompt history (~/.claude/history.jsonl), which is kept; spend, models and files only as far as the transcripts, which Claude Code deletes after cleanupPeriodDays (30 by default)
BServe this table to a browser while cctop keeps running — see In a browser
EscClear the active filter
? or F1Help: every key on one page, as [keys] has bound them, with the version and commit on its border; / in it narrows the page (see below)
q or F10Quit

Tabs and splits, from anywhere including inside a running agent:

KeyAction
t or Alt+nNew tab: an agent, a shell, an ssh host, or one still running
Alt+v / Alt+sSplit the current tab right / down
Alt+← / Alt+→Previous / next tab
Alt+1–9Jump to a tab; Alt+1 is the dashboard
Alt+tPick a tab from a list, typing to narrow it and Tab to pick by state (see below)
Alt+bJump to the next tab whose agent needs you, then to one whose turn ended unseen (✓)
Alt+rRename or recolour the tab you are on
Alt+oMove focus to the next pane
Alt+zZoom the focused pane to fill the tab, or put the split back (see below)
Alt+wClose the focused pane and stop its agent
Alt+Shift+WThe same thing, by a name that says so
Alt+Shift+RRestart the pane’s agent on the same session, after an update; on the dashboard, the selected row’s tab
Alt+Shift+CRecord the focused pane to an asciinema .cast; again to stop (see Recording a pane)
F9Paste the clipboard’s image (see below)
F12Back to the dashboard, leaving everything running

Alt+z zooms the focused pane over the whole tab, and the bar marks the tab ⤢ while it is. The other panes keep running out of sight — their output is still read, so none of them stalls — but they are not resized: each agent goes on at the size it had in the split, so zooming costs the hidden ones no redraw. Only the zoomed agent is resized, once each way. Alt+o while zoomed moves the zoom to the next pane; splitting again unzooms, so a new agent is never started out of sight. An agent another cctop is also showing is drawn at the smaller of the two sizes, as always, so zooming cannot grow it past that.

Every function key is cctop’s, inside a pane as much as on the dashboard: none of them is passed to the agent. F10 (quit) and F5 (refresh) act where you press them; F9 pastes into the pane you are in; F12 returns to the dashboard; F1, F3, F6, F7, and F8 bring the dashboard forward and then do what they do there, since a search box or a sort order over a pane would be drawn on a screen the agent is repainting. An unbound function key does nothing rather than reaching the agent as an escape sequence.

The Alt+t picker narrows two ways at once. Whatever is typed matches tab names — every printable key is the name’s, j and k included — and Tab cycles which tabs are listed by what their agents are doing: all of them, the ones that need you, the ones working, the ones idle; Shift+Tab goes back. The state in force is in the picker’s title (Go to tab · needs you), and it opens on all of them every time. The tab you are on is filed by what it is doing too, though the bar leaves it uncoloured, because with the picker over it you are not looking at it. The dashboard is only listed under all.

In the help, / starts a filter: the page narrows as you type to the entries that mention it, under their headings, with the query on the top border. It is a plain case-insensitive match that takes in the key column, so ctrl+u finds keys and half a page finds what they do; a heading that matches keeps its whole section. Enter stops typing and keeps the filter, so the arrows and j/k scroll the narrowed page, and / again goes back to editing it. Esc takes the filter off first and the page away second.

Typing in a box

Every box that takes text — the / filter, the cost floor, the s send box, the tab name, the launcher’s directory, the add-account name, a setting being changed, the Alt+t picker and the help filter — edits the way a shell prompt does, with readline’s keys:

KeyDoes
← / →, Ctrl+B / Ctrl+FMove a character
Ctrl+← / Ctrl+→, Alt+B / Alt+FMove a word
Home / End, Ctrl+A / Ctrl+ETo the start / end
Backspace or Ctrl+H, Delete or Ctrl+DDelete a character before / after the cursor
Ctrl+W, Alt+BackspaceCut the word before the cursor
Alt+D, Ctrl+DeleteCut the word after it
Ctrl+U / Ctrl+KCut to the start / to the end
Ctrl+YPut the last cut back at the cursor

A word is letters, digits and _, so in a path each component is one. Cuts in a row add up — Ctrl+W twice and Ctrl+Y gives both words back — and they go to the box’s own slot, not the system clipboard, so cutting never loses what you copied to paste. A paste goes in at the cursor, with its line breaks flattened to spaces. The cursor steps over an accented letter or an emoji as one thing, and a value wider than its box scrolls to keep the cursor in view, with … where it runs off.

These keys are the box’s only while one is open: on the table Ctrl+U and Ctrl+D still page, and Alt+B still jumps to the tab that needs you. Each box keeps the keys it already had — ↑/↓ for search history, Tab to complete a directory, the plain arrows to pick a colour in the rename box (its name moves by Ctrl+B/Ctrl+F, Home/End and Ctrl+←/→ instead), digits only in the cost floor.

Mouse works too: click session rows, column headers, and panel tabs; scroll anywhere. In Tool Activity, click any row to expand the full untruncated argument, and click the sidebar to filter by tool.

Anything that scrolls shows a scrollbar on its right border while it has more than fits, and only then. Click or drag along the table’s bar to jump the selection to that point of the list, top to first row and bottom to last; the bottom panel’s bar does the same for the panel’s text.

Settings and keybinds

, opens every setting and every key on the session table at the value it has now, a * beside the ones you have changed. Enter changes the row under the cursor — a toggle flips, a choice such as the theme turns to the next one (← and → turn it either way), and a key waits for you to press the combination it should move to — and Backspace puts it back. A key another action was on is still taken; the status line says which action lost it. e opens the file itself in $VISUAL or $EDITOR, and an edit saved there applies at the next keypress.

It all lives in config.toml under your config directory, beside any account tokens, and only what you changed is written:

[settings]
theme = "light"          # auto / light / dark / mono
graph_glyphs = "octant"  # braille / octant / sextant: what the charts are drawn in
tab_bar = "left"         # top / bottom / left / right: where the tab bar sits
notify = true
compact_threshold = 90   # context % the agent compacts at
idle_after = 12          # hours quiet before `I` counts a live session idle

[keys]
quit = "x"
bottom = "shift+down"    # ctrl+, alt+ and shift+ all work

Octants (Unicode 16) and sextants draw the charts as solid blocks rather than braille dots, but only in a font that has them. The graph_glyphs row draws a sample in each set, so a font without them shows its boxes there, before you choose.

tab_bar moves the tab bar. bottom puts it on the row above the key hints; left and right make it a column, one tab to a row, as wide as the longest label within 16 to 28 columns. A column with more tabs than rows keeps the tab you are on and counts the rest as +N, and a window under 60 columns wide draws it on top, so the agents keep their width.

Only the session table’s keys move. A modal’s keys are the letters on its own buttons, and inside a pane the keyboard is the agent’s.

The help page follows what you bind: a moved key is shown where it now is, and an action whose key you gave to something else, without giving it another, reads unbound rather than naming a key that now does something different. A line the file has but cctop could not use — listed as a problem at the top of the , panel — moved nothing, and the help says so by not moving either.

A paste that arrives twice

Some layer between a terminal and an agent can deliver one paste twice when the machine is loaded, so a sentence reaches the agent doubled. cctop drops a paste that is byte for byte the last one and starts within paste_debounce_ms (250 by default) of the last having been handed on. That holds in a tab, under cctop run and under cctop attach. A key pressed in between means you meant the second, so it is kept; a click is not a key, so the one that comes with a right-click paste does not count. paste_debounce_ms = 0 turns it off.

Each dropped paste is a duplicate-dropped line under CCTOP_LOG, with its size and the gap. An identical paste kept because it came after the window is logged as duplicate-kept: many of those say the window is too short for your machine. Only a bracketed paste is caught. A copy that arrives as plain keystrokes goes through.

Typing on a slow remote tab

A tab on a sibling machine goes through Cloudflare, and each key waits a round trip — around 120 ms — before its letter appears. So cctop draws the letter first, underlined, and lets the far side’s echo replace it, as mosh does. A guess the far side contradicts, or does not answer in time, is painted back.

It guesses only plain letters typed one at a time at a visible cursor, or at Claude Code’s own inverse-cell cursor, and only at the end of the text: typing into the middle of a line is left to the far side. Enter, Tab, Esc, arrows, control keys, pastes, and / or @ on an empty prompt stop the guessing until the far side has caught up, and after each of them nothing is drawn until one guess has come back right. An app that switches on the kitty keyboard protocol is not guessed for at all.

predictive_echo sets it: auto (the default) draws only once the round trip is over 60 ms, and stops below 30; always draws whatever it is; off never guesses. Nothing extra is sent to the other machine either way.

Pasting an image

A terminal cannot carry one. A bracketed paste is text, so a screenshot copied with the system’s own shortcut reaches an agent as nothing at all — which is why Ctrl+V in a pane appears to do nothing when the clipboard holds a picture.

F9 is the way in. It reads the image off the clipboard, writes it to ~/.cache/cctop/pastes/paste-<when>.png, and types the path at the agent, followed by a space — the form every one of these harnesses already reads an image in. No image bytes go near the pty. On the dashboard the same key opens the type-into box with the path in it, so a session you are not attached to can be sent one too. For a few seconds after either, the image itself sits in the bottom-right corner — the confirmation is the picture, not only its name.

Inside a pane Ctrl+V does it too, but only when the clipboard actually holds a picture: with text on it the key goes to the agent untouched, as it always did. Whether it arrives at all is the terminal’s decision — Windows Terminal binds Ctrl+V to its own paste and never tells the application, so there F9 is the way in, or unbind it in the terminal’s settings.

The right button is deliberately not a third way. It was for a day: in Windows Terminal it copies when there is a selection and pastes only when there is none, so selecting some output and right-clicking to copy it pasted an image into the agent instead. cctop cannot see the terminal’s selection, so it cannot tell the two apart.

It needs something that can read the clipboard: wl-clipboard or xclip on a Linux desktop, and powershell.exe under WSL, where the clipboard being read is Windows’. Under WSL it is also looked for at /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe, because a session nobody logged into by hand — an ssh into the distribution, a cron job — has none of the Windows interop entries on its PATH, and the same machine would otherwise report having no way to read its own clipboard. cctop says which of the two things went wrong: an empty clipboard and a machine with no such tool need different answers.

Pasting an image into a cctop you sshed to

No helper on the far side can reach it — the clipboard is on the machine you typed the ssh on. So F9 goes the other way and asks the terminal that is showing you cctop, over the escapes the connection already carries: kitty’s clipboard protocol can hand back image/png itself, and any terminal that answers the classic OSC 52 read gives up its clipboard as text, which files the same when the text is a base64 image. Most terminals refuse the read — the answer to “what is on the clipboard” is something no program should get unasked — so kitty wants read-clipboard in clipboard_control before it will say, and where the terminal stays silent F9 still says so rather than telling you to install xclip.

For a terminal that will never answer — Windows Terminal does not, and no Ctrl+V or right-click there can put an image on the wire — the way in is the connection itself. ssh carries sockets back down the link it opened, so a small listener on the machine you ssh from can hand the clipboard over. tools/clipboard-bridge.ps1 is that listener, on Windows:

powershell -ExecutionPolicy Bypass -File clipboard-bridge.ps1

with the port forwarded — ssh -R 8377:127.0.0.1:8377 <host> on the command line, or RemoteForward 8377 127.0.0.1:8377 in ~/.ssh/config to make it permanent. cctop asks by connecting; the bridge answers with the clipboard’s image, or with a closed connection when there is none. F9 uses it, and so does Ctrl+V in a pane — the ask is a localhost connect that costs nothing where no bridge is listening — so from Windows Terminal an image pastes the way text does. The port is CCTOP_CLIPBOARD_PORT on the far side and -Port on the bridge when 8377 is already taken.

The way that needs nothing of the terminal or a script is the browser. cctop serve on that machine puts the table on an HTTP port; reach it from your own machine — ssh -L 7788:127.0.0.1:7788 <host>, or --tunnel — and paste the screenshot straight into the box that answers a waiting session. A browser can take a real image off the clipboard where a terminal cannot, so the bytes travel over the connection cctop already has, land in pastes/ on the far machine, and the box fills with the path. Then write the sentence around it and send.

The other way needs no browser. What crosses a terminal is text, so send the image as text: cctop reads any paste that is an image in base64 — bare, or as a data:image/…;base64,… URI — writes it to the same pastes/ directory, and gives the agent the path. The decoded bytes are sniffed for a real format — PNG, JPEG, GIF, WebP, BMP — so an ordinary paste is never mistaken for one.

Encoding it is a one-liner where your clipboard is. In PowerShell, which is where you would be if you sshed from Windows:

Add-Type -AssemblyName System.Windows.Forms,System.Drawing
$i=[Windows.Forms.Clipboard]::GetImage(); $m=New-Object IO.MemoryStream
$i.Save($m,[Drawing.Imaging.ImageFormat]::Png)
Set-Clipboard ([Convert]::ToBase64String($m.ToArray()))

That replaces the clipboard’s image with its own base64, which then pastes into the agent as an image. On a Mac or a Linux desktop the same thing is pngpaste - | base64 | pbcopy and wl-paste -t image/png | base64 -w0 | wl-copy.

The images are kept, not cleaned up: the path is in a transcript by then, and a conversation resumed next week may read it again. The directory is yours to empty.

Right-click a row for its menu, the same menu Enter opens. The footer’s key hints are buttons — clicking ? Help opens the help, clicking R Resume resumes — and so are the [y] and [n / Esc] in every confirmation, where a click beside the dialog is also a cancel. q Quit is the one that takes two clicks: it is the only key on the footer with nothing behind it to ask again, and the first click says so in the status line.

More than one account

Claude Code and Codex both let one machine hold several logins — $CLAUDE_CONFIG_DIR for the first, $CODEX_HOME for the second — and a second subscription usually exists because the first one runs out of window before the day does.

cctop finds them the same way for both: the directories beside the conventional one that are actually signed in, so ~/.claude-work and ~/.codex-work are both an account called work. Where there is more than one, the new-tab launcher carries an as <name> line and p cycles it — the choice is remembered, because somebody working out of their work login is working out of it all afternoon.

Everything downstream follows the account rather than assuming the default: rows from every login appear in the table, the PROFILE column says which one each came from, the limits panel reports each subscription’s own 5h and weekly figures, and R resumes a session under the account whose directory the transcript lives in — without which a resumed Codex session would come back blank, its id being unknown to any other home. A tab launched or resumed under one names it on the bar — claude (work) — so two agents on two subscriptions are not the same label twice; the default account is the one left unqualified, as it is everywhere else.

An account whose limits cctop cannot read from its directory can be given a token instead. claude setup-token prints a long-lived one, and

cctop --add-account work   # reads the token from stdin, so it stays out of history

keeps it in config.toml under your config directory, readable only by you:

[accounts.work]
token = "sk-ant-oat01-…"

The name is the account’s. Where a ~/.claude-work exists it is that profile’s token, winning over the credentials in the directory — being explicit is the point of typing it in. Where no such directory exists, the token is the account: it gets its own column in the limits panel, and its sessions stay in the one ~/.claude with everything else, sharing the history, the settings and the project trust rather than splitting them across a second directory. Start one with CLAUDE_CODE_OAUTH_TOKEN set and that is the subscription it spends.

A setup-token token is made for model requests, and the usage endpoint may refuse it. When it does, the panel says no limits for a token rather than calling the account expired, and asks again only every few hours: the account works, cctop just cannot measure it. A login into a ~/.claude-<name> of its own is the way to see that account’s limits.

+ in the TUI is the same thing without leaving the dashboard: it asks for the name, then offers claude auth login into a ~/.claude-<name> of its own or claude setup-token, and runs whichever you pick in a tab of its own so you can watch it and see the link when it prints.

Such an account is not offered in the launcher’s profile picker, and cctop’s R never resumes under it: both work by putting the account in front of the command, and the only thing there is to put there is the token itself — which would be in every ps on the machine. Set the variable in the shell you launch from instead. CLAUDE_CODE_OAUTH_TOKEN wins over both, for the default profile only: Claude Code prefers that variable too, so a session started with it spends the account the variable names rather than the one the directory holds. One variable is one value per machine, which is why it answers for one profile rather than being reported as every account’s usage.

In the tab bar, drag a tab to move it along the bar — the arrangement is written onto the rmux sessions, so it is still there after F10 and in every other cctop on the machine — and right-click one, or press Alt+r on it, to name it or paint it: 3:claude-4 says nothing about what that agent is doing, and the name and colour you give it follow the tab into every cctop on the machine. ←/→ walk the colours in the same prompt; the leading ○ stop is no colour at all. A painted tab wears its colour in the bar and on the title of every pane border it owns.

The bottom panels

← back to the README

The panel under the table describes whichever session the cursor is on. ← and → move between panels, 1–9 jump to one, Tab cycles through them, and Shift+↑/↓ scrolls inside the active one.

Three of them repay a closer look, and so does the conversation view i opens.

Tool Activity

Each invocation shows the time, its arguments, and — where the transcript supports it — what it did:

19:16 main    ~/cctop/src/ui/render.rs     +43 -24   122ms ↓498.5K ↑ 1.2K
19:34 ↳aa1b82 ~/cctop/src/quota.rs          +2 -0    88ms ↓ 41.2K ↑  310
19:41✗main    cargo test --all-targets               1.4s  ↓ 12.0K ↑  180
  • origin — main for the session itself, or ↳<agent-id> for a subagent. Subagent activity is interleaved into the same log, so without this there’s no way to tell an agent’s edits from the parent’s.
  • ✗ and a red row — the call reported an error. Claude records this per call, OpenCode and Gemini record a tool status, Devin’s comes from its database rather than its transcript, and Codex is read from the sandbox’s own result line and exit code. Cursor, Pi and Windsurf record no tool outcome at all, so their calls are never marked.
  • +N -M — lines added and removed, from the edit result’s patch. Press v to expand the diff inline beneath the row.
  • duration — wall time from the call being issued to its result arriving.
  • ↓ / ↑ — tokens in and out for the assistant turn that issued the call. Claude only; Codex transcripts don’t tie token counts to individual calls.

That last one deserves a caveat: billing is per API request, not per tool call. When one turn issues several calls they all show that turn’s figures, marked with a leading *. Dividing the total between them would invent precision the transcript doesn’t contain. ↓ includes cache reads, which is why it tracks total context size rather than the size of any one call.

Codex tools are decoded too: apply_patch shows the files it touched and its line counts, update_plan shows progress and the step in flight, and write_stdin distinguishes a real write from a poll for more output.

Reading the conversation

The panels summarise what a session did; i shows what it said — or Enter and Read the conversation. It opens the selected row’s transcript full-screen, a remote row’s too (read over the same ssh link), at the end, so the agent’s last reply is the first thing on screen. It reads what cctop serve’s conversation page reads, so every harness that page shows is here, for the times you are on ssh with no browser to open.

  • Each turn is headed by who spoke and when: you in the accent colour, the agent in its harness’s, with the local time (and the date, for an older turn) and how long ago. What you typed sits behind a bar in that same colour, so a prompt is easy to find in a page of replies. Thinking is dim and italic; a compaction is a rule across the page.
  • Replies are rendered as markdown: headings, bold and italic, inline code, fenced blocks on a shaded ground with their language named, lists, quotes, tables and rules. Links are OSC 8 hyperlinks, so a click opens them even when they wrap; on a terminal that cannot do that (TERM=dumb or linux) the URL is printed after its label. What you typed is shown as you typed it. Code is not syntax-highlighted.
  • A subagent’s call is one line too, naming the agent and how much it did (3 turns · 12 tools · 2m10s); opened, it shows the agent’s report. A background agent that never handed back shows its last message instead, labelled as that. a opens the agent’s own turns, and Esc comes back to the same place; opening the reader on a subagent’s row in the table goes straight to its turns.
  • Tool calls are one line each until you open them: the tool, its argument, and how many lines opening it would add. Enter opens the calls of the turn you are reading; t opens every one.
  • Tool output keeps its colours. cargo’s green, pytest’s red and git’s diff colours come through, folded to sixteen colours on a terminal that has only those and dropped under NO_COLOR. Anything else a program wrote — cursor moves, line erases, window titles — is removed, so it cannot scribble over the view. Expanded Tool Activity rows get the same treatment.

A session paged back far with u can run to thousands of turns. It is laid out once for the terminal’s width, and scrolling it lays nothing out again.

KeyIn the conversation
↑ ↓ / j kScroll a line (the wheel scrolls three)
PgUp PgDn / b SpaceScroll a page
Ctrl+U Ctrl+DHalf a page
g G / Home EndThe start / the end
[ / ]Previous / next turn, header at the top
/Search as you type; Enter keeps it, Esc goes back to where you were
n / NNext / previous match (wraps)
Enter or xOpen or fold the tool calls of the turn at the top
tOpen or fold every tool call
mToggle between rendered markdown and its source
uLoad earlier turns, when there are any
aOpen the subagent whose call is highest on screen: its own turns, titled with the agent and its session
EscClear the search; with none, back out of a subagent’s turns, or close
qClose

Context breakdown

CTX% says the window is 68% full. The Context panel says what is in it — Claude sessions only, since no other provider’s transcript reports per-request usage.

Window   181.4K of 200K

Unaccounted    ━━━━━━━━━━━━━━━━────────────────────────   62.1K  34%
Tool output    ━━━━━━━━━━━━────────────────────────────   43.5K  24%
Startup        ━━━━━━━━━───────────────────────────────   34.5K  19%
Tool input     ━━━━━━━━────────────────────────────────   29.0K  16%
Attachments    ━━━━────────────────────────────────────    9.1K   5%
Assistant text ━━──────────────────────────────────────    3.6K   2%

Two of those numbers are measured and the rest are estimated, and the panel never blurs the line:

  • Window and Startup come from the usage figures the API itself reported. Startup is the first request of the live segment — everything the harness sends before the conversation begins: the system prompt, the tool schemas, CLAUDE.md, the skills index, and, after a compaction, the summary. It cannot be split further, because the transcript never records what was sent, only that it was.
  • Tool output, Tool input, Attachments, Your messages and Assistant text are estimated from how many characters the transcript holds, at 2.75 characters per token. That constant is fitted rather than assumed: across 167 local sessions, the characters a transcript accumulates divided by the context growth the API reports over the same span lands there, well under the usual prose rule of thumb because this content is mostly code, JSON and file paths.
  • Unaccounted is the remainder, and it is deliberately a bar of its own rather than being spread across the categories that happen to be measurable. It runs around a third of the window. Most of it is thinking — Claude Code writes those blocks with the text stripped and only the signature left, so there is nothing to measure — plus the <system-reminder> text the harness splices into each turn without recording it, plus estimation error.

A compaction resets the whole thing: everything before the summary has left the window, so counting across one would describe a context that no longer exists. Subagent turns are excluded too — they run against their own windows, and only the report a subagent hands back is in the parent’s, where it lands in Tool output like any other result.

When the estimate overshoots the window there is no gap to draw, and the panel says so instead of clamping: it means the harness has dropped context that the transcript still holds.

Given the height, the bar folds into a block map, like a memory map of the window: the same cells in the same order, filling rows left to right and top to bottom, a quarter of the panel’s rows and up to six. One row of eighty cells spends 2.5K of a 200K window per cell; four rows spend 625 tokens, which is the difference between a small category showing and vanishing. The footnote says what a cell is worth. Every category in the window gets at least one cell however small its share, so nothing in the legend is missing from the map. On the map, the free space past the auto-compaction threshold is drawn as · rather than a lone ┊, because that is a region — room the harness reclaims before it is ever reached — and one marker cell is lost in a grid. A panel too short to spare the rows keeps the one-row bar.

Under the bar, How it filled charts the window across every request the session made. The bar answers “what is in there”; the chart answers “how did it get that full”, which is the part that changes what you do next. A window that climbed evenly is a conversation that grew and will keep growing. One that stepped is a handful of large tool results, and the same call will do it again. A sawtooth is a session living on compactions, paying to rebuild its context over and over. The chart spans the whole session rather than the live segment, because a compaction is the most interesting thing that can happen to a context window and it is the only view that can show one.

Preview

The last panel, 9, is the selected row’s tab, watched from the dashboard: the agent’s screen as it is right now, updating while you look, without leaving the table to see whether it has finished or is asking you something.

It is a window, not a way in. Nothing typed on the dashboard reaches the agent, and the pane is not resized to fit the panel — the agent keeps drawing at the size its tab gave it, and the panel shows as much of that as fits. When the screen is taller than the panel, the bottom of what the agent has drawn is what stays, because that is where its prompt and its questions are. The bottom border names the tab and the key that goes to it, Alt+2 onwards.

A tab you have switched away from gives up its rmux client, so there is no screen of cctop’s own to show. The panel asks rmux for the screen instead, twice a second and only while the panel is on show; that is a read, and rmux resizes nothing for it.

A row with no tab says so in one line: a opens a running session’s terminal in one, R resumes a stopped session in one, and either then shows here live. A row from another machine has no tab here to show.

Driving agents from cctop

← back to the README

cctop starts as a viewer, but it can also answer a waiting agent, reopen an old session, and hold several agents on one screen. Everything here is optional — the table works without any of it.

Typing into a session

s opens a one-line prompt (prefilled with continue) and types it into the terminal driving the selected agent, as if you had typed it there — useful for the sessions whose status dot has gone yellow or red waiting on you.

An agent reads its keyboard from a pty, and only whoever holds that pty’s master side can put bytes into it — normally the terminal emulator, which offers no way in. (Writing to /proc/<pid>/fd/0 or /dev/pts/N reaches the output side and just paints the screen; it does not reach the agent.) So cctop needs one of these to be true, and tries them in this order:

The session runs…HowRequirements
under cctop run <agent>cctop owns the pty and typing goes through a unix socketnone
in a cctop tabsend-keys into the pane of cctop’s own rmux holding the agentnone
in a plain terminalTIOCSTI pushes bytes into the tty’s input queueLinux, and cctop as root — CAP_SYS_ADMIN clears both of the kernel’s gates. Without root it also needs sysctl -w dev.tty.legacy_tiocsti=1 (off by default since 6.2) and cctop sharing the agent’s controlling terminal, which in practice it doesn’t

The first is the one worth adopting — no root, no multiplexer, and the session looks and behaves exactly like one started directly. cctop sets it up for you: on its first interactive run it aliases claude, codex, devin, opencode and pi to cctop <agent> — a marked block appended to ~/.zshrc and ~/.bashrc, and ~/.config/fish/conf.d/cctop.fish for fish (each only where that shell is already configured; the fish file is unverified, having been written on a machine without fish).

cctop --remove-alias     # take it out again; deleting the block by hand also works
cctop --install-alias    # put it back

Every alias is guarded by a command-exists test, so it defines nothing unless both cctop and the agent are installed — uninstall cctop and claude goes back to meaning claude. The block is written once: remove it and cctop won’t add it again.

The run is optional: any first argument that names an executable is launched this way, so cctop claude --dangerously-skip-permissions works and the flags reach the agent rather than cctop. A word that isn’t a command still gets cctop’s usage error, so a typo doesn’t silently try to run something.

cctop run proxies your terminal byte-for-byte (including resizes) and exits with the agent’s own exit code, so it is a transparent stand-in. Sessions started any other way still show up in cctop; they just can’t be typed into unless they are in a cctop tab or the root path applies. An agent you started inside your own rmux or tmux is yours, and cctop does not reach into it: the alias above is how it becomes typeable.

Resuming a session

R reopens the selected session in a tab of its own, running the harness’s own resume command — claude --resume <id>, codex resume <id>, devin --resume <id>, opencode --session <id>, pi --session <id> — in the directory the session was working in. This is the way into a session cctop did not start: a can only show the terminal of an agent cctop is already hosting, while resuming starts a fresh agent from the transcript and so works for any session in the table, however it was launched and however long ago it ended.

The tab is named after the session, not the command that reopened it — claude · Improve super cctop rather than claude --resume 4ebf1ab4-2ef8-4fb2-a7d5-…, whose only variable part is a uuid nobody reads.

Cursor, Gemini and Windsurf keep their conversations inside an editor and have no such command, so R says so rather than guessing at a flag; y copies their transcript path instead. Resuming a session that is still running asks first — two agents appending to one transcript is not something the harnesses coordinate.

Stopping the sessions nobody is using

The other end of resuming. An agent left open for days still holds its memory, and on a shared server those add up to gigabytes. I shows the live sessions that have been quiet for idle_after hours (6 by default), biggest first, with the total a stop would give back; K there stops them — the marked ones, or all of them — after a confirmation that names each, its idle time and its memory.

It skips any session that is working, asking a question, or still using CPU, and says which and why. The stop is SIGTERM, so each agent exits cleanly and R brings it back whenever it is wanted again. See idle sessions for the details.

Tabs and splits

The session table is tab 1. t opens another: pick an agent — whichever of claude, codex, devin, opencode and pi you have installed — or your shell, and it starts on a pty cctop owns and draws in the window.

The launcher says where the agent will start, and c makes that line editable: type or paste a path, ~ included, Enter to take it and Esc to keep the one it had. An empty field means the directory cctop itself was started in. A path that does not name a directory is refused in the field rather than at launch — by then the launcher is gone, and the failure arrives from inside the pty as a message about spawning.

You do not have to remember the path. The field lists what it can see under itself: with nothing typed, the projects cctop has seen agents run in, most recently used first; type part of a name and the list narrows by substring, so api finds ~/work/api without the ~/work. A session that ran inside a repository offers the repository rather than the directory it was sitting in, so an agent that started in ~/work/api/src lists ~/work/api — the thing you would name. Two checkouts of one repository are two answers, because they are two directories to start an agent in.

Mixed in with those, one for one, come the repositories cctop found on disk: your git pull of this morning is offered even though no agent has run in it yet, newest first, and a long history of projects cannot push every repository off the list. The scan looks three levels down from your home directory, which covers ~/project, ~/code/project and ~/src/github.com/project, skips hidden directories, and never walks into a repository. It happens on a worker, so the repositories appear a moment after the field opens rather than holding it up — on the first opening too.

The field opens holding the directory the launch was headed for, and still shows that list. Once you edit the text and it reads as a path — anything with a / in it — the list comes off the disk instead: the directories under it, hidden ones only once you type the dot. Tab fills in as far as the entries agree (one match completes outright and adds the /, so you can walk down a tree a keystroke at a time), ↑/↓ move into the list and back out into the text, and Enter on a highlighted entry takes it. Clicking works too: once to pick, again to take it. Reattaching to a running agent does not offer this: that agent is already somewhere, and a path typed for it would be ignored.

The field also takes a place on another machine. Type host:path the way scp reads it — procdb:~/proj, me@10.0.0.5:/srv/api, [::1]:/srv — or pick a host: the hosts in your ~/.ssh/config (and the files it Includes) are offered under the directories as procdb:, matched by any of their names, one line per Host line with its other names beside it; patterns such as Host * are skipped. The moment a host is in the field, cctop connects to it in the background — never asking anything, see an agent here, working there — and then completes paths on it exactly as it does here: its directories under what you typed, and, before a /, the git repositories three levels under its home. While it connects the field says connecting to procdb…; a host that would need a password or a key prompt says so (procdb: needs a password or key prompt — the tab will ask), and Enter still takes the path, unchecked, for the tab to connect to interactively. A directory the agent could not work in is listed with why, dimmed — ~/src/cctop read-only on the host — so you can still go into it. Enter asks the host whether the path is there before taking it. Any directory you can read and write on the host will do, whatever this machine has at that path: it is mounted at the same path when it can be, and under ~/.cache/cctop/remote/<host>/ when it cannot — see where the mount goes.

The launcher’s line then reads in procdb:~/proj, and the agent you pick starts there: Claude and opencode through cctop sandbox (their commands run on the host, their file tools on a mount of it), your shell over ssh -t into the directory. An agent with no way to run its commands on a host — codex, devin, pi — is greyed out with the reason (codex can't run commands on a remote host yet), and Enter on it says so instead of starting it here. The tab is called <agent> ⇄ <host>. If sshfs is not installed the tab asks to install it before connecting. A new tab starts on this machine again; a split keeps its tab’s location. Alt+v and Alt+s split the tab you are in, side by side or stacked, so claude and a shell for git diff are one keystroke apart. Fresh tabs start in the directory where you started cctop; use R to reopen a session in that session’s project directory. Agent tabs also show their subscription-window usage and time until reset when that provider reports it.

Each pane is resized to the rectangle it is given rather than cropped to it, so a split is two agents each drawing a real screen — not two crops of one. The Overview stays above the panes, so cost and alerts remain visible while you answer an agent.

a does the same for a session already running: it puts that agent’s own terminal in a tab, live — its spinner, its permission prompts, whatever it is drawing. That needs cctop to hold the pty, so it works for sessions started with cctop <agent> and no others; the aliases above make that every session you start from a shell. On attaching, the shim replays its recent output so the screen is rebuilt immediately rather than at the agent’s next repaint.

A tab you are not looking at is marked green when its agent has stopped drawing — its turn is over and the prompt is yours — and blinks amber only when it has explicitly asked a question and is blocked on the answer. Amber wins when a tab has both. The tab you are on never blinks, since its focused pane is already in front of you.

Green is most tabs most of the time, so a turn that ended while you were somewhere else says so a second way: the label turns the accent colour and carries a ✓ until you focus that pane, or select its row on the dashboard. Alt+b walks every amber tab first and then the ✓ ones, so pressing it until it says “Nothing is waiting for you” is a round of everything with news. See the status dot for what counts as looking, and why the mark is this cctop’s own rather than shared with the others.

Alt+z zooms the focused pane over the whole tab — for reading a long diff in one half of a split — and again puts the split back. The tab wears a ⤢ while it is zoomed. The panes you cannot see keep running and are not resized, so zooming costs their agents nothing; see Every key for the details.

A tab holding the login shell rather than an agent is left alone. Both colours describe a turn, and a shell has no turns — it sits at its prompt drawing nothing, which is exactly the silence the green is read from, so a tab opened for git diff used to go green two seconds after the last keystroke and claim an agent was waiting in a tab that held none. The bell still gets through: a build that finishes with a \a is the thing in the pane asking for you outright, not cctop reading its pixels.

The two are measured differently, because only one of them is in the transcript. The question is: an agent that calls an ask-the-user tool records it. “Finished its turn” is not — in a transcript, answering you and still thinking look the same. So idleness is read off the pane’s own screen instead: a working agent repaints constantly (a spinner’s elapsed counter alone ticks every second), so two seconds of a still screen means the agent is waiting on you. That needs no per-harness parsing and works for anything you open in a tab, shells included.

Recording a pane

Alt+Shift+C records the focused pane to an asciinema v2 .cast file, and pressing it again stops and says where the file went. The tab carries a red ● REC on the bar for as long as any of its panes is being recorded, so one left running is seen from every other tab. Closing the pane, or its agent exiting, stops the recording too, and says so the same way.

Play one back with asciinema play <file>, or upload it to wherever asciinema casts go. What is recorded is the terminal’s own output — the bytes the agent wrote to its pty, at the times it wrote them, before cctop’s emulator read them — so a player redraws the pane exactly rather than cctop’s rendering of it. The file opens on the screen as it stood when you pressed the key, since a recording started mid-session would otherwise begin blank and fill in only as the agent happened to repaint. The header carries the pane’s size, and a resize — the window changing, a split opening beside it — is recorded as one, so the replay reflows where the pane did.

Recordings go to ~/.local/share/cctop/casts ($XDG_DATA_HOME/cctop/casts), named after the tab and the time. Not the cache directory, which --clear-cache empties, and not the session’s own directory, which is usually a repository: a cast there turns up in git status and in the agent’s own view of the project it recorded.

In a split each pane is recorded on its own, because a cast is one terminal of one size. A tab that is being recorded keeps its rmux client when you switch away from it, rather than handing it back as tabs otherwise do — giving it up would end the recording.

Tabs outlive cctop

Every tab’s agent runs in a session of cctop’s own rmux, which is built into cctop — there is nothing to install — and what cctop hosts is only the rmux client. Quitting cctop detaches: the agent does not notice and carries on. On the way out cctop says how many it left behind.

Opening cctop again restores those tabs automatically, with their scrollback intact — and, since a tab’s shape is recorded on its sessions, a split comes back as the one tab it was: same panes, same order, divided the same way. A tab’s name and colour are stored the same way, so renaming or painting a tab (Alt+r) and dragging it along the bar survive the restart too. Closing a pane is the other thing entirely: Alt+w ends the agent, because a window you closed should stay closed rather than come back at the next launch. The launcher (t) still lists any running agents that are not already open, so you can attach to them on demand. R on a session’s row does the same thing by another route — a resumed session’s rmux session is named after it, so pressing R twice reattaches rather than starting a rival agent on one transcript.

Each of those agents is listed by what the dashboard calls it, the directory it is working in, and what it last reported through its hooks — asking, working, idle — so which one to go back to is a decision rather than a guess. A ringed dot (◉) means something is already attached to it, in another terminal or another cctop; attaching a second client works, but the two then share one window’s size.

This is the same hook feed the dashboard uses, and it reaches these agents for the same reason: cctop looks up the process inside the rmux session rather than the client in front of it. So a tab whose agent is in rmux still blinks when that agent asks a question, and still keeps quiet while it is only thinking. a on a session’s row uses that lookup in reverse and opens the agent’s own terminal, whether cctop is holding its pty or rmux is.

cctop’s rmux is its own

The daemon is the cctop binary run as cctop mux daemon, on a socket under cctop’s runtime directory (cctop mux socket prints it). It starts with the first tab and exits when its last agent does. It reads no config of yours — not ~/.rmux.conf, not ~/.tmux.conf — and starts every session with cctop’s settings: a 50,000-line scrollback, the mouse on so the wheel scrolls, and no status bar, whose clock would make an idle agent’s screen look busy forever.

Nothing else on the machine knows about it. If you use rmux yourself, rmux ls does not list cctop’s agents and your rmux kill-server does not end them, and cctop never lists, attaches to or changes a session of yours.

cctop mux ls lists its sessions and cctop mux kill-session -t NAME ends one; cctop mux attach NAME attaches your terminal to one, as rmux attach would.

Upgrading from cctop 0.31 or older. Those versions kept their agents in the rmux you installed. Agents still running there carry on, but this cctop does not see them: it only looks in its own daemon. rmux ls lists them and rmux attach -t cctop-<name> reaches one; cctop doctor says the same when it finds an rmux on your PATH. They end when you end them, and new tabs go to cctop’s own daemon.

An agent you start inside your own rmux or tmux pane by hand, not from a cctop tab, is also yours: cctop still shows it on the dashboard, but types into it only through cctop run or the root path, never through your multiplexer.

Sharing an agent to a browser

W shares the selected agent’s terminal to a browser. cctop asks its rmux daemon for a web share of the agent’s session and puts the operator link on your clipboard — open it on a phone and you are typing into that agent. The pairing code goes on cctop’s status line; the link never does, because it grants input to a live coding agent and a status line survives into a screenshot. When the share reaches off this machine, W also opens a panel with the link as a QR code and the pairing code under it, for pointing a phone at; any key closes it. The code is the link — the panel says so — and on a terminal too small to hold it the panel says that instead.

The link reaches this machine through a Cloudflare tunnel of cctop’s, handed to rmux as --tunnel-url rather than letting it raise one through a provider of its own. With your own Cloudflare account connected and a serve running in this cctop — cctop serve --tunnel, or t in the dashboard’s serve panel — it is that tunnel, on the -share hostname setup made beside the page’s: one tunnel, two hostnames, and no second one opened. The page’s hostname is never used for a share, because everything there wants the page’s token and a share link must not carry it. Stop that serve and the share links on the -share hostname stop with it; the next W mints a new one.

On that hostname the link is the address itself — https://cctop-share.example.com/#… rather than a page on share.rmux.io — because cctop serves rmux’s browser app there too, from the same pinned copy the page frames its terminals with. What answers a share hostname is a server of its own with two things on it, the app and the share’s socket, and no route of the dashboard’s at all: a share link opens a terminal and never the page, whatever token anyone adds to it.

Choosing an agent’s address. The panel W opens shows the hostname the link goes out on (never the link). On your own domain, e or a right-click on that line asks for a name: myagent puts the agent on myagent.example.com, mints a fresh link there, copies it and redraws the code. The name sticks to the agent — the next W, after a restart too, goes out on it — and two agents can be shared at once on two names. An empty field sends it back to the -share hostname. The old name’s DNS record is deleted, so links on it stop answering. The session page in the browser has the same rename on its Address row. A quick tunnel’s address is Cloudflare’s pick, and an account connected with a tunnel token cannot write DNS, so neither offers one; the panel says which.

Otherwise it is a TryCloudflare quick tunnel, the same client cctop serve --tunnel=quick uses: opened on the first W of a run and closed when cctop exits. If the tunnel cannot be registered the share still happens and the status line says this machine only — a link that works from this browser is worth more than no link, and a link that silently reaches nothing is worth less than either.

rmux ships six tunnel providers of its own and deliberately not this one: a trycloudflare.com hostname, its docs say, “can take an unpredictable amount of time to become reachable” and carries no uptime guarantee — so it points anyone who wants one at --tunnel-url, which is the door cctop comes through. Expect a link to 502 for a moment after it is minted, and reach for a named tunnel or your own ingress for anything that has to stay up. rmux also cannot tell viewers apart by source IP once traffic arrives through a tunnel, so its per-viewer caps are not a control you have here.

What the tunnel carries is not what serve puts through one. rmux encrypts the share end to end and pairs it with a PIN, so Cloudflare moves ciphertext it cannot read; the served page is ordinary HTTPS that Cloudflare terminates. Both are still a credential to a live coding agent: share the link the way you would share a shell. A share lasts six hours at most, and ends with its agent’s session — closing the agent (Alt+w) ends every share of it.

rmux mints a read-only spectator link beside the operator one. cctop does not hand it out: the two are the same shape and grant different things, and only the operator link is asked for, as its own field.

What cctop keeps, and what goes to the agent

Panes cctop started are cctop’s to end: closing one (Alt+w, or the agent exiting on its own) takes the agent with it, rmux session included. Quitting cctop is the opposite and leaves them running. A pane opened onto someone else’s session with a only stops watching.

Everything cctop does not claim goes to the focused agent, Ctrl-C included: inside a pane, that interrupts the agent rather than quitting cctop. The function keys and Alt are what cctop keeps — the function keys because they are the ones agents never want, and Alt because Ctrl is the agent’s. No function key is passed on: F10 quits and F5 refreshes where you pressed them, F12 goes back to the dashboard, and the rest bring the dashboard forward and act there. The full list is in Reading the table.

One pair of keys is translated rather than kept or passed through. Claude Code puts the top and bottom of the chat on Ctrl+Home and Ctrl+End — the right action, a chord too many for keys already named after where they go — so in one of its panes the bare Home and End are promoted on the way in. Everywhere else, a shell or another harness, they keep their line-editing meaning, and the shifted forms are never touched: Shift+Home is still “select to the start of the line”.

Shift+Enter, and the keys a terminal cannot spell

Enter is a carriage return, and Shift+Enter is the same carriage return: the difference exists only in a protocol invented to carry it. So it has to be asked for at both ends. cctop asks its own terminal for the disambiguating form on the way in, where the terminal says it can send one, and sends Shift+Enter on to the agent as CSI 13;2u — but only to an agent that turned a keyboard protocol on, or through rmux, which rewrites it to a plain Enter when the pane’s program did not. A shell in a tab is never handed a sequence it would print as text. The daemon answers the kitty negotiation itself (the fork carries Helvesec/rmux#227), so a pane that asked only for kitty, as Codex does, gets CSI 13;2u, and one that also turned on modifyOtherKeys, as Claude Code does, gets xterm’s CSI 27;2;13~.

Which agents ask, as of writing: Claude Code turns on both the kitty protocol and xterm’s modifyOtherKeys, and Codex turns on kitty and explicitly turns modifyOtherKeys off. If your terminal has no extended keyboard protocol at all, nothing changes: Ctrl+J is the newline every agent also accepts.

The questions an agent asks the terminal

An agent asks the terminal what it can do before it draws anything — Codex asks five questions, Claude Code two. Under cctop run the real terminal is behind cctop and answers for itself. A pane is different: cctop rebuilds the screen from the agent’s output, and a picture of a terminal has nothing to say back, so a hosted agent’s questions used to go into the dark and it assumed the least about them.

The shim now answers the ones with an honest fixed answer: device attributes, device status, synchronized output, and the foreground and background colour — which it reports from cctop’s own palette, so an agent set to follow the terminal’s theme follows the pane it is drawn in. The cursor position is deliberately not answered; the shim relays bytes rather than parsing them, so it does not know where the cursor is, and a made-up position would put an agent’s first frame in the wrong place.

Waiting for an agent to finish

cctop wait 3f2a                     # a session id prefix
cctop wait reviewer --until done    # a tab's name
cctop wait 48213 -j --timeout 30m   # a pid anywhere in the agent's process tree

blocks until that session stops working, then exits — so a script, or another agent, can hand work to one agent and pick up when it is done. It reads what the dashboard reads: the transcripts, the agents’ own hooks (it listens for them the way a running cctop does, so a finished turn is heard the moment it happens), and the state a cctop recorded on the agent’s rmux session.

--untilMet when
any-stop (default)it is not working — at once, if it already is not
idleits turn is over and the prompt is yours
waitingit is blocked on a question: a permission prompt, an MCP elicitation
doneit has worked since the wait began, and stopped since — the one to use after typing it a prompt, which may not have landed yet

It exits 0 when the condition is met, 1 when the session ended first without meeting it, 2 when the target names no session or more than one, and 124 on --timeout (default 10m; 0 waits forever), timeout(1)’s own code. -j prints the outcome as one line of JSON: session, pid, label, state, until, met, timed_out, waited_secs. An agent can ask the same thing over MCP with wait_for_session — see Letting agents see each other.

A Claude Code transcript cannot say that a turn is over — answering you and still thinking look the same on disk — so for Claude that answer comes from its hooks — which cctop installs itself on start, unless they were removed by hand. Without them a Claude session that went quiet before the wait started reads as working until its next event, unless the agent is in a cctop tab and a running cctop recorded its state there.

Getting pinged when a session needs you

cctop is a monitor you look away from, so w turns on the other direction: when a session that was working starts waiting for your input — or the agent exits — cctop rings the terminal bell and raises a desktop notification. The setting is off by default and remembered between runs.

Both are the terminal’s own mechanisms, so nothing has to be installed. BEL is what rmux turns into a window’s bell flag; the desktop notification is OSC 9, which iTerm2, Ghostty, kitty, WezTerm and Windows Terminal raise as a real notification and everything else quietly ignores.

It rings on the crossing, never on the state: a session that is waiting for you is still waiting on the next refresh, and ringing for that would be an alarm clock. For the same reason, turning notifications on doesn’t ring for every session that happens to be idle at that moment — cctop tracks the states whether or not the bell is on, and starts from what it already knows.

The session that rang keeps a bell marker (◉) on its row for 30 seconds, and the footer keeps naming it (Bell: ◉ cctop · waiting for input · 12s ago) until you select it. b jumps straight there. A bell out of a dozen panes is never a mystery, even if you were away when it rang.

One thing it deliberately does not ring for: an agent that has simply finished its turn and is sitting at its prompt. In the transcript that looks the same as an agent still thinking, and a timer would fire in the middle of every long reasoning turn.

Alerts on spend, error loops and stalls

The same machinery can watch a session’s numbers as well as its state. Each alert is off until you give it a threshold in [settings] (or with ,, where Enter on the row opens a field), and 0 turns it back off:

[settings]
alert_cost = 20          # a session's COST passes $20
alert_burn = 30          # a session burns more than $30 an hour
alert_today = 100        # today's spend across every session passes $100
alert_errors = 25        # 25% of its tool calls in the last 10 minutes failed…
alert_error_calls = 10   # …once there are at least this many calls in them
alert_stall = 10         # a working agent has written nothing for 10 minutes

When one fires, cctop says so in a toast, and — with w on — rings the bell and raises the desktop notification, one ring per refresh however many fired in it. With $CCTOP_NOTIFY_URL set, each is POSTed as {"event": "alert", "text": …}. The row takes a marker in place of its status dot for as long as the reading is still past the threshold: $ for cost or burn rate, ! in red for an error loop, ◌ for a stall. The day’s spend has no row, so it is the toast alone.

Each fires on the crossing, like the bell, and not on the refresh after. It can fire again only once the reading has come back below four-fifths of the threshold, so a burn rate hovering around its limit is one alert rather than one a refresh. A session already over a threshold when cctop starts — or when you lower the threshold under it — is marked but not rung for: nobody watched that crossing happen. The day’s spend starts over at local midnight, and so does its alert.

The error loop is measured over the last ten minutes, not the session’s whole life: ERR% barely moves when a session that made three hundred good calls starts failing every one, so the alert takes the difference of the two counters the transcript already records across the window. The window only holds calls this cctop saw — it starts when cctop first sees the session.

A stall needs the agent’s hooks (cctop hook --install), and cctop will not guess without them. A transcript cannot tell three silences apart: a turn that is over, a tool call still running, and an agent that has stopped getting anywhere all leave the newest record where it was, and for most harnesses a finished turn reads as work in progress. So a stall is a session whose row and hooks both say it is working, with no tool call open, and no new record for the threshold — counting a running subagent’s transcript, since a parent waiting on its subagent writes nothing of its own. A tool call that has not come back is never called a stall: a hung command and a half-hour build look identical from outside, and an alert on every long build is one people learn to ignore.

Alerts only ever tell you. cctop never stops, pauses or signals a session for crossing one — an agent over budget may be a minute from finishing the job the budget paid for.

When the agent is the one ringing

That is cctop’s own bell, rung about a session’s state. An agent can also ring for itself — Claude Code with preferredNotifChannel: terminal_bell does nothing else, and several harnesses send an OSC 9 notification — and a tab whose agent rang turns the attention colour immediately, ahead of every inference the tab bar otherwise makes. If the notification carried text (“Claude needs your permission to use Bash”), the status line shows it once, when you switch to the tab.

This matters because it is the only signal that arrives in time. Everything else the tab bar has is inferred: a hook event on a six-second timer, or a screen that stopped moving. An agent blocked on a permission prompt keeps its spinner turning and reports itself as working, so it used to be drawn as busy for as long as it sat there.

The bell survives the whole stack — the agent rings, rmux passes it to its client, the shim relays it, and the pane’s parser keeps it instead of parsing it away. cctop does not pass it on to your own terminal: the bell is answered by looking at the pane, and a beep per agent per prompt is the alarm clock this page already argues against.

Handing a session to a different agent

O takes the selected session’s context across to another harness. Where R puts the same agent back on the same transcript, a handoff carries what the session was doing over to a different agent entirely — the one thing no harness can do for itself, since each can only read its own transcripts.

cctop writes a markdown brief, opens the launcher, and starts whichever agent you pick with a line pointing it at the file. Where the harness takes an opening prompt on its command line — claude, codex, opencode — that line is part of the argv, so the agent opens on the brief. Anything else is typed at once it has had a moment to start listening.

The distinction matters more than it looks. Every one of these CLIs asks the terminal what it can do as it starts, and reads its own input looking for the answer; a line arriving in that window loses however much of itself was in the queue at the time. Handing a Claude session to Codex used to produce a prompt beginning halfway through the path, so Codex went looking for a file that had never existed and asked for the brief to be pasted instead. An argument cannot be eaten that way.

The brief holds the task, the plan the session was working to, the files it changed and read, the commands it ran, what it delegated, and what it looked up — with paths relative to the project, and every list bounded so a long session hands over its most recent and most-touched entries rather than all of them.

It is deliberately not a transcript. Replaying a conversation into a fresh window spends the context it is supposed to save, and most of what it spends it on — tool output, file contents the new agent can read itself — is what the receiving agent should gather first-hand anyway. What does not survive a restart is the intent, and that is what gets carried.

Because it is built from the normalised session data rather than from any one transcript format, it works from and to all eight harnesses.

Claude to Claude, the conversation goes whole

The brief exists because no harness can read another’s transcripts. Between two Claudes that limit is not there, so handing a Claude session to claude copies the transcript instead: the file is written into the receiving account’s projects/ directory under a fresh session id, and the new agent is started with claude --resume on the copy. It opens knowing everything the first one knew, not everything a summary could carry.

It is a copy, not a second agent on one transcript. The two diverge from the moment the fork is taken, and the session that was handed over is left exactly as it was found — still listed, still resumable, still the only writer of its own file. That is also what makes this different from R, which refuses to put a second agent on a transcript for precisely that reason.

Claude to Codex, and back

Between two harnesses that keep a file of JSON lines, the copy can be made by reading one and writing the other instead, and cctop does that: handing a Claude session to codex writes a rollout into ~/.codex/sessions/, and codex resume picks it up. The reverse works the same way.

That is the same trade as the Claude-to-Claude fork, for the same reason and with the same cost — the whole window goes across, tool output and all — but between two harnesses whose vocabularies differ, so something is left behind. The conversation crosses: what was asked, what was said, and every tool call with its result. The rest does not, because the receiving harness can work it out for itself and the sending harness’s figures are about a window the receiver is not using. That means token counts, costs, the model, and reasoning — the last because it is signed for the model that wrote it and is unreadable to any other.

The copy keeps the session’s own id where the receiving store has it free, which is what lets cctop recognise the two as one piece of work rather than two sessions that arrived in the same directory. Where the id is taken, a new one is minted instead — a handoff never overwrites a conversation — and the transcript records which session it came from either way, so cctop --converted can list the copies and --remove clear them out.

OpenCode is not in this set. It keeps its transcripts in SQLite tables beside a project’s real state, and writing rows into a live store another agent is using is a different kind of risk from adding a file to a directory of rollouts. It gets the brief, which is the only form it can read today.

The brief is available without the UI, as is a conversion:

cctop --handoff            # the most recently active session, as markdown
cctop --handoff 2abd15fe   # a session id, or any unique prefix of one
cctop --convert 2abd15fe codex   # the conversation, in Codex's own store
cctop --converted          # the copies on this machine, and where they came from
cctop --export 2abd15fe    # the whole conversation as markdown, to paste anywhere
cctop --export 2abd15fe --tool-output   # with each tool result, cut at 800 characters

Where the brief is a summary, --export is the transcript: every turn, the words verbatim and quoted so a heading or a code fence inside a message cannot break the document around it, each tool call on one line, thinking left out. The session page’s ⋯ menu copies or downloads the same document, and the brief beside it.

--convert writes the transcript and stops there; it does not start an agent. Use the UI’s O for the launch, which resumes the copy as it goes.

In a browser

cctop serve
cctop: serving on http://127.0.0.1:7777/?t=9f3ac1de…
cctop: read-only link — no actions: http://127.0.0.1:7777/?t=71b02ee4…

Open that link and you get the table cctop draws in the terminal, streamed live, plus what the terminal has no room for: per session, the conversation itself, what it edited, what it can reach, a report that says where an afternoon’s money went, and each account’s rate-limit windows at /api/quota.

cctop serve in a browser: the session list with project, title, model, cost and context per row

It can also answer an agent. A page that tells you a session has been waiting twenty minutes and cannot do anything about it has shown you a problem and withheld the fix, so the page can send a prompt to a live session, resume a dead one, and hand one’s work to a different harness. Nothing destructive: no route stops an agent, kills a process or deletes a transcript — those stay in the TUI, where the confirmation prompt is.

A permission prompt gets a different pair of buttons. Allow and Deny are not words typed at the agent — a prompt is a menu, and a word plus Enter picks whatever is highlighted — so the page presses the key that harness’s own menu names: 1 to allow in Claude Code, y in Codex, Esc to deny either. The two harnesses only, because those are the menus cctop has actually driven; for anyone else the prompt still shows, with what it is asking where the hook or the screen could name it, and the answer stays in the terminal. The asking state itself arrives over the hooks, or — with read_screen = true in the settings file — read off the agent’s screen, which is how a session no hooks cover still puts the buttons on the page. See Reading the agents’ screens.

The whole authorisation for that is the token in the URL, so it is worth stating plainly: whoever holds the link can drive the agents on this machine. Hence the defaults — loopback, a token, and --no-actions to serve the pages without the buttons.

The second link is for showing, not driving. Each run also mints a read-only token, printed as a labelled second line. It opens every page and answers every GET — the same dashboard, the same reports, the same search — but every /api/act/* request it makes is refused with 403 this link is read-only, and the pages it serves are wired to it, so the prompt box never appears. It is a different credential rather than a flag on the first one: “read-only” is not something a request can assert about itself, it is a property of the token it carries. Hand it to a colleague who wants to watch a run, or a status board that has no business typing at your agents.

From inside cctop, with the table still there

cctop serve takes over the terminal it runs in and draws nothing — which is the wrong shape when you want the page and the dashboard. B in the TUI serves the same page while cctop keeps running. l puts it on this machine, t also opens a tunnel, o opens it in your browser, y copies the link, and x stops it. With a tunnel up, c draws the tunnel link as a QR code for a phone to scan.

╭ Serve this table to a browser ─────────────────────────────╮
│ This machine                                               │
│  http://127.0.0.1:7778                                     │
│                                                            │
│ The internet                                               │
│  https://supplemental-belt-spare-reflect.trycloudflare.com │
│                                                            │
│ Anyone holding it reads every session here,                │
│ and can type at your agents. Cloudflare carries it.        │
│                                                            │
│ Read-only — watches, never acts                            │
│  https://supplemental-belt-spare-reflect.trycloudflare.com │
│                                                            │
│ o open · y copy · c QR code · l local · t + tunnel · x stop│
╰────────────────────────────────────────────────────────────╯

The panel shows each link as its origin and not in full, because the full link carries the token — a credential that would otherwise be sitting in every screenshot of the panel. o and y use the whole thing, and so does c: a QR code is the link in a form a camera reads, so it waits to be asked for, goes when the panel closes, and is not drawn at all on a terminal too small to hold all of it — the panel says so instead.

It is the same server, reached differently, with one difference worth knowing: it does not scan for sessions. The dashboard already walks them several times a second, so the page is fed from the rows on screen — one pass over the disk instead of two, and no way for the page and the table beside it to disagree.

Stopping it, or quitting cctop, revokes every link handed out — unless it is on your own Cloudflare account’s tunnel, whose links come back with the next serve until r replaces them. A tunnel lasts exactly as long as the cctop that opened it.

Why you would want it

The table’s headline is which session is waiting on you, and that only pays off while somebody is looking at the terminal. Agents sit amber for twenty minutes because the person who could answer them is in a meeting. cctop serve puts the same fact on a surface you carry.

Sessions that need a person are pulled out into their own block at the top of the page. The Notify button asks for browser notification permission and then fires one the moment a session crosses into waiting — not for the ones already sitting there when you opened the page, which would be a notification about nothing.

The same edge can leave the browser entirely. --notify <URL> (or CCTOP_NOTIFY_URL, which a dashboard-hosted serve also reads since it has no flag) POSTs one JSON event per crossing:

{
  "event": "waiting",
  "session_id": "…",
  "project": "cctop",
  "title": "…",
  "url": "https://…/session/…?t=…"
}

event is waiting or asking; url links the session page, on the tunnel origin when there is one so the link works from wherever the webhook lands. Edge-triggered means exactly that: the first snapshot fires nothing, a session sitting waiting fires once rather than once per refresh, and it fires again only after it leaves the state and re-enters. The POST is made on a spawned thread with a five-second deadline, and a dead endpoint is reported once on stderr and then stays silent — a webhook that never answers costs the refresh loop nothing.

The report

Click any row, or go straight to /session/<id> (any unambiguous prefix of the id works, so a link is short enough to paste into chat).

The report is built from a full transcript read, which is why it happens for one session on request rather than for all of them on a timer. It answers three questions, in the order they are usually the answer:

What failed, repeatedly. Failed tool calls are grouped by tool and by the argument they were called with. Eleven failures of Bash is a session having a bad afternoon; eleven failures of Bash running the identical command is a loop, and the agent paid for every attempt. Every per-tool error count there is renders those two identically. This one does not.

Where the context window went. The stacked bar says what is in the window now — startup, tool output, attachments, and an Unaccounted slice that never pretends to be smaller than it is. The chart under it says how it got there, with compactions marked: a window that climbed steadily is a conversation growing, and one that jumped is a single tool result that will do it again.

What it cost, split by model. Plus spend per hour, the slowest individual calls by wall time, the full tool table, and the files the session wrote.

Reaching it from your phone

The default bind is 127.0.0.1, so out of the box nothing but this machine can open it. Two ways to go further, in the order you should prefer them:

The same page at phone width, in the dark and the light theme

A tunnel you already have. Tailscale, or plain ssh:

ssh -L 7777:127.0.0.1:7777 devbox

Now http://127.0.0.1:7777 on the laptop is the devbox’s cctop, authenticated by ssh, encrypted by ssh, and exposed to nobody.

Or a tunnel cctop opens for you, when there is no ssh to hand and the phone is not on the same network:

cctop serve --tunnel
cctop: opening a trycloudflare quick tunnel…
cctop: serving on https://particular-words-here.trycloudflare.com/?t=9f3ac1de…
cctop: also on http://127.0.0.1:7777/?t=9f3ac1de…
cctop: read-only link — no actions: https://particular-words-here.trycloudflare.com/?t=71b02ee4…
cctop: that first link is on the public internet. Anyone who has it can read
       every session on this machine — and, unless --no-actions, type at your
       agents, which runs commands as you. Cloudflare carries the traffic and
       can read it. The tunnel ends when this process does.

This needs nothing installed: cctop speaks Cloudflare’s tunnel protocol itself rather than shelling out to cloudflared, and the tunnel’s data path runs inside the cctop process, landing on the same loopback listener a local browser uses. So the token, the connection cap and the request deadlines all still apply — the tunnel adds a route in, not a second server.

What it is not is a private channel. Cloudflare terminates the TLS, which is worth saying because the opposite is easy to assume of anything with an https:// URL. The hostname is new every run, it stops existing when cctop does, and --tunnel refuses --no-token outright: a public URL with no token is a prompt box for your agents that anyone who finds it can use.

Or bind wider, and understand what that means:

cctop serve --bind 0.0.0.0
cctop: serving on http://0.0.0.0:7777/?t=9f3ac1de…
cctop: this is reachable from your network — anyone who can open that link can
       read every session on this machine.

cctop terminates no TLS of its own on that socket. A loopback listener does not want it, and doing it for the LAN case means certificates cctop has no business managing — which is why an ssh tunnel is still the better answer here, since it authenticates rather than merely encrypting. --tunnel gets its https:// by borrowing Cloudflare’s certificate and Cloudflare’s edge, which is a different trade, not a stronger one.

Your own Cloudflare account

A quick tunnel is the right shape for ten minutes from a phone and the wrong one for anything you keep. Cloudflare documents its limits: no Server-Sent Events (the live table is one, so it works unsupported and can stall), at most 200 requests in flight, no uptime promise, and a new hostname every run, so a bookmark or a home-screen shortcut dies with the process.

A named tunnel on your own free Cloudflare account has none of those: Server-Sent Events are supported, there is no request cap, and the hostname is yours and stays. What stays the same: Cloudflare still terminates the TLS and can read the traffic, cctop’s token is still what opens the page, and the free plan’s limits are a ~100 s origin response time (the live table’s keepalive is 15 s, so the stream is fine) and 100 MB per upload.

It needs a domain whose DNS is on Cloudflare. A tunnel is reached through a DNS record, so this is the one thing cctop cannot do for you. A cheap domain works, and DNS on Cloudflare is free; add one at https://dash.cloudflare.com/?to=/:account/add-site. Setup checks first and says so if you have none, and quick tunnels keep working either way.

The quickest way is to log in through the browser, the way cloudflared tunnel login does:

cctop tunnel setup --browser

Cloudflare’s login page opens; log in and pick the domain for the tunnel there. Over ssh, or with no browser on the machine, the address is printed instead — open it in any browser, and leave cctop running: the login comes back to it. Cloudflare hands cctop a certificate with an API token in it, cctop keeps only that token (owner-only, in config.toml, never logged), and goes on as below to the hostname. That token is made for tunnels: cctop routes the hostnames the way cloudflared tunnel route dns does, and cctop tunnel remove deletes the tunnel and its records but cannot revoke the token itself — that is done on your profile’s API Tokens page, which remove points at.

Or make a token yourself and paste it:

cctop tunnel setup

It prints a link to Cloudflare’s create-token page with the permissions and the name cctop filled in, and the three permissions in words in case the page does not take them:

  • Account · Cloudflare Tunnel · Edit
  • Zone · DNS · Edit
  • Zone · Zone · Read

Paste the token (it is not shown as you type), pick a domain if you have more than one, and accept the suggested hostname — cctop.<your domain>, or cctop-<machine>.<your domain> when another machine has that one. cctop creates the tunnel, sets its configuration, and adds two proxied DNS records: the page’s hostname and a -share one beside it for terminal shares. A name that already has a record cctop did not make is never overwritten, a deeper name than one label under the domain is refused (Cloudflare’s free certificate covers *.example.com, not a.b.example.com), and if a step fails halfway, what was created is deleted again so a retry starts clean.

From then on cctop serve --tunnel, and t in the dashboard’s serve panel, come up on that hostname:

cctop: opening your Cloudflare tunnel…
cctop: tokens from ~/.config/cctop/serve-tokens — links from earlier runs still work
cctop: serving on https://cctop.example.com/?t=9f3ac1de…

cctop serve keeps its tokens across restarts once an account is connected, exactly as --token-file does, in ~/.config/cctop/serve-tokens — a stable hostname with a new token every run would still be a dead bookmark. --rotate-token replaces them. The dashboard’s serve keeps them too when it is on your tunnel, in the same file, so a bookmark opens whichever of the two is running; r in its panel serves on new tokens, which revokes every link handed out before.

From the dashboard, the same setup is the [tunnel] section of Settings (,, or a in the serve panel B, which jumps there). With no account it has two rows: login opens Cloudflare’s login page and copies its address — over ssh, paste it into a browser wherever you are — and waits for it; token takes an API token or a tunnel token in a masked field (Ctrl+O copies the link that makes an API token with the right permissions). Then a zone row, which Enter cycles when the token reaches more than one domain, and a hostname field, pre-filled; Enter creates the tunnel. Backspace on any of them gives up.

With an account connected, every part of it is a row: status, hostname (Enter renames the dashboard’s address, as e on it in the serve panel does), qr (Enter draws the served link, token and all, as a QR code over the page until any key), zone (fixed at setup), share_hostname (Enter renames the default share hostname — every share link already handed out on the old one stops working, which the row says; agents with a name of their own keep it), access, and remove, which asks y / n and deletes what setup made, as cctop tunnel remove does. While a browser login is waited for, q on login draws its address as a QR code. A call to Cloudflare spins in its row, and what it came to — a refusal above all — is that row’s description until the next change.

While that serve runs, terminal shares (W in the dashboard) ride the same tunnel on the -share hostname instead of opening a quick tunnel of their own — see Sharing an agent to a browser. A share link never uses the page’s hostname, which would want the page’s token. CCTOP_TUNNEL_TOKEN alone names no share hostname, so shares keep their quick tunnel there.

Choosing addresses. With an API token connected, the page’s hostname and each agent’s share hostname can be renamed where they are shown: e or a right-click on the internet link in the serve panel, or on the Address line of a share’s panel; on the page, the address in the header and the Address row of a session (full link only — the read-only link has neither, and the server refuses it). A name is one label under your domain. It is refused, before anything is written, when it is not a usable label, when another of cctop’s names has it, or when the domain already has a record cctop did not make. The new record is made first and the old one deleted last, so a failure leaves the old name working. Renaming the dashboard moves the page while it runs and keeps its token: links to the old hostname stop working, which the field says before Enter, and the page you renamed it from goes to the new one. Agents’ names live in the [tunnel] table beside the records cctop made, and cctop tunnel remove deletes them with the rest.

--tunnel=quick asks for a quick tunnel even with an account connected. And if your tunnel cannot come up — its token revoked, the edge unreachable, or another cctop on this machine already serving it (two would become replicas, and Cloudflare would send half the requests to each) — a quick tunnel stands in, and cctop says why rather than hand out a different link without a word.

A tunnel token works too. If you made a tunnel in the Cloudflare dashboard, paste its token (the long string under its install command, starting eyJhIjoi) instead of an API token. Nothing is created: cctop stores the token, and learns the hostname from the configuration Cloudflare pushes when the tunnel connects — or give it with --hostname, or in the prompt.

Without a config file, for a service: CCTOP_TUNNEL_TOKEN (a tunnel token) and CCTOP_TUNNEL_HOSTNAME. They win over what setup stored. No flag takes a credential, so none ends up in ps or your shell history. Piped, cctop tunnel setup reads one token from stdin, and --zone and --hostname answer its questions.

cctop tunnel status     # what is connected
cctop tunnel remove     # delete what setup made, and forget it

QUIC or HTTP/2. The tunnel reaches Cloudflare over QUIC, which is UDP. On a network that blocks UDP to port 7844, or drops it for seconds at a time — WSL2 behind Windows’ NAT is one — QUIC connections die and every terminal on them with it, so the tunnel moves to HTTP/2 over TCP: at once when QUIC cannot connect at all, and after three timeouts within half an hour when it connects but does not stay up. It tries QUIC again at a reconnect after half an hour on HTTP/2; it never cuts a working connection to do so. To choose yourself, as cloudflared’s --protocol does:

[tunnel]
protocol = "http2"   # or "quic", or "auto" (the default)

or CCTOP_TUNNEL_PROTOCOL=http2, which wins over the file. It applies to quick tunnels too, and cctop tunnel remove leaves it in place.

remove deletes only what setup created — the DNS records and the tunnel, by the ids it stored — then clears the [tunnel] table of config.toml. If the API token has been revoked since, it lists what is left for you to delete in the dashboard. A tunnel connected from a pasted tunnel token was made in the dashboard, so it is forgotten and left there. Stop any cctop serving over it first; remove refuses while one is.

The tokens live in ~/.config/cctop/config.toml, which cctop keeps at mode 600, beside the account tokens it already holds.

Cloudflare Access in front

Cloudflare Access puts a login in front of the dashboard’s hostname, so you open it with your email instead of a token, and colleagues you invite do the same — none of them needs a Cloudflare account. cctop sets it up:

cctop tunnel access on --owner you@example.com   # or `setup --access <email>`
cctop tunnel invite add @company.com             # everyone there, read-only
cctop tunnel invite add boss@company.com --full  # one person, full access
cctop tunnel invite remove @company.com
cctop tunnel invite list
cctop tunnel access links off                    # token links: login only
cctop tunnel access off                          # delete it again

The same list is in the dashboard — the [tunnel] rows in Settings: access turns it on (asking for the owner’s email) or off (asking y / n), owner moves it, each invite row flips read and full on Enter and goes on Backspace, and invite + add takes a new one — and on the web page, behind the shield beside the address in the header. The web page shows it only to the full link or the owner’s own login: a read-only link, a read-only login and a full invite who is not the owner all get 403, from the list as well as from the edits. A full invite may act on sessions; it may not hand out access.

access on creates three things on the account, and remembers their ids in [tunnel.access] of config.toml: an Access application on the dashboard’s hostname, a policy allowing your email and the invites, and a one-time-PIN login (a code emailed to whoever asks) when the account has none of its own. Beside them it makes the token hostname, cctop-link.<your domain> — a DNS record and an entry in the tunnel’s ingress list, outside the Access application — which is where token links go from then on (see public token links below). access off and cctop tunnel remove delete exactly those. It needs two more permissions on the API token — Access: Apps and Policies · Edit and Access: Organizations, Identity Providers, and Groups · Edit. The link setup prints asks for them; a token made before can be given them in the dashboard (Edit keeps the token the same). A tunnel connected by browser login has no token Cloudflare lets do this, so connect with an API token for Access. The account also needs a Zero Trust team: open one.dash.cloudflare.com once and pick a team name and the free plan.

Three ways in, then:

  • You, through Access. Open https://<your hostname> and log in with the code emailed to you. You get full access, with no token in the URL.
  • An invited colleague, through Access. The same login, with their email. Read-only unless the invite says --full; read-only is exactly what the read-only link gets.
  • A token link, for anyone else or a computer without your login, on the token hostname: https://cctop-link.<your domain>/?t=…. That hostname asks for no login, and a login is worth nothing there either — only the token is read, so nothing an invite allows is ever mixed into a token link. cctop serve and the serve panel print token links on it while Access is on. On the dashboard’s own hostname the edge asks for a login first, so a token there helps only someone who can also log in.

Public token links: on or off. public_links in [tunnel.access], on unless turned off; cctop tunnel access links on|off, the public_links row in Settings, or the switch on the web page. Off:

  • the token hostname’s DNS record and ingress entry are deleted, and the tunnel stops routing it;
  • and cctop refuses a token that came through the tunnel on any hostname — the dashboard’s, the token hostname, a quick tunnel’s — as a second lock. A request came through the tunnel when its Host is one the tunnel routes to the page, or it carries the Cf-Ray / Cf-Connecting-Ip headers the edge adds. Only an Access login gets in from outside.

A token on this machine (127.0.0.1) always works. Turning links back on makes the token hostname again. A serve already running picks the change up by itself: the page’s server re-reads config.toml at its next request, and the tunnel’s routing follows within two seconds.

How cctop knows who logged in: the edge sends a signed Cf-Access-Jwt-Assertion with every request, and cctop checks its RS256 signature against your team’s published keys, that it was issued for this application (its AUD tag), by your team, and is not expired — and then that the email is on cctop’s own list, so removing an invite takes effect at the next request. The Cf-Access-Authenticated-User-Email header beside it is never trusted: anything that reaches the local port could write it. A page that got in on a login is handed no token, since a token would outlive the invite.

  • Access goes on the page’s hostname only, never on the -share one. A shared terminal opens its socket from rmux’s page on another site, Access’s cookie does not go with it, and the terminal would never connect.
  • When the Access session expires (after a day), the page’s requests get Access’s login page instead of cctop’s answers, and the page says it cannot reach cctop. Reload it to log in again.

ponytail: renaming the dashboard while Access is on moves neither the Access application nor the token hostname; turn Access off and on again after a rename.

The token

Each run generates an access token and puts it in the URL it prints. Every request needs it, including the ones that only return HTML, so a wrong token cannot be used to find out which routes exist. The one exception is GET /metrics, which carries no transcript and answers without one — see Scraping it.

The URL being the whole credential is deliberate: it makes the link shareable over whatever channel you already trust, and it is why --no-token is a thing to justify rather than a convenience. Without it, every process and every user on the machine can read your sessions — and a later --bind exposes them to the network with no gate at all.

Restarting cctop serve mints a new token and invalidates the old link — unless --token-file keeps them, below.

Either token is accepted in three places, all checked by the same gate: the ?t= query parameter a link carries, the cookie a page hands back so a reload still gets in, and an Authorization: Bearer <token> header — for a script or an HTTP client, and it keeps the token out of the URL they log and display.

Keeping them across restarts

A token minted per run is right for a link that can type at your agents, and wrong for a bookmarked dashboard or a read-only link pinned to a status board, which then break on every restart. --token-file is the opt-in:

cctop serve --token-file ~/.config/cctop/serve-tokens
cctop: new tokens written to /home/you/.config/cctop/serve-tokens — they outlive this run; --rotate-token replaces them

When the file exists its tokens are served; when it does not, fresh ones are minted and written to it — created with O_EXCL and mode 600, so there is no moment at which they sit in a file someone else can read. The next run says tokens from … and every link from the last one still works.

A file that is not plainly yours is refused rather than repaired:

  • readable or writable by group or others — anyone who could read it already holds the credential, and tightening the mode now does not un-leak it;
  • owned by another user — they can rewrite it, and a token someone else chose is one they know;
  • a symlink, or not a regular file — the file checked has to be the file read, and a link can be repointed in between.

Each refusal says so and names the way out. Rotating is cctop serve --token-file <path> --rotate-token, or deleting the file: both mint new tokens and revoke every link built on the old ones. The directories above the file are not checked, so put it somewhere only you can write.

The dashboard’s B reads no token file of yours: stopping that serve revokes every link it handed out — except over your own Cloudflare tunnel, where it keeps its tokens as described above and r revokes them.

Several machines at once

--host works the same as it does in the terminal, and composes with everything above:

cctop serve --host devbox --host build-01

Remote rows are read over ssh and merged into the same table. A host that cannot be read shows a banner saying which one and why, rather than quietly going missing and leaving the totals looking complete.

A remote row’s report page works too: /api/report, /api/chat and /api/access are answered by asking the cctop on the machine the session lives on — ssh <host> cctop --report <id> over the same channel the rows arrive by — and relaying the document it prints. Nothing is parsed at the row’s path on this filesystem, which would report whatever happens to live there locally. When the far side cannot answer — the host is down, or its cctop is older than these flags — the route says so with a 502 rather than an empty page.

Machines on the same Cloudflare account find each other

Every machine you ran cctop tunnel setup on, against one Cloudflare account, has a tunnel there called cctop-<machine> at a hostname of its own. That is already the list, so there is nothing to type: cctop lists the account’s cctop-* tunnels, and every other machine’s sessions appear in the table — the dashboard’s and the page’s — with the machine’s name on the row and a dot that is green while it answers and red when it stops. A machine that goes offline is shown offline, with the reason, never silently dropped.

What you need: each machine connected to the account with an API token or the browser login (cctop tunnel setup either way — a pasted tunnel token holds nothing that can list the account), and cctop serving on its tunnel there (the dashboard’s B, or cctop serve --tunnel) for it to be read. A machine whose tunnel has no connector is shown as offline.

From the page you can open a sibling’s session as if it were local: its conversation, report and access, its terminal, and the actions. The page only ever talks to the machine you opened it on, which relays to the other at /api/peer/<machine>/… — so the content policy is unchanged, and the terminal frame is the same same-origin frame as a local one. Who may act is decided where you are: a read-only link reads a sibling and cannot act on it.

How one cctop proves itself to another, with nothing configured: being on the same account is the trust. Each machine holds its own tunnel’s token, a secret, and any API token on the account can fetch any tunnel’s token. So the caller fetches the other machine’s token and signs each request with it — Authorization: cctop-peer <time>.<HMAC-SHA256(token, time + method + path)> — and the other checks it against its own, within a minute either way. Nobody off the account can get that token. A signature is never handed a token of the page’s, and a rotated tunnel token is refetched on the refusal that follows.

When Access fronts a machine’s page, siblings reach it on its -link hostname, which is outside Access; with public token links off there is no such hostname, and that machine shows as offline with the reason.

A sibling’s terminal in a tab survives its connection dropping — the tunnel moving, the edge restarting, a network change. The tab gets back to the same terminal and is sent what it missed. A drop that is over within two seconds shows nothing; a longer one shows a single line on the tab’s bottom row, which is painted back as it was once the tab is back. Keys typed while the tab is away are not sent — they were typed at a screen that has since moved on — and the tab says so, even after a drop too quick to show anything else. Both ends ping a quiet socket every 30 seconds, and give up on one that has said nothing for longer than any live one would. A ping unanswered for five seconds — and a key typed at a socket silent that long sends one — puts “not answering” on the bottom row until it answers.

To see why a tab keeps dropping, create cctop/ws-trace.log in the runtime directory ($XDG_RUNTIME_DIR, or the cache directory without one) on either machine, or start cctop there with CCTOP_TRACE_WS=1. Each drop, its reason, and how long getting back took are appended to that file, which cctop keeps readable by you alone.

Flags

--bind <ADDR>Address to listen on. Default 127.0.0.1
--port <PORT>Default 7777. Without this flag a busy port is stepped past; with it, a busy port is an error
--no-tokenServe with no access token. Also turns actions off — the token is what authorises one
--no-actionsServe the pages without the buttons: no prompts, no resuming, no handoff
--tunnelAlso reach the page from anywhere: over your own Cloudflare tunnel when one is connected, else a trycloudflare quick tunnel. Refuses --no-token and --bind
--tunnel=quickA quick tunnel even when your own is connected
--plan <PLAN>retail, max or included, as elsewhere
--delay <SECS>Seconds between refreshes. Default 2
--host <HOST>Also serve another machine’s sessions. Repeatable
--notify <URL>POST a JSON event when a session crosses into waiting or asking. Also read from CCTOP_NOTIFY_URL, including by a dashboard-hosted serve
--token-file <PATH>Keep the tokens across restarts: read them from PATH, or mint them and write it with mode 600. Refuses a file others can read or do not own, and --no-token — see the token
--rotate-tokenWith --token-file: replace the tokens in it, revoking every link built on the old ones

What it serves

GET /The dashboard
GET /session/<id>The report page for one session
GET /analyticsThe fleet’s history, filtered and charted in the page
GET /api/sessionsThe same document cctop --json prints
GET /api/analyticsThe whole fleet’s history as one document, untrimmed — the page fetches it on its own cadence rather than every refresh
GET /api/report/<id>The report, as JSON
GET /api/chat/<id>The conversation, as JSON — what cctop --chat prints
GET /api/chat/<id>?agent=<agent>One subagent’s own conversation, by the id its Agent call names — what cctop --chat <id> --agent <agent> prints; 404 for an id the session does not list
GET /api/access/<id>What one session can reach: instructions, skills, MCP servers — what cctop --access prints
GET /api/eventsServer-sent events; one sessions event per refresh
GET /api/hostsWhich --host machines and sibling machines on the account could not be read, and why
/api/peer/<machine>/<path>A sibling machine’s /api/<…> or terminal socket, relayed and signed. Reads for any link; a POST and a terminal only for one that may act. Not its pages or its event stream
GET /api/agentsWhat this run can hand work to, and whether it will act at all — the page hides the controls it cannot use rather than offering buttons that answer 403
GET /api/tabsThe tabs cctop is running, for the page’s tab strip
GET /api/quotaEach account’s rate-limit status and windows — {"claude":[…],"codex":[…]}, each profile with status, detail, plan and windows
GET /api/provider-statusWhat the Anthropic and OpenAI status pages last said, set against the sessions failing now: {pages, alerts, line, verdict}. Each page has label, site and state — pending, unavailable with a reason, or ok with level, description, incidents and degraded; each alert has confirmed and probably_local; line is the TUI footer’s sentence, or null when it would say nothing
GET /api/search?q=<query>Both search tiers over every session: {"hits":[{key, session_id, snippet, score}]}. Literal matches carry the matching text; topical-only hits carry ~NN% plus the chunk head
GET /metricsThe same table as Prometheus text exposition. Needs no token — see Scraping it
GET /insight/optimizeThe text cctop optimize prints, as text/plain
GET /insight/compareThe text cctop compare prints, as text/plain
GET /favicon.svgThe page’s icon
GET /manifest.webmanifestInstallable-page metadata, so a browser can add the dashboard as an app

The routes that act rather than read, all POST, all refused on a read-only link and on a --no-actions serve:

/api/launchStart an agent. Not under /api/act/, because a launch names no session. A cwd of host:path starts it on that ssh host under cctop sandbox, in an rmux session — claude and opencode only, as in the terminal’s launcher
/api/ssh/hostsThe hosts in ~/.ssh/config, names and aliases only
/api/ssh/complete{host, path} → the folders under path on the host, and for a bare name its git repositories, newest first
/api/ssh/check{host, path} → whether the folder can be worked in, in the words the terminal uses (read-only on the host)
/api/tab/<name>/terminalThe link that reaches one tab’s terminal
/api/act/send/<id>Type a prompt into a live session’s terminal
/api/act/answer/<id>Answer a permission prompt
/api/act/resume/<id>Resume a dead session
/api/act/handoff/<id>Hand one session’s work to another agent
/api/act/image/<id>File a pasted image and say where. Names a session, but the file lands on this machine
/api/act/terminal/<id>The link that reaches one session’s terminal

The /api/ssh/* routes start ssh connections from a request, which is why they are behind the full token like the actions — and why the host list is: the names in ~/.ssh/config are internal hostnames, and a read-only link is the one that gets handed around. Connections never prompt. A host that wants a password, a passphrase or a host-key answer comes back offline with that reason, and a launch there goes ahead anyway: the sandbox connects inside the rmux session, and the prompt appears in the session page’s terminal. No password ever goes through the page. Each connection takes a lease on the shared ssh master, given back when cctop serve exits on Ctrl-C, SIGTERM or SIGHUP, or when the dashboard hosting it quits.

/api/sessions is byte-for-byte the document --json prints and --host parses — one builder, so a browser is never shown different figures than the terminal.

/api/quota is served from memory: a standalone serve polls the usage endpoints itself on a 60-second cadence — slower than the two-second session refresh, because they throttle hard — and a dashboard-hosted one serves the reading the TUI’s own poller already made. Either way a request never triggers a fetch.

/api/provider-status works the same way. A standalone serve asks each status page on the TUI’s schedule — every two minutes after an answer, five after a page could not be reached — and one the TUI started is handed the TUI’s answers instead, so one process never asks the vendor twice. Nothing a browser sends makes cctop ask sooner. The dashboard re-reads it every 30 seconds and shows the footer’s line above the table, which opens the ! panel’s account in a dialog.

/api/search runs the same two tiers the TUI’s s does: the literal byte scan, then the embedding index when the model is installed and the query earns it — an empty literal answer, or a sentence-length question. Queries under three characters answer {"hits":[]}, hits are capped at 25, and a missing or unloadable index simply means the literal tier answered alone.

The insight routes re-parse every transcript on request, the same cost the CLI pays — they are asked for, not polled.

Scraping it with Prometheus

/metrics is the snapshot every other route serves, summed into gauges in the Prometheus text format. It says nothing /api/sessions does not, and leaves out titles, prompts and full paths on purpose, since a metrics store keeps what it is sent for months and shows it to anyone with a dashboard login.

It is the one route that needs no token. What is left is aggregate counts, costs, model names, project directory names and eight-character session ids — nothing a token would be protecting — and a scrape config is the file that gets copied into config repositories and Helm values, which is the last place a credential that also opens your transcripts should live. It also means a restart never breaks the scrape. Every other route keeps its check, and only GET (or HEAD) is answered. The cost: whoever can reach the port can read the numbers — this machine’s users on the default loopback bind, your network under --bind, and anyone with the hostname under --tunnel.

MetricLabels
cctop_build_infoversionAlways 1
cctop_remote_hosts_unreadable--host machines that could not be read
cctop_sessionsprovider, statestate is working, waiting, asking, error, or idle for a session with no live process. Every state is present, at 0 when empty, so an alert on waiting always evaluates
cctop_cost_usdprovider, includedEstimated cost at retail rates of every session in the table
cctop_cost_today_usd, cctop_cost_this_hour_usd, cctop_cost_last_hour_usdprovider, includedSince local midnight, in the current local clock hour, and in the last 60 minutes rolling
cctop_cost_burn_usd_per_hourprovider, includedThe smoothed live spend rate
cctop_model_cost_usd, cctop_model_cost_today_usdprovider, model, includedThe same, by model
cctop_tokensprovider, model, kindkind is input, output, cache_read or cache_write
cctop_tool_calls, cctop_tool_call_errorsproviderErrors only for providers whose transcripts record a per-call outcome — a zero elsewhere would claim “nothing failed”
cctop_context_used_tokens, cctop_context_max_tokens, cctop_context_fill_ratiosession, project, providerLive sessions only

Cost is reported whatever the plan, with included="true" marking usage the plan bundles — a retail equivalent rather than money spent, as the analytics page labels it incl.

Everything is a gauge, even the totals. They are sums over the sessions in the table, and a session can leave it — a host goes dark, a transcript is deleted — which a counter would report as a reset and rate() would turn into a spike.

Cardinality is bounded. Labels take values from small, closed sets — provider, state, model, token kind. The one exception is the context window, which only means anything per session: those series carry the id’s first eight characters and the project’s directory name, and exist only while the session is live. A session that ends drops out of the next scrape instead of leaving a series behind for every session ever run.

A remote row’s transcript is not on this machine, so its tokens and cost are filed under the model it is on, with its input kinds folded into input.

scrape_configs:
  - job_name: cctop
    scrape_interval: 30s
    static_configs:
      - targets: ["127.0.0.1:7777"]

No authorization: and no params: — a plain cctop serve is scrapeable as it is, and stays so across restarts.

Notes

The pages carry their own CSS and JavaScript and fetch nothing at all, which is what lets every response send default-src 'none'. The only same-origin exceptions are img-src 'self' for the favicon and manifest-src 'self' for the manifest — the two requests an installable page cannot inline. There is no build step, no CDN, and no assets on disk: an installed cctop is one binary, and a page that loaded its own stylesheet would break the moment that binary moved.

Integrations

← back to the README

Three ways cctop connects to something outside itself: letting the agents report their own state, letting agents query cctop, and reading another machine.

Letting the agents tell cctop directly

Both of those are cctop guessing. The agents can just say it, and cctop sets that up itself: on start it installs its hooks, for this user, into every agent whose config directory exists here, and keeps them current on every start after. Removing them by hand (--remove-hooks) is remembered, so they stay out until --install-hooks puts them back. By hand, for the rest:

cctop --install-hooks           # for this user: every agent below
cctop --install-hooks project   # only for the project in this directory
cctop --remove-hooks            # take it back out; same scopes
cctop --hooks-status            # what is installed, and whether it works

Press h (or F8) in the UI for the same thing with the install and remove keys on it, so none of this needs you to leave cctop or restart it.

One install covers five agents, each asked in its own dialect:

AgentWhere it is writtenWhat is written
Claude Code~/.claude/settings.json, or <project>/.claude/every event it raises but one — thirty of them, from SessionStart to SessionEnd. The exception is WorktreeCreate; see below
Gemini CLI~/.gemini/settings.json, or <project>/.gemini/eight — BeforeAgent, AfterAgent, BeforeTool, AfterTool, Notification, SessionStart, SessionEnd, PreCompress
Cursor~/.cursor/hooks.json, or <project>/.cursor/seven — stop, beforeSubmitPrompt, beforeShellExecution, sessionStart, sessionEnd, preCompact, subagentStop
Codex~/.codex/hooks.json, or <project>/.codex/, and ~/.codex/config.tomlten hooks — Stop, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, SessionStart, SessionEnd, PreCompact, PostCompact, SubagentStop — and notify = ["cctop", "hook", "codex"]
OpenCode~/.config/opencode/plugins/cctop.ts, or <project>/.opencode/a plugin, since OpenCode extends by code rather than by command — written for whichever OpenCode is installed, see below

OpenCode’s plugin is the one file cctop cannot register with a command, and the only integration whose shape is decided by somebody else’s release. OpenCode 2 replaced the plugin API outright and does not load a version 1 plugin at all — it says so in a log file and carries on — so the file cctop writes carries both: a named export, which is what version 1 has always loaded, and a default export with setup for version 2 and server for version 1.18.29 and newer. One file is right on every version, which means an OpenCode upgraded under a cctop that never runs again keeps reporting rather than going quiet. Both halves report the same events under the same names, so nothing downstream of the plugin has to know which one ran.

An OpenCode cctop launches also has its own tab strip turned off, for that run only: cctop’s tabs are the real ones, and a tab bar inside the pane is a second and lesser account of the same fact. That one does depend on the version — the strip is a version 2 thing — so cctop asks the binary which one it is. cli.json is yours and is not touched: type opencode yourself and your tabs are there.

Codex is asked twice, because its two ways of saying things fail differently. Its hook framework borrowed Claude Code’s spelling wholesale — same event names, same nested hooks object, same JSON on stdin — so cctop needed no new reader for it, and it reports everything Claude Code does bar the two failure events Codex has no use for: its PostToolUse fires for a command that exited non-zero as well as one that succeeded, and a turn that dies has no second ending. But a Codex hook is inert until a person has looked at it: run /hooks inside Codex and trust them, or they deliver nothing. notify stays installed alongside for exactly that reason. It says only that a turn finished, and it says it the moment it is written.

Nothing on disk says whether you have trusted them — Codex records that against a hash of the hook, somewhere it does not document — so cctop reads it from the events instead. notify carries no permission mode and a hook carries one on every event worth having, which makes a Codex row that knows how much it asks before it acts a Codex whose hooks are firing. Until one has, the panel keeps the reminder on Codex’s line, and starting a Codex from the launcher says it where it is a keystroke away rather than a thing to remember:

Started codex in ~/proj — run /hooks in it and trust cctop's to see it here

A Codex you reattached to is left out of that: it has been running since before whatever is installed now, so the answer for it is a restart rather than a keystroke.

Each hook runs cctop hook <event>; the plugin and Codex’s notify hand the event over as an argument instead. Whichever way it arrives, it is reduced to the same three facts — which session, what happened, which directory — and sent to every running cctop over a unix socket. The agents disagree about all three spellings (session_id, thread-id, conversation_id, sessionID; a cwd or a workspace_roots array), so each is read under every name anyone uses.

Half the list is there to end a state rather than start one, which is the half that goes wrong when it is missing. Claude Code fires PostToolUse only for a tool call that succeeded and Stop only for a turn that ended cleanly, so without their partners — PostToolUseFailure and StopFailure — a grep that matched nothing leaves a tool call in flight forever, and cctop draws a tool call in flight over a still screen as a permission prompt waiting for you. PermissionRequest is the same fact said properly: it fires the instant Claude Code asks, where the Notification for the same prompt is on a six-second timer and the tool-in-flight reading is a guess.

It fires when the prompt is raised, though, not when it is decided — and Claude Code’s auto mode decides most of them itself, by asking a model, which takes a few seconds. So cctop holds a permission prompt for four seconds before it counts as a question: no bell, no blinking tab, no attention card. If the decision arrives first — PermissionDenied, or the tool simply running — the newer event replaces the held one and nobody is disturbed at all. If nothing answers, the prompt is yours after the four seconds. Nothing else is held: an MCP elicitation and a plain notification have no second opinion coming.

cctop asks for all of them rather than a chosen few, so that teaching cctop about an event is a change to cctop and not to everybody’s settings file. Most fire a handful of times in a session. MessageDisplay is the exception — once per batch of streamed lines, several times per message — and it buys the one thing nothing else says: a long answer with no tool calls in it raises nothing at all between UserPromptSubmit and Stop, so a turn spent writing was a turn cctop could only guess at from a screen that had stopped changing.

Some are worth naming for what they fix. Elicitation is an MCP server asking you something mid-task: not a permission dialog, so PermissionRequest never fires, and a session blocked on one used to read as idle. TeammateIdle is an agent-team member finishing its turn — the team’s Stop, one member at a time. UserPromptExpansion covers the path UserPromptSubmit misses, where typing /skillname expands into a prompt without submitting one. CwdChanged moves the ground under a row, since the project a session is drawn against comes from its working directory. And PermissionDenied is the only trace auto mode leaves when it refuses a call.

WorktreeCreate is the one cctop will not install, and not because of cost. It does not observe worktree creation — it replaces it. Claude Code stops calling git worktree and takes the new path from the hook’s stdout, and a hook that produces no path fails worktree creation with an error. cctop’s hook writes nothing to stdout by construction, because a hook that prints is a hook that can derail the session it watches — so registering this one would break claude --worktree and every subagent isolated in a worktree. If you want cctop to know about worktrees, that needs a hook that actually creates one.

If you installed before an event was added — PostToolUseFailure, or a whole agent — that install is short exactly those events, and cctop fills it in on the way up rather than only mentioning it in the panel. It is filled in where it stands: an install naming a different cctop keeps that binary, because events reach every cctop that is listening whoever fires them. Only an install naming a binary that no longer exists is repointed at this one, there being nothing left to respect. The OpenCode plugin is topped up the same way, by being rewritten: cctop owns that file outright, and an old copy of it forwards fewer events for the same reason an old settings file registers fewer hooks.

The same goes for an install that has every event but not in the form this version writes — an older cctop’s command line, timeout, matcher, or an event it registered and this one no longer does. Those entries are rewritten into the current form, at the binary they already name, and only cctop’s: your own hooks in the same file, in the same event, even in the same wrapper, are left as they are. This runs at every start of the dashboard and of cctop serve, on a thread of its own so nothing waits for it, and after cctop --update, run by the new binary since only it knows the new form. When everything is already right it reads each file once and writes nothing; anything it does change is said once in the status line (or on stderr for serve). cctop hook itself never does this: it stays as fast and as silent as below.

/yolo in Claude Code

A user install of Claude Code’s hooks also lets YOLO be switched without leaving the session: /yolo switches it on for that session (every permission prompt is allowed until the session’s process ends), /yolo off switches it off, and /yolo status says which. The answer appears at once, in place of the command: no model turn runs and no tokens are spent.

Two pieces do it. cctop yolo-hook UserPromptExpansion, installed with the matcher yolo, does the switching: Claude Code runs it when you type the command, it writes the same switch the table does — for the session in the payload and the agent process in CLAUDE_PID (Claude Code 2.1.214 or later) — and it answers with a block whose reason is what is now true, so the command never expands. And a skill, ~/.claude/skills/yolo/SKILL.md, exists only so Claude Code knows /yolo as a command; it runs nothing. If the hook did not answer — hooks not reinstalled since an upgrade, or a Claude Code without the UserPromptExpansion event — the skill’s text tells the model to say so and change nothing.

The skill is refreshed and removed with the hooks, never written over a /yolo of your own (a skill of that name, or ~/.claude/commands/yolo.md), and not installed at project scope, since a command that hands out permission is not one to check in.

Why the hook may switch YOLO on: Claude Code fires UserPromptExpansion only for a command you typed. The model’s Skill tool goes through PreToolUse instead, and the skill sets disable-model-invocation besides. The command line cannot make that distinction — run from a shell in the session, cctop yolo sees the same environment and process tree whether you or the model ran it — so cctop yolo on is refused everywhere, while cctop yolo off and cctop yolo status still work from the session’s shell. What remains is the bar every other way in sits at: a model whose Bash calls are broadly allowed — Bash(*) or bypass mode — could run cctop yolo-hook with a payload of its own making, as it could write the switch file directly.

Cursor also reads Claude Code’s settings.json of its own accord, so with both installed each moment arrives twice; that costs a process spawn and nothing else, since the second event says exactly what the first did. The same goes for Codex once its hooks are trusted: Stop and notify both report the end of a turn, and applying the same fact twice changes nothing.

A state cctop was told about is believed until the agent says otherwise, with one exception: “I am working” has a shelf life of fifteen minutes. An agent mid-turn says something again well inside that — the next tool starts, the tool comes back, the turn ends — so silence that long means the event that would have closed it never came, which is what a killed session, a closed terminal and an interrupted turn all look like. “I am waiting on you” never expires, because nothing else was going to say it again and you may be away for the afternoon.

A reported turn beats a still screen: the green appears the instant the turn ends rather than two seconds later, and the amber no longer waits for a transcript to be written. SessionStart and SessionEnd do the same for the table itself — a session you just started appears at once instead of at the next poll, and one that has exited stops claiming a state it is no longer in. Sessions already running keep their old hooks until they restart.

The installer merges into your settings rather than writing them, recognises its own entries by their shape so it is idempotent and removable however the binary is named, writes through a temporary file so an interrupted write cannot leave you with no settings, and refuses outright if the file is not valid JSON — or, for Codex, not valid TOML — rather than replacing it. Codex’s config is edited in place, so the comments and layout around it survive. Codex allows only one notify program, so an entry that is not cctop’s is reported and left alone. Agents are independent of each other: a notify slot already spoken for, or a settings file that will not parse, is one line of the report and does not undo the installs that worked.

The OpenCode plugin is the one integration that runs inside an agent rather than beside it, so it is the one place the exit-code guarantee below cannot help. Its handler ignores everything it does not report, wraps the rest in a try, and never waits for the process it starts. cctop writes that file whole and deletes it whole — it is the only file here it owns outright.

--hooks-status and the h panel exist because none of this is visible otherwise: an install that points at a cctop you have since moved or deleted looks exactly like an agent with nothing to say. cctop repoints that one for you on startup — there is no behaviour to preserve in a command that runs nothing — but leaves an install pointing at a different cctop that does exist alone, since that is a second install and not a fault.

cctop hook cannot break your session. An agent reads a hook’s exit code as a decision — exit 2 blocks the tool call — so this one exits 0 unconditionally, writes nothing to stdout that could be read as a decision, and is bounded by a 250ms deadline covering the whole exchange, on a thread the process abandons if it overruns. No cctop running, a stale socket, malformed input, a wedged cctop, an outright panic: every one of them is a silent, prompt success. Dropping an event is always cheaper than stalling an agent.

Its stdout stays empty but for two answers that decide nothing: the documented no-op {"continue": true} for Stop and SubagentStop, and — only if you turn on warn_agents — a note on a file write that another running agent wrote the same file moments ago. See Telling the second agent.

Reading the agents’ screens instead

Hooks can fail in ways that look like an agent with nothing to say: not installed, installed at a binary that has since moved, a session started before they were, a permission prompt announced six seconds late. For an agent running in one of cctop’s own tabs, there is a second way in:

[settings]
read_screen = true   # or toggle it with `,`

cctop then reads the bottom of each agent tab’s screen on every frame and takes the state from the key hints the agent draws there — a way to cancel a dialog holding the turn, a way to interrupt a turn in flight. The words differ per agent, and sometimes collide: Esc to cancel is a permission prompt in Claude Code and a turn in flight in Gemini CLI. So each harness has its own list:

HarnessAskingWorking
Claude CodeEsc to cancelesc to interrupt
CodexPress enter to confirm or esc to cancel, enter confirm · esc skip, …Working (12s • esc to interrupt)
Gemini CLIAllow execution, Apply this change, …esc to cancel
OpenCode△ Permission required, …esc interrupt, …
CursorRun this command?, Skip (esc or n), …ctrl+c to stop
DevinApprove once + Esc cancel, …esc to interrupt, …
DroidEnter to select + Esc to cancelesc to stop
Pi—Working...

Claude Code’s (2.1.283) and Codex’s (0.157.1) were captured from real screens; the rest come from herdr’s detection manifests. Idle has no phrase: a screen that matches neither column counts as idle once it has been still for two seconds, since every one of these agents ticks a timer while it works. A phrase would not do — Codex keeps ? for shortcuts on screen while it works, and draws a frame mid-turn with no working line at all.

When the screen says something, it outranks the hooks and the transcript, for the row, the tab bar and the bell. It is off by default because these phrases are each agent’s UI, not a contract — a release that rewords its footer goes unread until cctop catches up.

The read reaches further than the tab you are looking at. A detached rmux tab’s screen is borrowed with capture-pane once a second — no client is attached, so nothing resizes — and a standalone cctop serve does the same for every running agent it can reach, through the cctop run shim’s replay socket or the pane rmux holds. That is what lets the page show asking — and the Allow and Deny buttons Claude Code and Codex prompts get — for a session nobody has a terminal open on. What it cannot see is an agent in a plain terminal of its own: no shim, no pane, no screen to borrow.

The footer is yours too:

[settings]
footer_hide = "sort, follow"    # drop hints and badges by name; "share" drops the corner
footer_note = "on-call shift"   # a static line, drawn like any other badge
footer_command = "date +%H:%M"  # a command; its first line shows, refreshed every 30s

footer_hide names the badges (idle, age, filter, cost, marked, follow, bell, remote, conflict, provider, update, quit, note) and the key hints (Move, Actions, Filter, Mark, Sort, Quit, …, as labelled), share for the corner that publishes the table, and link for a remote tab’s connection beside it (☁ 118ms · 2↻ · ×4.1: round trip, reconnects, compression; green, amber or red by round trip). footer_command runs through timeout 5s sh -c, in the background: a command that hangs or fails simply shows nothing until the next run.

Letting agents see each other

cctop --mcp

serves the Model Context Protocol on stdin/stdout, so an agent can ask cctop what the other agents on the machine are doing — the thing none of them can find out for themselves — and what any of them, past or present, already worked out. Register it with the agents you use:

cctop --install-mcp            # Claude Code and Codex, every project
cctop --install-mcp project    # Claude Code, this directory only (.mcp.json)

That runs each agent’s own claude mcp add / codex mcp add rather than editing its config file, which Claude Code rewrites while it runs. Anything else that speaks MCP takes the usual stanza, which the command also prints:

{"mcpServers": {"cctop": {"command": "cctop", "args": ["--mcp"]}}}

The server tells the agent at start-up what it is for, through MCP’s own instructions field, which a client puts in the agent’s instructions. That is what makes an agent reach for recall unprompted, without cctop editing anybody’s CLAUDE.md or AGENTS.md.

Seven tools, all read-only:

  • recall — the passages of past sessions, from any agent, that answer a question: why something was decided, what was found last time a test broke. Ranked by the query’s words and by its meaning (when the search model is fetched), with the two rankings fused by rank, at most two passages from one session, each with its session id, agent, directory and date. The caller’s own session is left out, since its conversation contains the question. The same thing is cctop recall on the command line.

  • read_passage — one passage from a recall answer with its neighbours, to read a hit in context.

  • list_sessions — every session, any harness: model, directory, branch, tokens, estimated cost, context occupancy, and whether it is still running. Filterable by running_only and by directory, which is how an agent asks who else is in this repo.

  • check_conflicts — the ! column, asked rather than read: give it a directory and the files you are about to change, and it answers with the running agents in the same repository and which of those files they have already written. The one question an agent cannot answer for itself and pays for getting wrong, since a lost edit arrives with no error attached.

  • get_session_context — the same brief O writes, for one session.

  • search_sessions — the full text of every transcript on the machine, with a snippet of each match: which sessions mention a string, literally. recall is usually the better question; this stays for an exact string.

  • wait_for_session — cctop wait as a tool: blocks until a session stops working and answers with the same JSON. For an agent that handed work to another and wants to pick up when it is done. Capped at five minutes a call (60 seconds unless asked), since a tool call holds the caller’s turn; on a timeout it says so, and the agent calls again. Unlike the command it does not listen for hooks itself, so it hears a finished turn a second or two later, once a transcript or a running cctop has it.

Nothing here starts, stops, or types at anything. An agent that can drive other agents is a much larger proposition than one that can see them, and the visibility is the half with no downside.

More than one machine

cctop is otherwise a monitor of one computer, which is the wrong shape if the agents are not all on it — a laptop in front of you and a devbox doing the heavy work, each with its own idea of what today cost.

cctop --host devbox --host flo@builder
CCTOP_HOSTS=devbox,flo@builder cctop     # the same, from the environment

Their sessions join the table with a HOST column saying where each one is, and their spend joins every total and every window in the Overview. The column appears only when a host is configured; on one machine it would be the word local repeated down the screen.

The mechanism is the dullest one available: a thread per host runs ssh <host> cctop --json every 15 seconds and reads what comes back. No daemon, no port, no protocol of cctop’s own, nothing to install on the far side beyond the cctop that is already there — ssh has the authentication and the transport, and --json is the wire format whether or not anyone sends it over a wire. A host must therefore have cctop installed and your key must reach it without a passphrase prompt (BatchMode=yes, so a host that would ask is an error rather than a hang).

If it says command not found, that is the usual first result and it is not a lie: ssh host cctop runs a non-interactive shell, which on most setups skips the rc file that put ~/.local/bin or a version manager’s shim on PATH. Name the binary instead:

cctop --host devbox:/usr/local/bin/cctop

Remote rows are read-only. d, Ctrl+K, s, a, R and O all refuse them by name, because every one of them reaches into this machine — a signal to a process, a transcript on disk, a pty — and the same path on this filesystem is a different file. For the same reason the branch shown is the one that machine read, not whatever happens to sit at that path here, and the ! conflict column carries the verdict that machine reached: each one detects its own overlaps, being the only one that can see its own disk.

Only the Info panel is filled in for a remote session. Cost, Context, Tool Activity and the rest are readings of a transcript that stays where it is, and they say so rather than drawing zeroes.

Two readers do reach back across the wire. i opens the conversation view for a remote row — the transcript is walked by the cctop on the far side and the document comes back over the same ssh channel, never parsed at the local path. And a cctop serve — whether standalone or the one B runs inside the TUI — answers /api/report, /api/chat and /api/access for remote rows the same way.

A host that stops answering keeps its last rows and says so in the footer (⚠ devbox: Permission denied). Blanking them would be the stronger claim — those agents have not stopped, cctop has merely lost sight of them.

Keeping the far side up to date

The two ends are separate installs, and nothing ties their versions together — a server can sit on 0.17.4 for months while the laptop moves on, still sending rows, just without whatever came since. So each time cctop connects to a host (once at the first read, and again after the host comes back from being unreachable) it also runs ssh <host> cctop --version, over the same options as the poll. Every cctop ever released answers that, which is why it is a separate round trip rather than a field in --json: the remote that matters is the old one, and the old one would not know to send it.

When the versions differ you hear about it once per host per run:

The far side is…What cctop shows
oldera toast; ↑devbox in amber in the HOST column; a cctop line in Info; and Update cctop on its host in the row menu (Enter)
newera toast and an Info line saying it is this machine’s cctop that is behind — cctop --update here
missinga toast saying there is no cctop where ssh looks, and how to name one with --host host:/path/to/cctop

Update cctop on its host is only offered for a host known to be behind, and only ever runs after you say yes to a confirmation that shows the command:

ssh devbox -- cctop --update

It runs with no terminal (ssh -T, BatchMode=yes, a closed stdin), and --update asks questions only on a terminal — so the far side never waits on a prompt nobody can see. If the binary there sits in a directory only root can write (the /usr/local/bin install), the update fails and cctop says so, with the command to run yourself:

ssh -t devbox sudo cctop --update

cctop does not run sudo on another machine for you. The same comparison is in cctop doctor --host devbox, which warns when either side is behind.

An agent here, working there

--host watches agents that run somewhere else. cctop sandbox is the other way round: the agent runs here, with your login, your hooks and this dashboard, and what it does happens on another machine.

cctop sandbox devbox:/srv/api
cctop sandbox devbox:~/src/api --model opus     # anything after the path goes to the agent
cctop sandbox --agent opencode devbox:~/src/api

The TUI starts the same thing from its new-tab launcher: press c for the directory, type devbox:~/src/api or pick devbox: from the hosts in your ~/.ssh/config, and the agent you pick runs there — see tabs and splits.

Which agents can. An agent qualifies only if every command it runs can be sent to the host, so that nothing lands on this machine by mistake:

AgentRemoteHow
Claude Codeyesevery Bash call goes through CLAUDE_CODE_SHELL_PREFIX; a PreToolUse hook keeps the file tools on the mount, and points a host path at it
opencodeyesits shell setting points at a cctop script (OPENCODE_CONFIG_CONTENT), with permission.external_directory set to deny to keep its file tools on the mount; started --standalone
your shellyesplain ssh -t into the directory; no mount
codexnoruns <your login shell> -lc … with the shell taken from the passwd entry, and has no setting to change it; its own remote mode needs codex installed on the host
devinnono shell setting; a hook could rewrite commands, but one that fails lets the command run here
pinonot verified

Connecting from the launcher. As soon as a host is in the directory field, cctop starts one ControlMaster for it in the background with BatchMode=yes and no terminal, so it can never stop to ask you something inside the TUI. The socket is per host and per user, in cctop’s runtime directory (m-<hash>.sock, short enough for the 108-byte socket limit), and the cctop sandbox the launch starts finds it there and rides it rather than connecting again. A host that needs a password, a passphrase or a host-key answer is shown offline with that reason, and the launch tab — which has a terminal — connects interactively instead. Each directory listing is one command over the master with a 5-second timeout (sh, no GNU-only flags), remembered for the session; the repository scan (find ~ -maxdepth 4 -name .git, hidden directories skipped) is run once per host. When cctop exits it closes the masters it started, unless a sandbox tab is still using one — then that tab closes it when it ends. A master nobody is using exits on its own after ten idle minutes, which covers a cctop that was killed.

Which directories can be used. Any directory on the host that exists and that you can read and write. The launcher marks a suggestion that is not — not readable on the host, read-only on the host — and cctop sandbox refuses it before mounting, with the same reason; the agents that mount are greyed out for it, and your shell, which mounts nothing, can still go there. Nothing about this machine refuses a directory: where it is mounted here is cctop’s problem, not yours (see below).

Nothing is installed on the host. It needs bash and setsid, which every Linux box has, and this machine needs sshfs and fusermount3. When sshfs is missing and cctop is on a terminal, it asks:

sshfs is required. Install it now? [Y/n]

and on yes prints and runs the install for your package manager — apt-get install -y sshfs, dnf or yum install -y fuse-sshfs, pacman -S sshfs, zypper install sshfs or apk add sshfs — through sudo unless you are root, so sudo asks for your password on the same terminal. On no, with a package manager it does not know, or with no terminal to ask on, it prints the command and exits non-zero. A sandbox opened from the TUI keeps a failure on screen until you press Enter. install.sh can install sshfs too; see installing. Three ordinary pieces do the work:

  • One ssh connection. cctop starts an OpenSSH ControlMaster to the host and every later call rides it, so you authenticate once — a passphrase or a host key question is asked on your terminal before Claude starts. It is your own ssh reading your own ~/.ssh/config, so host aliases, jump hosts and agent keys work as they do at a prompt.
  • The directory, mounted here. sshfs mounts the host’s /srv/api on this machine, where Read, Edit, Write, Glob and Grep — which run here — see the host’s files. At the same path when it can be, under cctop’s cache when it cannot; see where the mount goes.
  • A shell that is not here. Claude Code runs every Bash command through CLAUDE_CODE_SHELL_PREFIX, which cctop sets to itself. Each command goes to the host over the connection, in the same directory, and its output, exit code and final directory come back — a cd sticks, as it would locally. Interrupting a command (Esc, or a timeout) closes its ssh channel, and the host stops the command’s whole process group when that happens. opencode is given a two-line script as its shell instead, which sends -c <command> the same way and opens a terminal on the host for opencode’s own terminal.

The file tools can only see the mount, so a PreToolUse hook installed for this launch alone (through --settings; your settings files are not touched) refuses them anywhere else, telling Claude to use Bash for that path instead. opencode gets the equivalent from its own external_directory permission, set to deny in the inline config for this launch alone. opencode’s LSP servers, formatters and MCP servers still run here, on the mounted files. Claude Code’s own files — ~/.claude and its temporary directory — stay reachable. Claude is also told, in its system prompt, which host it is on and what runs where.

Hooks and the status line are not Bash commands, and they still run here: cctop hook keeps reporting to the dashboard. The row shows its host as devbox⇄, on the web page too, and its paths are never compared with a local session’s for the ! conflict column — the same path on two machines is two files.

When the agent exits — or cctop is sent SIGTERM or SIGHUP — the mount is unmounted, the directories made for it are removed again, and the connection closed. If cctop is killed outright the two helpers go with it, and a mount left behind (“Transport endpoint is not connected”) is cleared the next time a sandbox starts on that path.

Where the mount goes

At the same path, when it can be. If the host’s /srv/api is an empty directory here, or missing and yours to create, it is mounted at /srv/api. A path then names the same file to the file tools and to the host’s shell, and nothing is translated.

Under cctop’s cache, when it cannot. The usual case is a home directory: the host’s is /home/you-on-the-host, and this machine has no such home and a /home only root can write to. It is also what happens when the path is here already and has files in it (the mount would hide them), is a mount point, or sits inside a WSL Windows drive or a network share. The host’s directory is then mounted at

~/.cache/cctop/remote/<host>/<path on the host>

which needs no sudo and no setup. The place depends only on the host and the path, so it is the same every time: Claude Code keeps its history per working directory, and claude --resume finds yesterday’s session there. A second sandbox in the same directory while the first is running goes to <host>~2/…. The tab says where it mounted.

The directory then has two names — this machine’s (L, under the cache) and the host’s (R) — and cctop translates between them where a name crosses from one machine to the other:

  • A command on its way to the host. The directory it starts in, and every L in its text, become R: the model writes the paths it has seen, and it works in L. L is replaced only where it stands as a whole path or a path’s start — "L/src", --dir=L, PATH=L/bin:… — never inside a longer name. That is safe to do without parsing the shell because of what L is: a long path under ~/.cache/cctop/remote/, which no command contains except to mean this directory.
  • The directory a command ended in, on its way back, becomes L again, so a cd sticks as it would locally.
  • A file tool given the host’s name. The host’s commands print R — pwd, git rev-parse --show-toplevel, a compiler’s errors — and the model then hands that to Read or Edit. The guard hook rewrites the path to L (through updatedInput, so Claude Code still asks permission exactly as it would have for that file) instead of refusing it.

Output is never rewritten: what a command prints is the host’s, byte for byte. No environment variable is sent to the host, so there is none to translate. opencode gets the command half; it has no hook that can rewrite a tool’s input, so an R path in its file tools is refused as outside its directory rather than translated. And R is put in as it is: one with spaces or quotes in it, where the model left L unquoted, changes how the host’s shell splits the command.

Limits worth knowing: Claude Code starts each command in its current directory here before cctop moves it to the host, so a cd outside the mount carries over to the next command only if that directory exists on this machine too; and a command that leaves something running in the background is stopped with the rest of its process group when it returns.

Every user on the machine

Run cctop as root and it reads every user’s sessions, not root’s own — which on most machines is an empty table, since nobody runs an agent as root. This is what htop does with processes, for the same reason: the point of running a monitor privileged is to see the machine rather than your corner of it.

sudo cctop                       # every user's sessions
sudo CCTOP_ALL_USERS=0 cctop     # root's own only, the way any other user sees it
CCTOP_ALL_USERS=1 cctop          # sweep without root, where the homes are readable anyway
CCTOP_HOMES=/export/people/ana:/export/people/bo cctop   # homes discovery cannot find

Homes come from /etc/passwd — root and the login accounts, service accounts skipped — plus whatever sits under /home, which catches users served by LDAP or SSSD rather than the local file. $CCTOP_HOMES names any the machine keeps somewhere else entirely, :-separated as a PATH is. Each home is read for every harness cctop knows — Claude Code and its profiles, Codex, Cursor, Gemini, OpenCode, Pi, Windsurf and Devin — so another user’s session has its cost, tokens and context like your own, not a $0.00 row.

Their running agents are matched to their transcripts the ordinary way, by resume id or working directory, but only ever to their transcripts: a process is tied to a user by its uid, and a session by the home it was read from. Two people with a checkout at the same path — a shared /srv/app — never have one’s agent credited to the other’s session. An agent whose owner has no home cctop can read shows as a row of its own.

Rows gain a USER column naming whose session each one is, your own included. It appears only while more than one user’s sessions are on the table, and goes again when they are not. /user:ana filters the table to that person’s sessions, and the tree view puts a heading per user above the repositories.

Root mode is read-only for everyone else

cctop reads other users’ homes and never writes to them. Its cache, UI preferences, logs, recordings, hooks and shell aliases all go in root’s own home (/root/.cache/cctop and so on), and d on another user’s session is refused rather than deleting their transcript. That holds under a sudo that kept your $HOME or $XDG_* variables, too: root’s home is read from /etc/passwd, an inherited directory pointing into someone else’s home is ignored, and that home is read as theirs, under their name. Stopping an agent (Ctrl-k, confirmed first) is the one action that still reaches another user’s session: it signals a process and touches no file, and stopping a runaway process is what root is for.

The one thing cctop declines to guess is identity — the Account line and the account field in --json are read from your own credentials, so they are left off another user’s row rather than stamped with your email.

Troubleshooting

← back to the README

Start here: cctop doctor

Most of what can go wrong is invisible from outside the process: a CLAUDE_CONFIG_DIR left over from an experiment sending discovery somewhere empty, a pricing table that never downloaded so every session reads $0.00, hooks installed against a binary that has since moved. doctor prints all of it — one line per check, with the fix attached to anything that is not fine.

Session sources
  ✓ Claude Code            26 session(s)
  ! Cursor                 directory exists but holds no sessions (/home/flo/.cursor/projects)
      → if that is wrong, check the environment overrides above

Pricing
  ! LiteLLM table          cached but 49h 35m old
      → the next interactive run refreshes it; costs use the stale rates until then

It covers the version and binary path, any CLAUDE_CONFIG_DIR-style overrides in the environment, each harness’s session directory and how many it found, pricing, whether the parsers are reading what they found (below), the cache and whether it is writable, the hooks report, and which of the three backends behind s this machine actually has.

One harness is missing from that list: Devin has no row in the Session sources section, so a Devin install that is not being read says nothing here. Its directory is ~/.local/share/devin/cli (or the parent of $CHISEL_SESSION_DB), and the count is the sessions that have both a database row and a transcript beside it.

cctop doctor --host devbox adds a section that makes the ssh round trip for real, which is the only honest test of it — and reports ssh’s own words back with the fix that matches them, since a key that needs a passphrase and a hostname that will not resolve need very different answers.

It exits 0 when nothing is broken, 1 for a real fault — an unwritable cache, no pricing at all, a --host that could not be read — and 2 for a bad argument. A warning is something you chose not to set up, so it does not fail the exit code and cctop doctor is usable in a script to mean “is this installation sound”.

The Parsers section

A parser that stops understanding its harness rarely errors. It fails closed: a failure it cannot read counts as a success, a model it cannot price costs $0.00, and both look like good news. Codex sessions reported no tool errors for months that way. No single session shows it, so this section adds up every session cctop can see and flags totals too unlikely to be chance:

Parsers
  ! Codex                  4,812 tool calls and not one failed — the parser is probably not reading failures, so its error rates read 0%
      → a real agent fails a few percent of its calls; if this harness updated recently, report it at https://github.com/flolep2607/cctop/issues
  ! unpriced model         some-new-model (OpenCode): 4 session(s), 12.3M tokens at $0.00
      → no price table lists these, so their cost is missing, not free; LiteLLM's table here is 3h 12m 5s old, and a new model is priced once LiteLLM adds it
  ✓ Claude Code            27 session(s), 6,640 calls, 3.4% failed, 1.6G tokens
  ✓ free model             space-bunny-free (OpenCode): 2 session(s), 80.8M tokens at $0.00, named as free
  • No failures seen. 500 calls or more from a harness that records outcomes, and none failed. Agents fail a few percent of their calls in ordinary use; at even 1%, 500 clean calls in a row happens less than 1% of the time. Pi, Cursor and Windsurf write no outcome, so they are never flagged.
  • Mostly failures. More than half of 500 or more calls failed. That is a parser reading successes as failures, not an agent having a bad week.
  • No calls seen. Twenty or more sessions that used tokens, and not one tool call among them. For Cursor and Windsurf, which record no tokens, every session counts.
  • No tokens seen. Twenty or more sessions that made tool calls, and not a token between them, from a harness that records tokens.
  • Unpriced models. Any model that used 10,000 tokens or more and cost exactly $0.00, sorted by tokens. Each one is reported as one of three things:
    • a free model: LiteLLM lists it at zero, or its name ends in -free or :free. Nothing to fix.
    • an unpriced model: no table has a rate, so its cost is unknown, not zero. A model newer than LiteLLM’s table gets a price once LiteLLM lists it.
    • price not applied: a table has a rate and the session still came to zero. That is a cctop bug. cctop --clear-cache re-prices everything, and if it stays at zero, please report it.

Everything here is a warning, never a failure. These are inferences from your data, not faults in the installation, and the most common one is LiteLLM being a day behind a model release. So they do not change the exit code. The section reads the same cached walk cctop --list does, so it costs about as much as --list.

My sessions are missing

Run cctop doctor first — the Session sources section names every directory it looked in and how many it found in each, which answers this outright most of the time.

The usual causes, in order of how often they turn out to be it:

  • An environment override. CLAUDE_CONFIG_DIR, CODEX_HOME, CURSOR_HOME, GEMINI_DIR, OPENCODE_DATA_DIR, PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR, VIBE_HOME, WINDSURF_USER_DIR and CHISEL_SESSION_DB all move where cctop looks. doctor lists the ones it knows about that are set — everything above but CHISEL_SESSION_DB, which it does not report, so check it yourself with env | grep CHISEL if a Devin install is not showing up. That one is the odd shape of the set: it names Devin’s database file, and the transcripts are read from the directory beside it.
  • The agent has not written a transcript yet. cctop reads what the harnesses leave on disk; a session that has just started may not be there for a moment.
  • A filter is on. Esc clears one layer at a time, and the table’s title says Sessions (6/71) whenever anything is hidden.

Every session costs $0.00

The pricing table did not load. doctor reports this as a failure rather than a warning, because a missing download and a genuinely free plan look identical in the $ column. cctop needs to reach raw.githubusercontent.com once, then caches for 24 hours.

If only some sessions read $0.00, the table loaded and a model in it has no price. The Parsers section of doctor names the model and says whether it is free, missing from the table, or priced and not applied.

s does nothing

Typing into a session needs cctop to reach the pty the agent is reading from, and there are only three ways to do that — see Driving agents. doctor reports which of them this machine has.

The usual fix is cctop --install-alias, then starting agents from a shell as normal.

A --host machine never appears

cctop doctor --host <host> makes the ssh round trip and reports ssh’s own words back. The most common cause is that a non-interactive ssh gets a different PATH than a login shell, so cctop is not found even though it works when you ssh in by hand — name the binary instead:

cctop --host devbox:/usr/local/bin/cctop

A colour is too dark to read, or not the one you expect

cctop’s palettes are written in xterm’s 256-colour table, and some terminals rebuild that table from their sixteen-colour scheme — Tabby’s terminal.paletteGenerate, for one, under which the accent’s pale blue comes out navy and the settings page’s black-on-accent cursor disappears. So on a terminal that says it takes 24-bit colour (COLORTERM=truecolor or 24bit), cctop writes those colours as the RGB xterm gives them, which no regenerated table can change. The sixteen ANSI colours stay your scheme’s, and what an agent prints in its pane reaches your terminal exactly as it printed it.

If your terminal takes truecolor but does not set COLORTERM, set it yourself. If you have themed the 256-colour table on purpose and want cctop to follow it, set CCTOP_INDEXED=1 and the indices go out as they are.

What the cost figures mean

← back to the README

Claude, Codex, and Gemini costs are estimates: tokens multiplied by published per-token rates, taken from built-in tables and falling back to the LiteLLM database (cached for 24 hours). OpenCode and Pi already persist provider-calculated costs, which cctop reads directly — except where the figure is $0.00 against tokens that were spent, which is what both write for a provider they have no rates for rather than a model that was genuinely free; those turns are estimated like the rest.

Gemini records a per-turn token breakdown but no cost. Its cached count is the part of the prompt served from context cache rather than an addition to it, so only the uncached remainder is priced at the full input rate; thinking tokens are priced as output, which is how Google bills them. A Gemini session on a bundled Code Assist tier will still show an estimate — the transcript carries nothing that says which tier it ran on, and the figure is honest about what the tokens would cost at retail.

Cursor native-agent transcripts expose projects, conversation activity, and tool calls. The model, the title, the real folder and the context occupancy come from Cursor’s own state.vscdb when cctop finds it. Tokens and cost are recorded nowhere — not in the transcript, not in the database — so they display as unavailable rather than as free, and there is no dedicated per-session process: live status is read off the transcript’s last turn and how recently it changed.

Windsurf goes further: its workspace state records the conversation and its tool calls but no model, tokens, or cost at all — the credit accounting lives on Codeium’s servers. Those sessions report cost as unavailable rather than as free, and their timestamps come from the workspace database’s mtime, which is the only clock Windsurf leaves behind.

Devin is the third harness with no figure of its own, and the only one that still fills every other column: its ATIF transcript records per-step token metrics and its database records which model ran them, so CTX%, TOOLS, TOKENS and ERR% are all real. Neither store holds a dollar amount, so a Devin row’s cost reads as unavailable rather than as an estimate — the price of its tokens is not something cctop can know, only guess, and a guess printed in the $ column would be indistinguishable from one. --plan max counts Devin as bundled, but that changes nothing on screen: incl stands for a figure cctop computed and then withheld, and a Devin row has no figure to withhold.

Mistral Vibe is estimated: its token totals at Mistral’s published API rates (docs.mistral.ai/inference/pricing), built into cctop because LiteLLM lists Mistral’s models only through resellers and under other names. A model that table does not carry falls back to LiteLLM, and to nothing after that. Vibe’s Python CLI wrote a session_cost of its own, but priced from whatever rates the user’s Vibe config carries, and its newer session store writes none, so cctop uses neither and prices both layouts the same way. Vibe records no time on any message, so a session’s cost counts toward “today” and the month on the day it was last active.

Subscription plans — Claude Max, Pro, Team — are flat-rate or bundle tokens differently, so these numbers will not match your invoice. Treat the $ column as a measure of resource consumption, not as billing. Use --plan max or --plan included to display bundled usage as incl instead.

Today’s model mix

The Overview’s Models row is today’s spend split by model: a short stacked bar, then each model’s name in its segment’s colour with its share of the day. Colours go by rank, not vendor, since the mix worth seeing is usually two Claude models against each other; past the fourth, models share one dim tail so the bar is always the whole of today. Like the Today figure, it counts every session rather than only the ones a filter leaves on the table. The names are placed first and the bar takes what they leave, up to sixteen cells; when that is fewer than eight the bar is left off and the row is the plain list of percentages.

Where each provider’s data comes from

SourcePath
Claude Code (CLI)~/.claude/projects/<slug>/<uuid>.jsonl
Claude for Mac~/Library/Application Support/Claude/{claude-code,local-agent-mode}-sessions/
Codex~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
Cursor~/.cursor/projects/*/agent-transcripts/*/*.jsonl
Gemini CLI~/.gemini/tmp/<project>/chats/session-*.json{,l}
Devin~/.local/share/devin/cli/sessions.db (platform data directory), with transcripts/<id>.json beside it
OpenCode~/.local/share/opencode/opencode*.db (platform data directory)
Pi~/.pi/agent/sessions/**/*.jsonl
Mistral Vibe~/.vibe/logs/session/{session_*,unified/*}/meta.json
Windsurf<Windsurf User dir>/workspaceStorage/*/state.vscdb

CLAUDE_CONFIG_DIR, CODEX_HOME, CURSOR_HOME, GEMINI_DIR, OPENCODE_DATA_DIR, PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR, VIBE_HOME and WINDSURF_USER_DIR are honoured, along with OPENCODE_CONFIG_DIR for the plugin --install-hooks writes. CHISEL_SESSION_DB moves Devin’s, and is the only one that names a file rather than a directory: cctop takes the parent of the database it points at, because the transcript has to sit beside it. Caches live in ~/.cache/cctop/.

Gemini and Windsurf sessions are read from disk but not matched to a running process: neither takes a session id on its command line, so there is nothing to tie a PID to a transcript. Their rows always read as stopped, and the CPU and memory columns stay blank rather than showing another session’s figures.

The left status dot is green while an agent is working, amber after its latest response is waiting for your input, and red when the newest transcript event is an API error. A hollow grey dot is a stopped session, and a filled ◉ is the session that rang in the last 30 seconds.

cctop optimize and cctop compare

← back to the README

The table says what a session cost. These two say what it cost you for.

cctop optimize   # what was spent and not got back
cctop compare    # how each model did on the work you gave it

Both are also o and c in the TUI, drawn over the table, and both take --json, --provider <name> and --since <span> — 24h, 7d, 2w, or a date such as 2026-09-01. Models change, and so does what you give them, so last month’s sessions blur this week’s comparison.

Neither writes anything — not to your configuration, not anywhere. They read transcripts, and optimize reads Claude Code’s configuration to say where something it found is defined, and print.

Whether the work that money bought was kept is a question for the repository, and cctop yield asks it.

Why they are slower than everything else

They re-read every transcript. The individual tool calls, with their arguments, are the thing both commands reason about, and those are never cached — at roughly 31 KB a session they were 83% of a cache that had to be read in full before the first frame. So the table gets a cache that stays small and these get a full parse, which takes a second or two on a large machine.

What optimize looks for

Findings come in three classes, and the class matters more than the wording:

fixSomething to go and do — a setting, a deny rule
habitOnly you can change it
noteWorth knowing. Not a criticism

Each carries what it cost, and whether that figure was measured — counted from tokens the transcript recorded — or estimated from this machine’s own averages. The distinction is not decoration. A measured saving is one you can check; an estimated one is an argument.

What it currently detects:

  • Reads into generated or vendored directories — node_modules, .git, build output
  • The same file read twice inside one session, which is usually a compaction that took it out of the window
  • One file read from scratch by five or more separate sessions — a piece of context the agent needs every time and is told nowhere
  • Sessions that read ten times more than they edited, excluding the ones whose job was to explore
  • Tool calls that failed and were billed anyway
  • Sessions that spent real money and changed no file
  • MCP servers, skills, agents and plugins that Claude Code offered to five or more sessions and nothing ever used — see below
  • What the memory files — every CLAUDE.md, and the auto-memory index — cost by being re-read on every request

Underneath, where the money went by kind of work: coding, debugging, testing, exploration, planning, delegation, git, build, conversation.

A finding has to be worth more than the time it takes to act on. The report once opened with a fix worth $0.0071, ranked above a habit worth a dollar, and both figures were right. Neither was worth having: a dollar is like a few minutes of my time, and a fix is several minutes — read the row, find the settings file, edit it, check it did something. A list whose first row is a losing trade is a list people stop reading.

So a finding must clear $5, or 1% of what these sessions spent, whichever is larger. The share matters because the absolute floor alone misjudges scale: $5 back is worth having on a corpus that spent $40 and invisible on one that spent $5,000, and only the second kind of user is drowning in findings.

What falls below the bar is not silently dropped — it gets one line saying how many findings there were and what they came to, because a report that detected four things and printed none of them is indistinguishable from a broken detector. --json carries the same thing as floor_usd and below_floor, so a script can reach past the bar without reimplementing the detectors.

A finding with no price on it is kept. Unpriced is not the same as small, and filtering on the number alone would drop the findings cctop knows least about while keeping the ones it has measured as trivial. When those are all that is left, the headline says so rather than claiming $0.00 looks recoverable.

The headline only counts what could actually be recovered. A note records what a set of sessions spent, which is an observation and not a saving — an earlier version added them together and advertised $218 of ordinary work as though it were waste.

What a session was given and never used

Before a Claude Code conversation starts, every skill, agent and MCP tool it could reach is described in the prompt, along with each CLAUDE.md that applies. That prefix is cached, and read back from the cache on every request the session makes. Something nobody uses is not free just because it never runs — though it is usually close to free, which is what the value floor is for.

“Never used” is the easiest claim here to get wrong, so each half of it comes from the strictest source there is:

  • Offered is read from the transcript, never from the configuration. Claude Code records the skill listing, the agent listing, the MCP tools it offered and the instructions each server sent, and a session only counts toward a server if its own listing names it. A server switched off, a project .mcp.json nobody approved, or one added yesterday is in no listing from before then — so it is judged only by the sessions that had it, and needs five of them.
  • Used is any way in: a tool call, an MCP resource read, a slash command, the Skill tool, the agent opening the skill’s own SKILL.md, a delegation to the agent.
  • Still there is the configuration as it is now: ~/.claude.json (user and per-project servers, and disabledMcpServers), the project’s .mcp.json and disabledMcpjsonServers in any settings file, enabledPlugins, and the skills and agents directories. Something disabled or removed since is not reported from the transcripts of before. For a claude.ai connector or a synced skill, which live on an account cctop cannot read, the newest session stands in: if it was no longer offered the thing, it has been dealt with.

Each finding is one place to act — a file, a plugin, the connectors — and names it: claude mcp remove, /mcp to switch a server off for the projects that never call it, disabledMcpjsonServers in settings.local.json for a shared .mcp.json (so it stops loading for you without being taken from anyone who clones the project), /plugin for a plugin. A plugin is reported only when nothing it adds was used, since it cannot be removed in part.

Anything cctop cannot trace to a file is never reported: Claude Code’s own built-in skills and agents are in every listing and are not yours to remove. Only names and paths are printed. An MCP entry can hold an API key in env or a token in headers, and no value from a configuration file reaches the output.

The price is estimated, and how is worth saying: the characters each entry put in the window are counted from the transcript, turned into tokens at the same fitted 2.75 characters per token the Context panel uses, multiplied by the session’s requests, and priced at what that session paid per cached token. The first request writes the prefix rather than reading it, at a higher price, so the figure runs a little low.

Memory files are a note, and a price, not an accusation. CLAUDE.md is the cheapest place to tell an agent something — other findings here recommend putting more in it — so calling its cost waste would contradict the rest of the report. The one objective line is Claude Code’s own: it warns about a memory file past 40,000 characters, and a file past that is offered as a habit, with only the part beyond the line counted as a saving.

Limits worth knowing:

  • Claude Code only, and only versions that write these listings. Older transcripts, and every other harness, contribute nothing here.
  • An MCP server whose tools load in full rather than through tool search is listed nowhere in the transcript, so it is never seen and never reported. Its tool definitions are also exactly the expensive case; there is simply no record of them to count.
  • One profile: sessions run under another CLAUDE_CONFIG_DIR are resolved against this one’s files, where their servers usually are not found — and something not found is not reported.
  • A subagent’s own copy of the listings is not counted, so the cost is low by that much again.

What compare measures

Per model, and then per model per kind of work:

filesFiles the model edited; 4+9 adds nine its subagents on another model wrote for it
1-shotShare of its files edited without a retry, with a 95% range: 95%±3
reworkedShare of its files a later session had to fix within a day
redShare of its agents whose last test or build after their final edit failed
$/fileIts cost per file, delegated ones included
time/fileIts working time per file, delegated ones included
cacheShare of input that came from the cache

With --rate 60, a last column prices the time at $60 an hour and adds it to the dollars, and the table ranks by that. A free model that takes a day per file is not cheaper than a $10 one that takes five minutes, and dollars alone say it is. There is no default rate: what an hour of waiting is worth is your number.

A retry is a second attempt after something failed

Editing a file, seeing a command or an edit fail, and editing that same file again is a retry. Editing it again after the tests passed is the next step, and editing a different file is progress — neither counts against the model. Only the same agent’s calls are read, because subagents’ calls are interleaved into the same history and one of theirs failing says nothing about the parent’s attempt.

A harness that records no per-call outcome — Cursor, Pi, Windsurf — has no failure to see, so there any call between two edits of a file makes the second a retry.

The ± is a Wilson interval: 100% on seven files is 100%±18, and two rows whose ranges overlap are not told apart by this data.

Did the work hold

1-shot is judged inside one session. reworked asks the question it cannot: a later session reopening the file to fix it, within a day. A later edit is a fix when it came straight after that session’s own command failed, or when the session is filed as debugging — titles hardly ever say “fix”, so a failing command is the signal that works in any language. The session that wrote the file last is charged, once. Worktrees under .claude/worktrees/ count as their main checkout, or the same file in two worktrees would never match.

red is the other end: the agent stopped with its last test or build failing. Only test and build commands count — a grep that found nothing exits non-zero too — and an agent that checked nothing after its last edit is unknown, not red.

Subagents are credited to their own model

A session that delegates is several models at once. Each subagent is credited to its own model with its own cost, calls and 1-shot rate. Responsibility for the result is shared, though: a parent that briefs a subagent, waits for it and uses what came back did real work towards those files, and paid for it. So the parent’s cost and time are spread over its own files and the delegated ones, while its 1-shot and rework rates stay about its own writing.

The parent’s clock is the session’s end to end, since it was waiting while its subagents worked. A subagent on the parent’s own model lands in the same row and adds nothing twice; one on another model gets its own minutes as well.

It is observational, and it says so

You did not give two models the same work. You gave the expensive one the problems you expected to be hard. A table that ignores that reports the expensive model as worse while measuring nothing but your own routing.

Nothing can fix that from a transcript, so two things make it visible instead: the caveat is printed under every table, and the same figures are broken out per kind of work — most of “this model is worse” turns out to be “this model was given the debugging”.

One agent that switched models mid-way is credited to whichever cost it the most. The transcript records which model billed a request, not which model asked for a given tool call.

What counts as editing

An edit tool — Edit, Write, Codex’s apply_patch — is the obvious case. The less obvious one is a shell command, and missing it was wrong in a way worth recording: a session driven in “do the work through Bash” mode edits with sed -i, a heredoc and a redirect and never touches an edit tool at all. cctop counted no edits, filed those sessions as Testing, and then reported them as having spent money and changed nothing.

So a shell command is read for a write: an in-place editor, tee, a copy or a move, or a redirect whose target names a path rather than a descriptor — 2>&1, >&2 and /dev/null being the three that appear constantly and write nothing worth counting.

This undercounts, deliberately. A python3 - <<PY whose script calls open(p, "w") writes a file that the command line cannot reveal. A missed write leaves a session looking quieter than it was; a false one would accuse somebody of editing a file they only read, and that is the worse mistake.

One consequence to know about: a shell write carries no file name, so it counts toward whether a session changed anything but not toward the one-shot rate, which needs a path. 1-shot, reworked and $/file therefore describe edit-tool work only.

Where the numbers are floors

The per-session tool history is capped, so a session that made more calls than the cap has its oldest ones dropped. Any count derived from it is therefore a floor, and a finding built on a capped session says so on its own line rather than quietly under-reporting.

What is deliberately missing

Applying fixes. optimize tells you what to change and does not change it. Writing to somebody’s ~/.claude/ is a different kind of commitment from reading it, and it should not arrive in the same release as the detectors that decide what to write. The findings come first; automating the ones that turn out to be right can follow.

cctop yield — did the spend ship?

← back to the README

optimize says what a session wasted and compare how a model behaved. Neither can say whether the work was kept. A session that edited forty files, passed its tests and was then thrown away cost exactly as much as one whose work is in production, and only the repository knows which was which. So yield asks it.

cctop yield                   # every session that edited a file
cctop yield --since 7d        # this week's
cctop yield --provider codex  # one harness — every session still competes for commits
cctop yield --json            # every session with its class and its commits

It reads transcripts and runs git log. It fetches nothing and writes nothing.

The classes

shippedA commit of its work is on the default branch, and was not reverted
revertedIts work was committed and a later commit reverted it
unmergedCommitted, but only on branches that never reached the default one
uncommittedIt edited files in a repository and no commit matched it
in progressNothing committed yet, but it was active in the last four hours
not in gitEvery file it edited is outside any repository
unreadableIt edited files in a repository git could not read

Spend is shown per class, then per model and per repository as the share of each row’s own spend that landed in each class. A session with no price — the harness recorded none, or the model is missing from the price table — is counted in sessions and never in dollars: a $0.00 would call it free.

A session that worked in two repositories takes the better of its two outcomes overall, since one shipped repository means the money bought something; the per-repository table splits it by the files it edited in each and shows what happened there. Per model, each agent’s cost goes to its own model, the way compare credits subagents, and every agent shares the session’s outcome — a commit cannot be traced to the subagent that wrote a given line.

Sessions that edited no file by path are left out and counted on their own line. Most are conversation or exploration; some wrote through the shell (sed -i, a heredoc), which names no file and so cannot be matched to a commit.

How a commit finds its session

A clock alone cannot do this. Two sessions open at once in the same repository — most of the time, on a machine running several agents — are equally close to every commit either made. cctop knows which files each session edited, so:

  1. A session’s window runs from its first edit in that repository to four hours after its last activity. Four hours because the commit is often not the agent’s: somebody reads the diff over lunch and commits it. Much longer and the window starts catching the next session’s commits to the same central files.
  2. The candidates for a commit are the sessions whose window contains its author date and that edited at least one file it touches. Author date, not committer date, because a rebase moves the latter.
  3. The candidate that edited the most of the commit’s files wins. A tie goes to the tighter window. No commit is counted for two sessions.

A file in a worktree under .claude/worktrees/ counts as the main checkout’s, and since worktrees share one object store its branch’s commits are already in the main repository’s git log --all. Stashes, notes and merge commits are left out: none of them is somebody’s work on a branch, and a merge’s own diff would let one git merge claim every file on a long branch.

The default branch is origin’s (origin/HEAD, else origin/main or origin/master) together with the local main or master, whichever exist; HEAD only when there is neither. Both, because cctop never fetches — a merged pull request read through a stale origin/main would otherwise look unmerged — and because committing straight to the local main is a decision to keep the work.

A revert is a later commit whose message says This reverts commit <sha>, which is what git revert writes. A revert of that revert puts the work back, and a revert sitting on a side branch does not undo work on the default one.

What it gets wrong

It is a heuristic, and the report says so under every table.

  • Squash and rebase merges over-report unmerged. Both leave the session’s own commits off the default branch and put new ones there. The new commit still matches if it was made inside the session’s window and shares its files — a squash merged the same afternoon usually is — but a pull request squashed a week later reads as unmerged.
  • A stale remote under-reports shipped. Nothing is fetched. A pull request merged on GitHub and never pulled is unmerged here until somebody runs git fetch, unless the local main already has it.
  • Commits made by hand compete like anyone’s. Somebody editing the same files as an agent, inside its window, can hand the agent their commit — the candidate list is sessions, and a person is not one.
  • A worktree outside .claude/worktrees/ is a repository of its own as far as the path walk can tell. Its commits are deduplicated by sha, so none is counted twice, but its files never match the main checkout’s.

cctop burn — what the subscription was paid for and did not use

← back to the README

A rate-limit window is use-it-or-lose-it. Claude gives an account five hours and seven days; Codex gives it five. When one resets, whatever was not spent is gone — nobody is billed for it and nobody mentions it, so the most expensive thing about a subscription is invisible: the weeks that used a third of it.

The Limits pane already answers how full is the window now, which is what decides whether there is room to keep working. This answers the other question: whether the plan was worth buying.

cctop burn
cctop burn --json
  claude/default/7d  (max_20x)
  ▇▅▄▇▃▆  peak of each window, oldest first
  33% went unused on average, across 6 well-observed windows

When there is enough history, the same figure appears on the Limits pane beside the live percentages — ~33% of 7d unused.

Why it takes a while to say anything

The provider reports only how full the window is now. It never says what a window peaked at, and the moment it resets the evidence is gone. So cctop writes the readings down as it sees them, and reconstructs each completed window from the samples taken while it was open.

Two consequences:

  • A window has to reset before it can be reported. A few hours for Codex, a few days for Claude. The window currently running is deliberately left out — calling its unused share “wasted” while there is still time to spend it would be wrong.
  • Nothing is claimed from a single window. One quiet week is a quiet week. The figure appears once there are at least two completed windows to average.

The log lives beside the other caches, holds a rolling year, and thins its older half rather than truncating when it grows — so old history keeps its shape at half the resolution instead of ending at a cliff.

The honest limit

cctop only samples while it is running. A window whose busiest hours happened with cctop closed has a recorded peak below its real one, so usage is understated and the unused share is overstated.

What this reports is therefore an upper bound on what was wasted, not a measurement of it. Two things keep that from being misleading:

  • Every window records how much of its own length was actually observed.
  • A window observed for less than two thirds of its life is left out of the average entirely, and the report says how many were dropped and why. A barely-watched window would drag the figure toward “you used none of it”, which is precisely the wrong direction for a number that might prompt someone to downgrade a plan.

If nothing is well-observed enough, the answer is that there is no figure — never a zero.

Percentages are not dollars

How a provider maps tokens onto a utilization percentage is undocumented and not obviously linear across models. So cctop will say you left 40% of the week’s allowance unused, and will not turn that into “you wasted $80” — that would be an illustration wearing the clothes of a measurement.

The plan name is recorded alongside each sample, because a plan change makes the history either side of it incomparable.

What is not built yet

The charts over hours and days, the browser view, and the standalone HTML report. The sample log carries what they need; nothing reads it that way yet.

What cctop reads, per harness

← back to the README

Nine harnesses, nine pages. Each one says where that harness’s sessions live, what shape they are in, which columns cctop can fill from them, and — the part worth writing down — where the format says something other than what it appears to say.

These are cctop’s own notes, written from the parsers in crates/core/src/session/. They are not a description of the harness: they are a description of what our code believes about it, which is a narrower and more useful thing. Every claim here should be traceable to a function you can open.

ParserWhere its data lives
Claude Codecrates/core/src/session/claude.rs~/.claude/projects/<slug>/<uuid>.jsonl
Codexcrates/core/src/session/codex.rs~/.codex/sessions/**/rollout-*.jsonl
Cursorcrates/core/src/session/cursor.rs~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl
Devincrates/core/src/session/devin.rs~/.local/share/devin/cli/{sessions.db,transcripts/}
Gemini CLIcrates/core/src/session/gemini.rs~/.gemini/tmp/<project>/chats/session-*.json{,l}
Mistral Vibecrates/core/src/session/vibe.rs~/.vibe/logs/session/{session_*,unified/*}/
OpenCodecrates/core/src/session/opencode.rs~/.local/share/opencode/opencode*.db
Picrates/core/src/session/pi.rs~/.pi/agent/sessions/**/*.jsonl
Windsurfcrates/core/src/session/windsurf.rs<Windsurf User>/workspaceStorage/*/state.vscdb

And adding a harness, which is the list of places a tenth one has to be registered before it works — derived from what the nine already touch, not from a design.

Why this is not docs/harnesses/

docs/harnesses/ is a mirror: seven of these nine projects’ own documentation, pulled wholesale by pull.sh and overwritten wholesale on the next pull. Nothing in it is edited by hand, because the next refresh would delete the edit — and its value is precisely that it does not move, so a format change can be read against yesterday’s copy of the upstream prose.

That leaves nowhere to say what we do, so this directory says it: one page per harness, ours, versioned with the parser it describes. The two are meant to be read together — the mirror tells you what the harness says it writes, and these pages tell you what cctop found when it went and read it.

The reason behind each ─

The README’s capability table marks with ─ anything a harness does not record. That table is deliberately terse; the reason lives here.

CostTokensContextToolsLive process
Claude Codeestimated✓✓ full breakdown✓✓
Codexestimated✓✓✓✓
OpenCodereported✓✓✓✓
Pireported✓─✓✓
Gemini CLIestimated✓─✓─
Devin─✓✓✓✓
Cursor──✓✓inferred
Windsurf───✓─

Devin is the odd one out in the other direction: it records everything except the price. Neither its transcript nor its database holds a dollar figure, so its $ is a ─ rather than an estimate — and doctor skips it in the unpriced-model check for the same reason, since a blank is not a price of zero.

Three of those cells are conditional rather than absolute, and the pages say so: OpenCode’s context needs LiteLLM to know the model’s window, since OpenCode records no ceiling of its own; Devin’s is the same case, its num_tokens_preceding without a window of its own; and Windsurf’s tool count is read from a bubble shape nobody has yet confirmed against a live install.

“Estimated” against “reported” is a policy, not a per-harness accident — What the cost figures mean is where that policy lives, and these pages only record how each harness happens to land on one side of it.

A note on the traps

Most of what is on these pages started as a bug. A resume that pointed a live process at a dead transcript, two thirds of Codex’s tool calls rendering with no arguments, an oversized line that silently dropped a turn’s cost. They are written down because the format made each of them look correct, and the next person reading the same field will draw the same conclusion unless the page warns them.

Claude Code

← all harnesses · parser: crates/core/src/session/claude.rs

The harness cctop reads most deeply, and the only one whose transcript answers every column. Everything else on this page is a consequence of that: the context breakdown, the compaction chart and the subagent list exist because Claude Code writes enough down for them, not because they were designed provider-neutral and only implemented once.

Where it lives

$CLAUDE_CONFIG_DIR/projects/<project-slug>/<uuid>.jsonl, defaulting to ~/.claude (config::CLAUDE_CONFIG_DIR). A file is a session only if its stem is a full UUID — config::is_full_uuid in list_sessions — so a stray .jsonl in a project directory is ignored rather than half-parsed.

The project slug is not read for anything. The working directory a row shows comes from the cwd field inside the transcript (collect_static), because the slug is a lossy encoding of a path and the transcript states it outright.

Three things widen the search beyond that one directory:

  • Profiles. $CLAUDE_CONFIG_DIR lets one machine hold several logins, each with its own projects/. cctop reads all of them: any ~/.claude* directory holding a .credentials.json counts as an account (config::PROFILED), and config::profile_for stamps the PROFILE column from the transcript’s path. Reading only the directory the env var named was a real bug — a running session showed as a row with a process and no model, because its transcript was in a profile cctop was not looking at.
  • Other homes. $CCTOP_ALL_USERS and $CCTOP_HOMES add other users’ homes to every root; Session::owner is stamped from the path in list_all.
  • Claude for Mac. ~/Library/Application Support/Claude/ {claude-code,local-agent-mode}-sessions/<account>/<device>/local_*.json is metadata, not a transcript: cliSessionId points at a real Claude Code transcript nested under the session directory’s own .claude/projects, which find_desktop_jsonl locates. An entry flagged isArchived is skipped, and a vmProcessName marks the Cowork surface (the agent runs in a cloud VM, so there is no local process — see the liveness section). The label comes from userSelectedFolders[0], falling back to cwd.

Subagents get their own files: <transcript-stem>/subagents/<agent-id>.jsonl, with an optional <agent-id>.meta.json beside them. transcript_files in session/mod.rs is what makes a session mean “the main file plus its sidechains” everywhere it matters — including the mtime, since a subagent streams into its own file without touching the parent’s.

The format

JSONL, one record per line, type naming the kind: user, assistant, system, attachment, custom-title, ai-title. Discovery reads only the first 50 lines (collect_static) for the model, cwd, start time and launch id, and the last 200 (scan_custom_title) for a /rename, because a transcript can be hundreds of megabytes and the table needs neither.

A session whose first 50 lines name no model is dropped from the table entirely. That is an agent that started and never reached the API, and it has nothing to show.

Billable usage rides on assistant records as message.usage: input_tokens, cache_read_input_tokens, cache_creation_input_tokens, and a cache_creation object splitting the last into ephemeral_5m_input_tokens and ephemeral_1h_input_tokens. Older transcripts carry only the aggregate, so the remainder after the 1h tier is attributed to 5m, which is the default TTL.

Traps

Streaming repeats a request. The same requestId is written many times with growing counts. visit_assistant keeps only the last snapshot per key (last_by_key), and the totals are flushed once the file is read. Summing as you go inflates every figure in the session.

Parallel tool calls are not one record. The API shape suggests one assistant message with several tool_use blocks; Claude Code writes one record per block, all sharing a requestId. So counting blocks inside a record answers “1” for every call in a fan-out. attach_call_details counts across the whole file instead, and stores the answer as ToolDetail::shared — anything that divides a turn’s tokens by that count, or attributes the turn’s window growth to a single call, is wrong for every fan-out without it.

A resume forks. claude --resume X does not reopen X. It writes a new file under a new id which records X as the id it was launched with — the session_id field, as against sessionId, which is the file’s own. That launch id is what Session::launch_id holds, and matching a running process on it is what stops the live agent being attributed to the finished conversation. See proc::resumed_key, whose doc comment is the long version.

An oversized line used to lose its cost. A base64 image or a whole-file Write can push a line past MAX_JSONL_LINE_BYTES, and parsing it into a Value is what blows up memory. extract::slim_oversized re-reads such a line into a small object carrying only usage, so the turn’s tokens survive; a huge user entry, which carries no usage, is still dropped.

Sidechain turns are billed but not in the window. Older transcripts interleave subagent turns into the parent file flagged isSidechain. They cost this session real money, so they count towards cost — but they ran against a different context window, so in_context keeps them out of the breakdown, and last_main_model keeps a subagent’s model from naming the session.

An unknown model is an error, not a zero. Billable usage under a model resolve_claude cannot price sets SessionData::error, which the cache refuses to persist. A fabricated $0.00 would look exactly like a free plan.

Cost

Estimated: tokens times published rates, pricing::resolve_claude with a LiteLLM fallback. The general policy, and why the number will not match your invoice, is in What the cost figures mean.

Context

The only harness with a full breakdown, and the only one where a compaction is visible at all.

total and startup are measured — they are the window figures the API itself reported, startup being the first request of a segment, whose input is the system prompt, tool schemas, CLAUDE.md and skills index with no conversation in front of it. Everything else is estimated from transcript characters at CHARS_PER_TOKEN = 2.75, a constant fitted across 167 local sessions rather than assumed; it is low against the prose rule of thumb because this content is code, JSON and paths. Whatever the parts do not account for is unaccounted, reported signed, because “the categories add up to more than the window” is information rather than an error.

A segment is one stretch from a start or a compaction to the last request that measured itself. Mixing segments is the one way the type can lie without any number being wrong, which is why note_compaction retires the whole segment at once and seals it: a session that compacted and then stopped has no request in its new segment, so the sealed one is reported whole and flagged superseded.

Compactions are recognised two ways — isCompactSummary on the summary entry in newer transcripts, system/compact_boundary in older ones — and both the full extractor and the tail scan look for both, or the column and the panel would disagree about the same file.

The window’s size is resolved in one place, resolve_ctx_max, so nothing can name two ceilings for one window: a [1m] model string, then a pinned model in .claude/settings.local.json, .claude/settings.json, or the config root’s settings.json, then LiteLLM, then 200k. Observed usage outranks all of them — a settings file naming a 200k model must not report a 1M session as 239% full.

Assistant thinking blocks are deliberately not counted: Claude Code writes them with an empty thinking string and only the signature survives, so there is nothing to measure and it lands in the unaccounted gap honestly.

Subagents

Built by build_subagents from the sidechain files plus the parent’s Agent tool_use records. Three cases, all of which have bitten:

  • A transcript still being appended to outranks the parent’s tool_result. A background subagent is acknowledged the moment it starts, so taking that result at face value marked every one of them finished three seconds in. Silence for SUBAGENT_QUIET_MS (30s) is what counts as done.
  • Older agents wrote no toolUseId, so they are paired back to a parent tool_use by description, nearest start time winning.
  • Claude Code purges old subagent transcripts but keeps the parent’s call/result pair, so a row is synthesised from what survives and marked ghost.

Liveness

A per-session process, matched by --resume <uuid> forwarded through the fork, or by working directory when nothing on the command line says. Claude for Mac resumes by window title rather than id, so that is matched separately. Cowork sessions run in a VM and have no local process at all: activity within 90 seconds stands in, and CPU and memory stay blank by design.

The binary is not always called claude — the native installer and the web harness ship it as a version-named executable — so is_claude_binary trusts argv[0] and the install path as well as the process name.

permissionMode is stamped on every user record, so the permission column works without hooks; the newest one wins, since the user can change it mid-session. What the transcript cannot express is an agent blocked on a question: a held permission prompt writes nothing, so the newest record is a tool call in flight, which reads exactly like work. Only the hooks --install-hooks writes into $CLAUDE_CONFIG_DIR/settings.json say that outright.

Codex

← all harnesses · parser: crates/core/src/session/codex.rs

Codex writes a rollout: an append-only log of the events the agent went through, rather than a conversation. Most of the parser’s weight is not in reading it but in one field — exec, which arrives as JavaScript source.

Where it lives

$CODEX_HOME/sessions/, defaulting to ~/.codex (config::CODEX_HOME). Codex files rollouts under YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl, but discovery uses config::rglob(root, ".jsonl") — any depth, any name — so a layout change costs nothing.

Files are sorted by filename, not path, before anything is read: the rollout name carries its timestamp, and sorting by path would order by whose home the file sits in first.

Codex is the second harness with profiles: ~/.codex* directories holding an auth.json are accounts, selected by $CODEX_HOME (config::PROFILED). A subscription runs out of window before the day does, which is the reason both harnesses grew the concept.

The format

JSONL. The records cctop reads:

  • session_meta — payload.id, payload.cwd, payload.timestamp.
  • turn_context — payload.model and payload.effort (or reasoning_effort). The model can change mid-session, so the last one wins.
  • event_msg with payload.type == "token_count" — the usage figures, under payload.info.last_token_usage.
  • function_call / custom_tool_call and their *_output counterparts, either at the top level or wrapped in a response_item.
  • web_search_call.

The session id is the trailing UUID of the filename (config::trailing_uuid), falling back to the first session_meta. That ordering matters: a forked rollout carries a second session_meta naming the session it was forked from, and letting a later one win would give the file another session’s identity.

Traps

exec is JavaScript, not arguments. Codex hands the wrapper a source string — await tools.exec_command({cmd:"ls"}) — so there is no JSON to read. unwrap_exec_all lifts every tools.* call out of it, and quote_bare_keys turns the object literal into JSON; without that, roughly two thirds of Codex tool calls reached the activity pane with no arguments at all, losing the command or the file that makes the line worth reading. bound_json_string resolves the common const patch = "…"; tools.apply_patch(patch) shape, and find_outside_strings skips tools. occurrences inside a quoted patch.

One entry can delegate to several tools — the agent batches them as Promise.all([...]) — and each is a real call, so all of them are recorded. Batched calls share the entry’s call_id, and therefore its measured duration: they ran concurrently, so a common window is the honest reading.

ponytail: the count is of calls written in the source. A runtime fan-out such as ids.map(id => tools.write_stdin({session_id: id})) is one call textually and many at execution, and still counts once.

Tool names are renamed to match everything else. normalise_exec_tool maps exec_command → Bash (with cmd → command), and view_image / read_mcp_resource → Read (with path / uri → file_path). This is what lets one activity renderer serve every harness.

There is no is_error. output_failed reads the sandbox’s own summary line — Script failed, Script error: — and, for the structured form, the JSON-inside-a-string metadata.exit_code. Anything unrecognised counts as success, so an unfamiliar output shape leaves a call unmarked rather than falsely flagged.

input already contains the cached part. last_token_usage.input_tokens is the total; only the uncached remainder bills at the full input rate, which is why extract computes input = input_total - cached_input before pricing.

A rollout with no token_count yet is not an error. It returns tools and a model with no cost, rather than a SessionData::error — unlike Claude, where billable usage under an unknown model is a hard failure.

A multi-file apply_patch only contributes its first file to the recent- writes list, because the summary is first.rs (+3 more) and the rest are not recoverable from it. That is a documented limit in session::distil_recent_writes, and it means the collision warning can miss a file a Codex session is holding.

Cost

Estimated. pricing::resolve_codex — a built-in table, LiteLLM behind it — and the resolved per-million rates are carried on SessionData::rates so the Cost panel can show what it charged rather than only the total. Policy in What the cost figures mean.

Context

From the same token_count event: info.last_token_usage.input_tokens for what is used, info.model_context_window for the ceiling, falling back to config::CODEX_DEFAULT_CTX (258,400). Codex is the only harness that states its own window size, which is why nothing here has to consult a table.

No compaction accounting: the rollout does not say when the window was reclaimed, so compacted is always false and the CTX% chart has no markers.

Liveness

The hardest of the eight, and the only one where cctop deliberately refuses to guess. codex resume <uuid> matches directly. But the app-server — which is what actually runs most sessions — carries no rollout UUID on its command line, so an unmatched Codex PID falls through to the working-directory match, walking ancestors to find one: the worker is often a child of codex-linux-sandbox and has no useful cwd, while the parent carries the managed workspace in --command-cwd or --sandbox-policy-cwd.

If that still finds nothing, the PID is dropped rather than turned into a row. Every other provider gets a synthetic “running, no transcript” row for an unmatched process; Codex does not, because a manufactured rollout-less session is worse than a missing one (proc.rs, in the fallback loop).

Codex is also the harness with two hook surfaces: $CODEX_HOME/hooks.json says everything, and the notify program in config.toml says only that a turn ended but works the moment it is written. --install-hooks writes both for the user scope.

Cursor

← all harnesses · parser: crates/core/src/session/cursor.rs

Cursor’s native agent exports a transcript with the conversation in it and none of the accounting. The editor’s own database adds the title, folder, model and context; tokens and cost are recorded nowhere, and the parser’s module doc states the rule that keeps them blank: unavailable rather than estimated from incomplete evidence.

Where it lives

$CURSOR_HOME/projects/<project>/agent-transcripts/<id>/<id>.jsonl, defaulting to ~/.cursor (config::CURSOR_HOME). Only each project’s agent-transcripts directory is walked — the rest of a project is mcps/ and canvases/ with whole node_modules trees — and the file stem must be a full UUID.

A subagent is not a session. Its transcript sits at <id>/subagents/<sub>.jsonl and is folded into its parent (see below). Cursor sometimes keeps a top-level copy of a subagent too, as <sub>/<sub>.jsonl; any id seen under a subagents/ directory is never a row.

The same transcript can exist under two project slugs. Cursor retains a copy when a project is moved or reopened elsewhere. The UUID stays the stable identity, so list_sessions sorts by activity and keeps the first copy of each id — newest wins.

The format

JSONL of message records whose content is an array of blocks. cctop reads exactly one kind: blocks with type: "tool_use", for their name and input. Nothing else in the file is parsed.

There are no per-event timestamps, so tool details are pushed with an empty ts and the Tools panel cannot order or time them. The session’s own times come from the filesystem: created for the start (falling back to mtime, since not every platform records a creation time) and mtime for last activity.

The label is a project slug unless the database knows the folder. project_slug takes the directory name under the projects root, which turns / and _ alike into - and so is not a path. When the editor’s database records the chat’s workspace folder and that folder exists here, the row carries the real path instead (see below).

The editor’s database

Cursor’s global state.vscdb keeps, under the transcript’s own id, what the transcript leaves out. It lives in $CURSOR_USER_DIR/globalStorage/ (default ~/.config/Cursor/User) and, under WSL, in each /mnt/c/Users/*/AppData/Roaming/Cursor/User/ — Cursor runs on Windows and its agent writes transcripts on the Linux side. config::cursor_global_dbs finds them once per process.

  • At list time, one query over composerHeaders gives each row its title, start time and workspace folder.
  • At extraction, a point lookup of composerData:<id> in cursorDiskKV gives the model (Cursor’s Auto router, default, shows as auto and never reaches pricing), the reasoning effort, and each subagent’s type.
  • For the context column, the same key’s promptTokenBreakdown: Cursor’s own count of the prompt it last sent against the window it allows.

On a Windows drive the database is opened immutable=1: a plain read-only open over 9P creates -shm/-wal files in Cursor’s directory and takes locks the Windows writer cannot see. Immutable writes nothing, but reads only what Cursor has checkpointed, so a live chat’s model or context can trail by a checkpoint. Every failure — no database, an older Cursor without composerHeaders, a read torn by a checkpoint — leaves the row as the transcript alone makes it.

Subagents

<id>/subagents/*.jsonl are the session’s subagents: their calls count toward the session’s tools and edits, each one is listed with its own tool count, and their writes keep the parent live. Their type comes from the database; their model and cost are not recorded, so they read ? and —.

What cannot be extracted

No tokens, no cost, and no per-call outcome — the database records none either: every message’s tokenCount is zero and every usageData empty. summarize sets cost_available = false and total_cost = None, so the money columns read as unavailable rather than as $0.00 — the two mean very different things, and a zero would be a claim. Provider::records_tool_outcomes is false, so ERR% is ─ rather than a reassuring 0%.

Tool counts are the transcript’s real signal, which is why the capability table’s Tools column is a ✓.

Liveness

Cursor runs one shared editor process, not one process per agent transcript, so there is nothing to attribute a PID to. loader::attach_processes infers liveness instead: a transcript whose mtime is within 90 seconds is running. That is why the README’s table says inferred rather than ✓, and why the CPU and memory columns stay blank — showing the editor’s own figures on a session row would attribute one agent’s cost to all of them.

What a row is doing comes from the transcript’s tail. Cursor ends every turn with {"type":"turn_ended","status":…}, so a finished turn reads as waiting for you; status: "error" is an API error unless its text says the user aborted or cancelled, which is how Cursor files a Stop. An AskQuestion call reads as a question waiting on you.

There is no resume command either: Session::resume_argv returns None, because the conversation lives inside the editor and no CLI invocation picks one back up. Callers show the transcript path instead of inventing a flag.

What cctop can still do

--install-hooks writes ~/.cursor/hooks.json (a flat shape, unlike Claude’s and Gemini’s nested one), so a live Cursor session can report the states its transcript cannot express. And access.rs lists the project’s own .cursorrules-style file with a note saying the rest — models, settings — lives in the editor UI where nothing can read it.

Devin

← all harnesses · parser: crates/core/src/session/devin.rs

The only harness here that keeps its conversation in two places: a SQLite database the CLI is working from, and a transcript it writes beside it for other tools to read. Neither half is complete, so most of what is on this page is about which of the two a given field comes out of — and about the one number Devin records nowhere, which is the price.

Where it lives

~/.local/share/devin/cli/ — the platform data directory plus devin/cli (config::DEVIN_CLI_DIR) — holding sessions.db and transcripts/<id>.json beside it.

$CHISEL_SESSION_DB overrides it, and it is the one override that names a file rather than a directory: cctop takes the parent of the database it points at as the CLI directory, because the transcript has to sit beside the database it belongs to (see db_for). Pointing it at a database somewhere else therefore moves the transcripts with it, which is the only way that override can be right.

Every other home’s copy is found by convention, through devin_cli_dirs(). Only this user’s own database honours the variable; another user’s is always ~/…/devin/cli/sessions.db, since an override cannot speak for a home it is not in.

The configuration is somewhere else entirely. ~/.config/devin (DEVIN_CONFIG_DIR) holds AGENTS.md, config.json, mcp_config.json and skills, none of which is next to the sessions. access.rs spent a while looking in the data directory for a config.toml that has never existed there, so the two are separate constants on purpose and the Access panel reads the config one.

The format

The database, opened read-only (SQLITE_OPEN_READ_ONLY | NO_MUTEX):

TableWhat cctop takes
sessionsid, working_directory, model, created_at, last_activity_at, title, hidden, and agent_mode for the permission column
message_nodeschat_message (a JSON blob per node: role, tool_calls) and metadata (num_tokens_preceding), newest by row_id
tool_call_statetool_call_json and tool_call_update_json per call: the arguments, and the status each update reported

The transcript is one ATIF-v1.7 JSON document per session — not JSONL, and not a database. schema_version, session_id, agent (with model_name and the tool definitions, which are the toolset and not its usage), then steps: each with source (system, user or agent), timestamp, model_name, metrics, and tool_calls. Totals live at the end under final_metrics.

The traps

The two stores answer different questions. The transcript is the artefact other tools are meant to read; the database is the live state. Which is why the tool outcomes are only in the second: a step records that a call was made, and tool_call_state records how it ended. tool_statuses joins them on tool_call_id, and a call with no row in it counts as not failed rather than as unknown — a still-running call is not a failure, and guessing either way would put a wrong number in ERR%.

The transcript is one document, so nothing can tail it. Every other JSONL harness here answers “what is it doing” by reading the last 64 KB; a single pretty-printed JSON file has no records to walk, and a seek into its middle lands in the middle of a string. So live_state, extract_last_tool and extract_context all read the database instead, and session::live_state hands Devin over before the tail walk starts. The conversation reader reads the whole document for the same reason, through its own read_devin rather than extract::for_each_jsonl.

One model, two spellings, in one file. Most steps record the slug the API was called with (swe-2-high); a few carry the display name the database knows (SWE-2 High). Bucketing on the raw string splits one model into two rows in the Cost panel, so canonical_model folds to letters and digits lowercased and the slug is the spelling shown — it is what the pricing tables key on.

agent steps only. system and user steps carry no metrics and no tool calls; counting from them would double the tokens against final_metrics.

created_at is seconds, not milliseconds. Every other harness stamps milliseconds, so ms_to_rfc3339(created_at * 1000) is the whole conversion. Read as-is, every Devin session is dated 1970.

ATIF keeps no structured patch. An edit or write diff lives in the call’s arguments, so the report’s Changes view has to be built from extract::edit_delta rather than read off the step.

Cost

There is none. Neither the transcript nor the database records a dollar figure, so list_sessions sets cost_available = false and total_cost = None, and the $, $/1H and $/24H columns read ─. Tokens are a different question and are recorded: Provider::records_token_usage is true for Devin, which is why doctor’s unpriced-model check skips it — a Devin $0.00 is a blank, not a price.

--plan max counts Devin as bundled (Plan::includes), alongside Claude. On screen that changes nothing: incl stands for a figure cctop computed and then withheld, and cost_available = false is tested before it, so a Devin row reads ─ under every plan.

Context

message_nodes.metadata.num_tokens_preceding on the newest node is what the next request would carry in. The ceiling is LiteLLM’s max_input_tokens for the model, or nothing — the same qualification as OpenCode’s, and a model LiteLLM has never heard of shows a blank CTX%. compacted is always false: ATIF records no compaction, so the CTX% chart has no markers.

Tokens

final_metrics is the session’s ledger — total_prompt_tokens, total_completion_tokens, total_cached_tokens. The per-step metrics are what fill the hourly and daily buckets, so a session whose steps carry no timestamp contributes to the total and to no window, rather than to an invented one.

What the format does not settle: whether total_cached_tokens is part of total_prompt_tokens or additional to it. cctop keeps them in separate buckets and prices them separately, on the reading that the cached part of a prompt is billed cheaper rather than twice. A real transcript is the only thing that can settle it.

Liveness

devin acp is the agent backend, spawned per session by the CLI and by editor integrations; proc::could_be_agent recognises the binary, and provider_of requires the acp subcommand so that devin auth, devin mcp and the rest never become a phantom session row. devin --resume <id> puts the id on the command line, and Session::resume_argv is that command — so this is one of the harnesses with a real process and real CPU and memory columns.

agent_mode on the session row answers the permission column, translated into the four buckets: normal and auto are ask, accept-edits is edits, plan is plan, and dangerous/yolo/bypass/autonomous are BYPASS. smart — edits auto-approved plus a fast model running clearly-safe actions — has no honest bucket, so it reports nothing rather than picking one.

cctop installs no hooks into Devin: it is not among hook::HARNESSES, and Devin’s own hooks.v1.json is a shape cctop hook does not speak. Everything on a Devin row is forensic, read out of what the CLI left behind.

What else it can reach

The conversation reader works (serve/chat.rs::read_devin), because ATIF keeps a call and its result together on one step under observation.results, keyed by source_call_id. Two Devin-specific details in it: system steps are mostly prompt assembly — the sysprompt and rules telemetry sources, plus <available_skills> and <system_info> blocks re-injected each turn — and only a tagged block reporting an event is shown as a turn; and step_id is a number, not a string, so two consecutive steps stay two replies.

delete is the one destructive path that writes to the harness’s own store. It sets hidden = 1 and removes the transcript, rather than deleting the row: the live CLI holds the database open, and hiding is what the CLI itself does.

Gemini CLI

← all harnesses · parser: crates/core/src/session/gemini.rs

Gemini records a per-turn token breakdown and never a cost, so its money column is inferred from published rates. Its chats also come in two on-disk shapes depending on the release that wrote them, and the parser reads both as one.

Where it lives

$GEMINI_DIR/tmp/<project>/chats/session-*.json{,l}, defaulting to ~/.gemini (config::GEMINI_HOME). The tmp/ reads like something disposable; it is where the transcripts actually live.

Discovery descends exactly two levels — project, then chats/ — rather than globbing. The sibling tool-outputs/ holds a file per tool call and dwarfs the transcripts beside it, so a recursive walk would spend most of its time in a directory with nothing in it cctop wants.

The working directory comes from a .project_root file written beside chats/. Older subtrees are named for a hash of that path with no marker to undo it; those keep an empty label, because a hash rendered in the LABEL column looks like a path and is not one.

Identity, and why it is the filename

Session::session_id is the filename stem, not the sessionId inside the file. Resuming a chat opens a new file under the same sessionId, and the segments are disjoint rather than cumulative — so keying rows on that id would collapse them onto each other and lose every earlier segment’s usage. The filename is unique and is what Gemini itself indexes by.

The start time is parsed straight out of that stem (session-2026-05-14T17-34-79709c93 → minute precision), which is all the table sorts on. Reading it from the file instead would mean deserialising a multi-megabyte JSON conversation during discovery, since the single-object shape has no header to stop at.

The two formats

  • Older: one JSON object holding the whole conversation, with a messages array.
  • Newer: JSONL — a header line, one line per message, and {"$set": …} patches revising header fields in place.

for_each_record unwraps $set patches and hands them over as plain objects. Every field a patch revises is one the header already declares, so a caller that simply takes the last value it sees ends up with the same header the JSON form states outright.

Tokens, verified

A gemini record’s tokens object carries input, cached, output, thoughts, tool and total. Checked against 605 recorded turns:

  • total equals input + output + thoughts + tool, exactly, and
  • cached is never among the addends — it is the part of input that was served from context cache, not an addition to it.

So the full-rate input is the uncached remainder, which is the same split Codex reports. thoughts is billed as output, which is how Google bills it and is the larger half of a reasoning turn on Gemini 3. The tool bucket has no rate of its own and reads zero on every turn seen so far, so it is left inside total rather than folded into a bucket that would be charged.

Cost

Estimated, through pricing::resolve_codex. The name is Codex’s; the shape — flat input, cached input, output, no cache-write tier — is Gemini’s billing model too, and a duplicate resolver under a different name would buy nothing.

A session on a bundled Code Assist tier still shows an estimate: nothing in the transcript says which tier it ran on. See What the cost figures mean.

What cannot be extracted

Context is ─. There is no extract_context for Gemini — loader::read_tail returns None for it. The transcript records what a turn consumed but not how full the window was afterwards, and the two are not the same number.

No live process. Gemini takes no session id on its command line, so there is nothing to tie a PID to a chat file; Gemini is not among the providers proc::could_be_agent recognises at all. Rows always read as stopped and the CPU and memory columns stay blank rather than borrowing another session’s figures. Session::resume_argv is None for the same reason.

Tool outcomes are recorded — a call’s status == "error" — so ERR% is real here, unlike Pi’s.

The title is the summary field, which is what Gemini’s own update_topic tool writes; it is the closest thing to a session name the format has.

--install-hooks writes $GEMINI_DIR/settings.json in the same nested shape Claude uses, so a live Gemini session can report the states its file cannot.

Mistral Vibe

← all harnesses · parser: crates/core/src/session/vibe.rs

Vibe has written its sessions two ways, and one home can hold both: the Python CLI’s plain directory, and the newer store the Rust frontend’s harness keeps, which is a content-addressed snapshot plus a journal. Most of this page is about the second, because what it records is good and where it records it is not obvious.

Where it lives

~/.vibe/logs/session/ (config::VIBE_SESSIONS_ROOT). $VIBE_HOME moves the whole ~/.vibe, which is Vibe’s own variable for it, and other homes are found through vibe_sessions_roots().

Configuration sits in the same tree: ~/.vibe/config.toml (with a project’s .vibe/config.toml over it), ~/.vibe/skills/, and a project’s AGENTS.md.

The format

session_<date>_<time>_<id8>/, from the Python CLI:

FileWhat cctop takes
meta.jsonsession_id, start_time, end_time, environment.working_directory, title, config.active_model, and stats: session_prompt_tokens, session_completion_tokens, tool_calls_failed, context_tokens
messages.jsonlthe conversation in OpenAI chat shape: string content, reasoning_content, tool_calls whose arguments are a JSON document in a string

unified/<id>/, from the newer store (store_format mistral.vibe.unified-session-store/v1):

FileWhat cctop takes
meta.jsonthe same header as above, without stats or config
CURRENTwhich generation is the snapshot
generations/<n>/manifest.jsonthe chunk ids that make up the snapshot’s checkpoint and projection_state
chunks/<sha256>.jsonarrays of items. The checkpoint’s are the model’s context (message.role, typed content parts, a tool message’s outcome); the projection’s are the UI’s history, where a model_change entry names the model
generations/<n>/runtime-state.jsonsession_metadata.active_model, when one was chosen for the session
generations/<n>/projection-state.jsonthe snapshot’s envelope: session.status, tokenUsage, contextUsage
journal/<seq>.jsonlevery record since the snapshot, one per line

Every session’s data_file is its meta.json, in both layouts: it is the one file both have, and the directory around it is the session.

The traps

The snapshot is only written when a turn ends. During a turn the journal is the only live copy, and the journal carries a fresh set_envelope (status, token totals, context use) on every step. So tokens and state come from the newest envelope in the journal’s tail, falling back to the snapshot’s.

A journal line can be bigger than the tail that is read. Each model call’s action_intent record embeds the entire prompt, so a segment runs to megabytes and a 256 KB tail can start in the middle of one. Records are written with sorted keys, which puts type last, so record_type reads it off the end of a line that cannot be parsed. That is how a model call still in flight reads as working even when the envelope before it is out of reach.

A question leaves a record behind. An approval prompt is a callback_registered, answered by a callback_resolved with the same callback_id. One with no answer after it is a held prompt, and the row says Asking, which a transcript of any other harness here cannot tell apart from working.

The model is recorded only when it changes. A session that never switched model has no model anywhere in its store, so it falls back to active_model in config.toml, the default a fresh session starts on. Discovery leaves the unified layout’s model empty rather than read the snapshot for every session on every walk; extraction finds it, and the loader copies it onto the row.

Tool counts come from the checkpoint, so compaction loses calls. The checkpoint is the model’s context, and a compaction replaces the calls it summarises. The projection’s history is no better (it keeps a window, not the session), and the journal reaches back only to the last snapshot. A tool message whose outcome is failure is an error; the Python layout records outcomes only as stats.tool_calls_failed, so its per-call detail never says failed.

The Python layout cannot say what was cached. Its stats count prompt tokens without the cached part, so its sessions price every input token at the full rate and read high, where the unified store’s cachedInputTokens is billed at Mistral’s cached rate.

Every step creates files. Chunks, journal segments and generations, each one a create. The watcher would hold a re-walk open for each of them, so everything inside a session directory except its meta.json is noise to it (is_vibe_noise), as Gemini’s tool-outputs/ is.

Columns

ColumnSource
$estimated: the tokens at Mistral’s published rates (MISTRAL_TABLE in pricing.rs, from docs.mistral.ai/inference/pricing), cached input at the cached rate, else LiteLLM’s. The Python CLI’s session_cost is ignored: it is priced from the user’s own config, and the unified store records none
Tokensthe envelope’s tokenUsage (input with its cached part inside it), or stats
CTX%contextUsage.totalTokens (or stats.context_tokens) against LiteLLM’s window for the model; none for a model LiteLLM does not know
Tools / ERR%checkpoint tool_call parts and tool outcomes; messages.jsonl tool_calls and stats.tool_calls_failed
Statejournal: unanswered callback → asking, a model call newer than every envelope → working, else the envelope’s status: idle → waiting, running → working. Python layout: the last message
Processvibe-rs, the frontend the vibe launcher execs into, matched by working directory or --resume <id>
Resumevibe --resume <id>

What is not read

  • Permission mode. Vibe’s is the agent a session started with (--agent auto-approve, --yolo), which the store does not record.
  • Hooks. cctop hook does not speak Vibe’s hook dialect, so nothing is installed.
  • MCP servers. config.toml lists them as an array of tables ([[mcp_servers]]), which the Codex-shaped TOML reader does not take.
  • Timestamps per message. Neither layout stamps one, and usage is only ever a running session total, so a session’s whole cost and tokens are filed under the moment it was last active. That is exact for a session inside one day, which is nearly all of them; one that spans midnight counts entirely on its last day, and resuming an old session moves its whole cost to “today” and “last 60 min”. Tool details carry no time.
  • The Python-only CLI’s process. It runs as python, which proc does not descend into; its sessions are listed, but never as running.
  • Two sessions in one directory. Both match the newest by working directory. active/<id>.lock.json names each session’s app-server pid, which would attribute them exactly.
  • The turn in flight in the conversation view. The reader uses the checkpoint, so the unified layout’s conversation ends at the last finished turn.

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:

TableWhat cctop takes
sessionid, directory, title, model (JSON, .id inside), time_created, time_updated, and aggregate cost / tokens_* columns
messagedata, a JSON blob per message: role, modelID, tokens{input,output,cache{read,write},reasoning}, cost, time.created
partdata, 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.

Pi

← all harnesses · parser: crates/core/src/session/pi.rs

The simplest of the eight to read, and the one that records its own costs most completely. What it does not record is anything about the context window or the outcome of a tool call, and those two gaps are the whole of its ─ column.

Where it lives

$PI_CODING_AGENT_SESSION_DIR, or $PI_CODING_AGENT_DIR/sessions, or ~/.pi/agent/sessions — checked in that order (config::PI_SESSIONS_ROOT). Two variables rather than one because Pi lets the session store move independently of the agent directory. Everything below the root is swept with rglob(root, ".jsonl").

The format

JSONL, with three record types cctop reads:

  • session — id, cwd, timestamp. Both the id and the start time come from here, and a file without one is skipped entirely.
  • session_info — name, which becomes the row’s title.
  • message — message.role, message.model, message.timestamp (epoch milliseconds), message.usage, and message.content[] blocks of type toolCall.

usage carries input, output, cacheRead, cacheWrite, cacheWrite1h, reasoning, totalTokens, and a nested cost object with input, output, cacheRead, cacheWrite and total. The 1h cache-write figure is a subset of cacheWrite, so the 5m bucket is the remainder — .min(cache_write) guards against a file that says otherwise.

Discovery parses the whole file, unlike every other JSONL harness here, which reads a bounded head and tail. There is no header record that carries the last-activity time, so the last message’s timestamp has to be found by reading to the end. It is worth knowing if Pi sessions ever get long: this is the one discovery path whose cost grows with transcript size.

Cost

Reported, and preserved exactly rather than re-derived from the model id. The one exception is a turn Pi priced at nothing while reporting tokens — what a provider it has no rates for produces — which is priced from LiteLLM instead, since reporting real spend as free hides it. Same rule as OpenCode’s, same FallbackRates helper. See What the cost figures mean.

What cannot be extracted

Context is ─. loader::read_tail has no context reader for Pi. The transcript records what each turn consumed but never how full the window stood afterwards, and there is no ceiling recorded either.

Tool outcomes are ─. Pi records the call — name, arguments, id — and nothing about how it ended. Provider::records_tool_outcomes is false for Pi precisely so ERR% reads as unavailable: a transcript with no failures in it and a transcript that cannot express one look identical, and drawing 0% would put a reassuring figure on a row that earned nothing of the sort.

Liveness

pi --session <id> puts the id on the command line, so the match is exact (proc::session_value), and node running a script whose stem is pi counts as well. pi --session <id> is also the resume command.

Waiting-for-input is read off the tail like the file harnesses: a message record whose content holds a toolCall named for an input request (session::is_input_request_tool).

cctop installs no hooks into Pi — it is not one of hook::HARNESSES — so everything on a Pi row is forensic.

Windsurf

← all harnesses · parser: crates/core/src/session/windsurf.rs

The least legible of the eight, and the only page here with a standing caveat on its own accuracy. Windsurf is a VS Code fork, so its conversations live inside the editor’s per-workspace settings database, and its accounting lives on Codeium’s servers where nothing local can see it.

Where it lives

$WINDSURF_USER_DIR/workspaceStorage/<hash>/state.vscdb. Without the override, the User directory follows the platform: ~/Library/Application Support/Windsurf/User on macOS, %APPDATA%\Windsurf\User on Windows, and the XDG config directory elsewhere. $WINDSURF_USER_DIR names the whole User directory rather than a parent, which is what a portable install actually moves.

The working directory comes from workspace.json beside the database, where VS Code records it as a file:// URI; it is percent-decoded by hand, because undoing %XX is the entire job and a URI crate would be a dependency for six lines. A remote or virtual workspace has no local path and keeps an empty label.

The format

SQLite, opened read-only. ItemTable maps a settings key to a JSON blob, and Cascade parks its conversations in one of those blobs as a list of tabs, each holding the bubbles of one conversation. One row per tab: tabId for the id (string or number, both accepted), chatTitle for the title.

The stored value is taken as raw bytes, Text or Blob alike: VS Code writes some ItemTable entries as one and some as the other, and asking rusqlite for a concrete type fails outright on the wrong one.

The caveat

ponytail: the parser is written against the documented ItemTable layout rather than a Windsurf install, so the settings-key list and the bubble field names are the part that could be wrong.

Four keys are tried in order — cascade.chatdata, workbench.panel.aichat.view.aichat.chatdata, aiChat.chatdata, chat.data — and the first that parses into tabs wins. Tool calls are read from either toolCalls or tool_calls, with a name under either name or toolName.

Both fail closed: an unrecognised key yields no rows, an unrecognised bubble yields no tool calls. So a wrong guess costs visibility and never produces a wrong number. If you can read the real key off a live install, add it to CHAT_DATA_KEYS. Until then, the README’s ✓ in Windsurf’s Tools column is provisional in a way the other seven are not.

What cannot be extracted

No model, no tokens, no cost, no per-message timestamp, no tool arguments, no per-call outcome. Windsurf bills in credits server-side and keeps none of it locally, so list_sessions sets cost_available = false and total_cost = None: these sessions report cost as unavailable rather than as free. Tool details carry the tool’s name and nothing else, because a fabricated argument line would read as though the blob had recorded one.

Both timestamps are the database’s mtime. Windsurf stamps neither a start nor an end on a conversation, so every tab in a workspace shares the file’s time. That is honest about ordering — the most recently used workspace really did change last — and deliberately says nothing else.

The consequence reaches the cache: cache::disk_key returns None for Windsurf, because there is no per-conversation stamp to build an honest key from, so its rows are never persisted and re-extract together whenever the workspace file moves. That is cheap, since the blob is small.

What cctop will not do

delete returns Unsupported with a message pointing at the editor. Removing one conversation means rewriting a settings blob inside a live editor’s own database, which is not a thing to do behind a running Windsurf — and reporting the refusal beats returning success and leaving the row on screen.

There is no resume command, no process to match (Windsurf’s agent is the editor itself), and no hooks: access.rs lists a project’s .windsurfrules and notes that the global rules live in the settings UI, where nothing can read them.

Telemetry

cctop sends a small usage report at most once a day to https://cctop-releases.ecorsiste.workers.dev/telemetry. It is on by default, and nothing is sent until a one-time notice has been shown on an interactive run. cctop hook and cctop yolo-hook never send anything. A report holds only:

  • id: 128 random bits, made at the first report; cctop telemetry reset-id replaces it.
  • version, os, arch: cctop’s version, and e.g. linux, x86_64.
  • features: how many times each of tui, serve, workspace, tunnel, yolo and update ran since the last report.
  • agents: the most agents of each kind seen running at once.
  • crash, only if cctop panicked: the file and line in cctop’s source, and the panic message with paths, URLs, emails, user and host names, IPs and long tokens removed, cut to 200 characters.

It never sends prompts, session names, working directories, account names, hostnames or IP addresses. cctop telemetry status shows the next report. To turn it off, any one of: cctop telemetry off (also deletes the ID and the counts), telemetry = false under [settings] in config.toml or the toggle in settings (,), or CCTOP_TELEMETRY=0 / DO_NOT_TRACK=1.

Contributing to cctop

Thanks for helping improve cctop. Please keep changes focused and include tests when behaviour changes.

Development

Build and run the test suite with Rust 1.89 or newer (the code uses let-chains and std’s file locks):

cargo test
cargo clippy --all-targets

Before opening a pull request, run the whole gate. CI sets RUSTFLAGS: -D warnings, so clippy output that looks advisory locally is a build failure there:

# -D warnings is set in .cargo/config.toml
cargo fmt --all --check
cargo clippy --all-targets
cargo test
cargo publish --dry-run --workspace --allow-dirty   # what `verify / package` runs

A build cache that works across worktrees: kache

Agents each work in a worktree of their own, with a target/ of their own, so without a shared cache every one of them starts by compiling all ~540 crates. kache is the rustc wrapper that shares them, locally as it does in CI (kache-action in .github/workflows/verify.yml):

# a release binary from github.com/kunobi-ninja/kache/releases, or:
cargo install kache --locked
kache init --no-shell --no-service   # writes rustc-wrapper = "kache" to ~/.cargo/config.toml
kache doctor

It is set in the user-level ~/.cargo/config.toml, never in this repository’s .cargo/config.toml: there it would break the build for anyone without kache installed. kache stats --last-build --root <checkout> says what one build restored, and kache explain <crate> why a crate missed.

Why not sccache, which this machine used before: its Rust cache key hashes the compile’s command line raw, and that line carries the target directory (--out-dir, -L dependency=…/target/debug/deps, the --extern paths). A worktree’s target/ is at another path, so nothing one worktree compiled ever matched in the next — the same memchr built twice under two CARGO_TARGET_DIRs is a miss, under the same one a hit, whichever worktree runs it. sccache’s basedirs rewrites C/C++ paths only, not Rust’s. kache replaces the workspace, target and home directories with placeholders before hashing, so a second worktree restores the first one’s output.

Measured on 2026-10-09 (6 cores, load 5 to 12 from another agent building at the same time), cargo test --no-run on the same commit:

wall timefrom cache
first worktree, empty kache store211 s0 of 544 crates
second, fresh worktree79 s426 of 434 crates

What still compiles in a second worktree is cctop’s own: cctop-core’s build script reruns for a new checkout on purpose (see CCTOP_CHECKOUT in crates/core/build.rs), and test binaries that embed CARGO_MANIFEST_DIR differ per checkout. Everything from crates.io is restored.

Build --release only when the change is about the release build: it is a second, separate set of every crate, so it doubles the cold build and the cache’s size for something cargo test does not run.

While iterating, run the tests that could have broken

cargo test --workspace --all-targets is about 1570 tests, and on a warm build it takes about 5 seconds of wall time for 6 seconds of CPU (6 cores, already loaded to about 4 by other lanes, median of seven runs). It used to be a minute of mostly thread::sleep; the sleeps are now waits on the condition itself (cctop_core::test_wait), so what is left is the real ptys and subprocesses, and the slowest binaries take about a second each. No test asserts a tight wall-clock margin any more: the few that check elapsed() — test_wait::tests::a_condition_that_already_holds_costs_no_sleep, yolo::tests::the_hooks_allow_never_waits_long and ssh_master::tests::a_command_that_overruns_is_cut_off, all in -p cctop-core — bound something meant to be instant by fifty times what it needs. A full-suite failure on a busy machine is still not evidence until you have re-run it alone: fork-heavy tests share one process, and a race between them is not your change.

One such race is worth knowing when you write a test that takes an flock: the lock belongs to the open file description, and a child forked by any other test thread holds a copy of every open descriptor until it execs. A lock file this test (or code it called) opened a moment ago can therefore still be held when the test tries to take it. Take it on a file nothing has opened yet, as yolo::tests::switched_on_unlocked does, or wait for it with test_wait.

The first run after a build can also be several times slower than the next (#141): 40 s rather than 10, with a handful of tests taking seconds that take a fraction of one on a rerun. What is known of the cause:

  • recall::tests::recall_finds_the_passage_and_skips_the_caller used to load the fetched 30 MB embedding model from ~/.cache/cctop — cold off the disk on a first run, and only on a machine that had fetched one. Under test, config::CACHE_DIR is now a temporary directory of the process’s own, so no test reads or writes the real cache.
  • advise::tests::the_ledger_stays_small takes an flock per write and polls for it every millisecond. A child forked by another test in the same binary holds an inherited flock until it execs, so under a parallel run with many forks that wait can add up.
  • The chat::tests::an_opencode_* tests build sqlite fixtures; the first one pays for sqlite’s first touch.

So a slow first run on its own is not a regression: compare it against a second run of the same command before blaming any one test.

tools/targeted-test.sh maps what you changed to the tests worth running:

./tools/targeted-test.sh                           # diff against main, run what it names
./tools/targeted-test.sh crates/ui/src/filter.rs   # or name the files yourself
./tools/targeted-test.sh --all                     # force the full suite

A test’s module path is its file’s module path inside its own crate, so crates/ui/src/filter.rs runs filter:: in -p cctop-ui, and src/cli.rs runs cli:: in the binary, -p cctop. A crate root (lib.rs, main.rs) runs that crate’s whole suite; crates/core/build.rs or anything non-Rust runs everything. Only the changed file’s own crate is tested, not the crates built on it — that is what the full run at the end is for. Use it while you work and the full suite once, at the end, on its own.

Layout

cctop is one binary built from five crates, so that an edit recompiles only what sits above it:

CrateWhereWhat
cctopsrc/main, the command line, and the commands nothing else calls (doctor, why, wait, mcp, recall)
cctop-uicrates/ui/the terminal dashboard
cctop-servecrates/serve/the web server
cctop-corecrates/core/sessions and their parsers, the cache, pricing, config, processes, hooks, rmux, the shim, and what both faces read and do to a session (the conversation, report, export, handoff, actions)
cctop-tunnelcrates/tunnel/local ports on public URLs, behind a provider-agnostic Provider trait; Cloudflare’s quick and named tunnels are the two implementations, speaking the edge’s protocol natively

The dashboard and the server each depend on core and not on each other, and the binary depends on all three; core depends on the tunnel crate, which depends on nothing of cctop’s and is the one crate whose licence is not plain MIT (it began as cloudflare-quick-tunnel, MIT OR Apache-2.0); the root Cargo.toml says why each boundary is where it is. Nothing in core reaches up: something both faces need belongs in core. The dashboard starts a server to share its table through a function the binary hands it (serve_for_dashboard in src/main.rs).

Core’s guards that keep a test off the real machine — no bell on stdout, no copy to your clipboard, no write over your saved preferences, a runtime directory of the test’s own — ask cctop_core::under_test(), and the fixtures other crates’ tests use are cfg(any(test, feature = "test-support")). cfg(test) is only true for core’s own tests, so the other crates turn the feature on from their dev-dependencies. A release build never has it; a binary built by cargo test does, and its main switches the guards off before anything else runs, so it behaves as cctop.

The debug feature

crates/serve/src/debug.rs is behind #[cfg(feature = "debug")], which is off by default and never enabled for a release — so an ordinary build contains neither the routes nor the strings that name them. That is the condition for having them: a debug surface reachable in a shipped binary is a debug surface somebody else can reach, and the serve token is one link away from anyone you shared a page with.

Three things live behind it, all reachable only from a build that asked:

  • /api/debug/state and /api/debug/why — what the server is holding, and how it decided which sessions are running. Both are answerable from outside (cctop -j, cctop why), but not from the serving process itself, which is where a disagreement between the page and the terminal would show.
  • /api/debug/fault — makes subsequent /api/ responses fail in a chosen way (502, slow, html, empty), or holds the event stream open and silent (stall, what a quick tunnel that does not carry SSE looks like). The pages have to survive a tunnel whose far end has gone and a proxy answering HTML where JSON was asked for, and neither can be produced on demand by a correct server.
  • /api/debug/log?level=io — turns CCTOP_LOG on for a server that was not started with it.
cargo run --features debug -- serve --no-token

Because the feature is off by default, cargo clippy --all-targets and cargo test --all-targets never compile that module — a change to crates/serve/src/lib.rs that breaks the debug routes is green, and nothing finds out until someone runs it by hand. So verify runs both commands a second time with --features debug, and a change that touches those routes should pass both locally:

# -D warnings is set in .cargo/config.toml
cargo clippy --all-targets --features debug
cargo test --all-targets --features debug

The tests do not exercise the fault routes — arming slow sleeps for 30 seconds by design — so the extra run is a compile check, not a timing one. If you add tests here, keep them off slow, or the suite grows half a minute per case.

cctop is Linux-only

There is one platform, and it is Linux (including WSL). macOS and Windows were supported once and are not any more: verify builds and tests on Linux alone, and a release ships two statically linked musl archives, x86_64 and aarch64.

Write for Linux directly. A #[cfg(unix)] or #[cfg(target_os = "linux")] gate is noise around code that has no other target to be conditional against, and a stub standing in for a platform that is no longer built is dead code, which -D warnings rejects.

cctop hook must never break the session it watches

cctop hook runs inside someone’s coding session, many times a minute. Claude Code reads its exit code as a decision: non-zero blocks the tool call and feeds stderr back to the model. So it exits 0 always, writes nothing to stdout, and returns inside a deadline — by construction, not by care. See the module docs in crates/core/src/hook/mod.rs.

A hook that fell through to clap would exit non-zero on every fire, so the hook dispatch in main.rs is never gated behind anything.

Conventions

  • ponytail: comments mark a deliberate, documented limit — a thing this code knowingly does not do. They are not TODOs and do not want fixing without a reason.
  • Comments say why, not what. The prose in this codebase explains the decision behind a line; match that rather than narrating the syntax.
  • Doc comments carry the reasoning for anything a reader would otherwise have to reconstruct — especially where two plausible designs existed.

Design notes

A few things that are less obvious from the code:

  • Token dedup. Streaming writes the same requestId repeatedly with growing counts. Only the last entry per request is counted; summing them all inflates totals several-fold.
  • Cache keys carry a pricing generation. Cached entries hold computed costs, so a refreshed rate table must invalidate them just as an appended transcript does. Without this, sessions priced before the table loaded report $0.00 forever — their transcripts never change again.
  • The cache version is derived, not written. build.rs hashes the shape of the serialised types, so adding a field to SessionData invalidates stale entries without anyone remembering to bump a number.
  • Threads are excluded from process matching. Threads share their process’s command line, so every one of them matches the same session and competes to be picked as the root — nondeterministically. The winner reports its own CPU and no children.
  • Tail reads. Context usage and last-tool come from seeking backwards from EOF, so a live 50 MB transcript costs one 64 KB read per refresh, not a reparse.
  • Ghost subagents. Claude Code purges old subagent transcripts but keeps the tool_use/tool_result pair in the parent. Those rows are reconstructed and marked ◌, with — rather than 0 for figures that can no longer be measured.
  • Collisions compare repository roots, not directories. A linked worktree carries its own .git, so two agents in two worktrees do not collide while two in one checkout do. Comparing directories gets both backwards.
  • Remote rows are inert. A row from --host carries Session::remote, and every action that signals a process, deletes a file, opens a pty or reads a git directory guards on it — all of those are about this filesystem.

Releasing

A release is a merged pull request, and it is always the same one. On every push to main, .github/workflows/release-plz.yml runs release-plz, which keeps one pull request open — labelled release, titled chore: release, on a release-plz-… branch — that bumps every crate main would release, with Cargo.lock to match. It is rebuilt from scratch whenever main moves, so it is always current and never needs rebasing.

Merging it is the release. That changes the root version, and on a change of the root version on main release.yml derives the matching v<version> tag, creates the GitHub release, builds the Linux archives and publishes to crates.io. There is no separate confirmation step, and cargo publish to crates.io cannot be undone. Do not create a release tag by hand, and do not let release-plz create one: tagging, the GitHub release and publishing are release.yml’s alone, and release-plz.toml switches them off in release-plz.

Before merging:

  1. Write the release’s prose at the top of the description, above the first line (an invisible marker). One paragraph saying what the release is for, in the user’s terms — the theme, and why the digit that moved moved. release.yml puts it above the generated list of merged pull requests, and cctop --update shows it to everyone who updates. release-plz rewrites the description whenever main moves, so write it last; left empty, the release is the generated list alone.
  2. Check CI is green on it. It runs like on any pull request, because release-plz opens it with a real account’s token (below).
  3. Make it a minor if it should be one. release-plz bumps the root by a patch. When behaviour changed in existing installs, set the root version (version under [workspace.package] in the root Cargo.toml) to the next minor on the release branch, run cargo update --workspace for the lock, and push both. Do it last too: a commit by anyone but release-plz makes the next run close that pull request and open a fresh one, without it.

What gets bumped

The four internal crates, cctop-tunnel, cctop-core, cctop-serve and cctop-ui, each have their own version in crates/*/Cargo.toml. The root requires each with a caret on the compatible part of its version under [workspace.dependencies] — "0.28", meaning at least 0.28.0 and below 0.29 — so a published cctop-ui keeps building beside a newer cctop-core of the same minor. release-plz bumps one only if it differs from its copy on crates.io, which is any of:

  1. its files changed;
  2. its packaged manifest changed without that — a [workspace.dependencies] entry it uses was bumped, its requirement on an internal crate moved, or a [workspace.package] field it inherits (edition, rust-version, license…);
  3. an internal crate it depends on takes a breaking bump, because that moves its requirement. Whether a crate’s public API broke is cargo-semver-checks’ answer, against the same crate as published; a crate that broke takes 0.28.x → 0.29.0, and its requirement moves to "0.29". The release pull request says which crates broke, and what the tool found.

The root cctop is bumped whenever anything is: release.yml releases on a change of its version and nothing else, and an internal crate is only reachable through a cctop that requires it.

So a change to crates/ui alone bumps cctop-ui and cctop, one to crates/core that keeps its API bumps cctop-core and cctop, one that breaks it bumps core to the next minor and cctop-serve and cctop-ui by a patch for the moved requirement, and one to the binary’s own src/ bumps cctop alone. pub in these crates exists for use between them rather than as a designed API, so expect a breaking bump more often than a library would take one.

The carets let cargo install cctop without --locked pick a newer compatible core than the one released with it. The published crate carries its Cargo.lock, cctop --update and the README both pass --locked, and cargo-semver-checks covers the API; what is left is a behaviour change that kept the API, reaching someone who typed a bare cargo install cctop.

  • cargo-semver-checks moves with the toolchain. It reads the rustdoc JSON the pinned Rust writes, and each release of it understands a range of Rust versions, so its version sits at the end of rust-toolchain.toml, beside channel, and release-plz.yml installs that one. A pull request that moves channel checks its release notes and moves that line too when it has to.
  • publish-crate asks the crates.io index which crates are missing at their current version (tools/unpublished-crates.sh) and publishes only those, dependency order first; the rest are skipped with a notice. A run that failed part-way can be re-run as it is.
  • To see what release-plz would do without opening anything, run release-plz update in a scratch clone (it edits the manifests in place) and git diff there. It needs cargo-semver-checks at the pinned version on PATH.

The token release-plz opens the pull request with

A pull request opened with a workflow’s own GITHUB_TOKEN starts no workflow, so CI would never run on the release pull request. release-plz.yml therefore uses the repository secret RELEASE_PLZ_TOKEN, and fails, saying so, when it is missing. Either of:

  • a fine-grained personal access token of a maintainer, for this repository only, with Contents: read and write and Pull requests: read and write (and Metadata: read, which GitHub adds itself); or
  • a GitHub App installation token with the same two permissions, minted in the workflow by actions/create-github-app-token — it does not expire with a person’s token, at the cost of an app to keep.

gh secret set RELEASE_PLZ_TOKEN stores it. A fine-grained token expires; when release-plz.yml starts failing on a 401, that is the first thing to renew.

Name every other pull request after what it changes

The notes GitHub generates are one line per merged PR, and cctop --update prints those lines to everyone who updates — so a PR title is a release note. CLAUDE.md has the full rule and the shapes to avoid; the short version is to write the sentence you would want someone three versions behind to read. The release pull request is the one exception, left out of the list by its release label (.github/release.yml): its prose goes in its description.

Notes are fetched when someone updates rather than when the release is cut, so a note that landed badly is still worth fixing: edit the release body on GitHub and everyone who has not updated yet sees the better one.

The mirror and the signature

cctop --update asks GitHub first for the release JSON and falls back on a Cloudflare Worker, worker/: GitHub rate-limits anonymous API calls and is sometimes simply down. The archives go the other way — the Worker first, GitHub when it fails — because GitHub’s downloads crawl from New Zealand (half a megabyte a second, against the Worker’s 18 MB in under a second). The release workflow keeps the Worker in step (tools/push-release-mirror.sh), and a mirror failure never fails a release. The Worker is deployed by Cloudflare Workers Builds when a change to worker/ reaches main — not by hand — so anything a new binding in worker/wrangler.jsonc names (a bucket, a namespace) has to exist in the Cloudflare account before that change merges.

Each archive is signed with ed25519 before the release is made public (tools/sign-release.sh, with the RELEASE_SIGNING_KEY secret), and the updater checks it for every download, GitHub’s or the mirror’s. That is what keeps the mirror’s push token from being a way to ship a binary to everyone. tools/release-key.sh makes the pair once: it stores the private half in the secret without printing it and prints the public half to paste.

A binary refuses releases signed by any key it does not carry, so it carries two (RELEASE_KEYS in crates/core/src/update.rs): that primary, and a backup whose private half is kept offline by a maintainer (tools/release-key.sh --backup made it) and never goes near CI. It exists to rotate the primary out if it leaks or is lost: release a source that trusts a new primary and the backup but not the old primary (tools/release-key.sh --replace), then re-sign that release’s archives with the backup and refresh the mirror (tools/release-key.sh --sign-with-backup v<version>), which every installed binary accepts. The top of tools/release-key.sh has the steps. Do not run --replace outside that procedure: without the backup’s signature, no installed cctop would accept a release again.

Refreshing the screenshots

docs/assets/ holds real captures, not mock-ups. Regenerate them after any change to the table’s columns or the panels:

cargo build --release
R="--redact YourCompany=Example Inc"
python3 docs/assets/shot.py docs/assets/dashboard.png --size 146x30 $R
python3 docs/assets/shot.py docs/assets/context.png --keys 'Tab*7' --size 146x36 $R
python3 docs/assets/shot.py docs/assets/demo.gif --record docs/assets/demo.cast \
        --size 128x30 --scale 1 $R

The pictures in the docs pages (docs/assets/frames/) are not captures: they are the snapshot tests’ fixture, the same made-up sessions the .snap files pin, rendered in colour. After a change to what the TUI draws, accept the snapshots and redraw them all with:

python3 docs/assets/frames.py

A new one is a snapshot name in its FRAMES list and an image link.

shot.py drives a real cctop through tmux and rasterises the captured screen itself, so the images carry whatever is on the machine that made them.

It scrubs email addresses unconditionally, because cctop reads the signed-in account out of each harness’s config and prints it in the Info panel — an address the person taking the screenshot never chose to publish and would not think to look for. --redact adds literals (an employer, a client’s project name). Scrubbing happens on the parsed grid, so a replacement of a different length cannot shift a column: it is padded or truncated to the same width.

Nothing else is scrubbed. Session titles, working directories and real spend figures all appear as they are — look at what is in frame before committing.

Three traps the script guards, each of which produced a wrong picture first:

  • a multiplexer session named cctop-* is adopted by cctop as one of its own tabs, so it attaches to the terminal it is running in. cctop drives rmux, and the script drives tmux, so the two no longer collide — the guard stays because the naming rule is what made it a trap, not which daemon held the session;
  • preferring target/release over target/debug captures whichever is older, which once shipped a screenshot missing two columns added that afternoon;
  • an SVG would be smaller and sharper, but cctop draws sparklines with eight-dot braille (U+2840+) and DejaVu Sans Mono covers only the six-dot block, so those cells become tofu on any reader whose font agrees. Rasterising pins them;
  • DejaVu Sans Mono is a wide face, and at its natural line height the cell is 1.93:1 against the ~2.2 terminals use — every row looks vertically squashed. The cell is therefore 1.35× the font size, and the ten box and block glyphs (─━│┊╭╮╯╰█░) are drawn as geometry rather than text, because a font’s box characters only span its own natural line height and would otherwise leave the panel borders visibly gapped.

Agents’ git hooks

tools/git-hooks holds three hooks that act only inside a Claude Code session (CLAUDECODE=1) and leave a person’s commits alone: one adds the co-author line to an agent’s commit, the other two push each commit (and each merge) in the background on a branch that already tracks the remote (never main, never mid-rebase), so an agent’s draft PR is always current. Enable them once per clone:

git config core.hooksPath tools/git-hooks

A fourth, reference-transaction, is for a main clone kept bare so that only worktrees are edited. The files beside such a clone are what a session started there reads (CLAUDE.md, agents, skills), and git never updates them. The hook runs tools/refresh-checkout.sh whenever a fetch moves origin/main, which brings them up to date unless someone has edited them by hand. The first time, name the commit they are at:

tools/refresh-checkout.sh --from <commit>   # later runs remember it

The issue loop

GitHub issues are a work queue: an issue filed from anywhere, a phone included, is picked up by agents — but only while a Claude Code session is running the loop on a machine with a checkout. Nothing on GitHub’s side starts one. To start it, open Claude Code in the checkout and run:

/loop /issue-loop:issues

With no interval the loop paces itself (see the issue-loop plugin’s issues skill, “Under /loop”): each pass spawns solvers in the background, and their finishing wakes the loop again. When nothing is running and nothing is queued it looks again in about 20 minutes — soon enough that an issue filed from a phone is started within the half hour, without polling GitHub every minute for a queue that is usually empty. Press Esc to stop it. A self-paced loop is not restored by claude --resume, and like any recurring task it expires after seven days, so run /loop /issue-loop:issues again after either.

What it picks up is decided by labels, the table in the skill:

labelmeaning
agent-readywritten and waiting for a solver — the only label that starts new work
agent-workinga solver has it, with a draft PR from issue-<N>
agent-questionthe solver asked on the issue and stopped; your reply resumes it
agent-prthe PR is ready for review; review comments send a solver back

So an issue filed by hand needs the agent-ready label to be worked on, and an unlabelled one is left alone. /issue-loop:issues <one-line request> files one with the issue-writer agent, which writes it up and adds the label. Merging and releasing stay with you: the loop never merges a PR or bumps a version.

Adding a harness

← all harnesses

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_sessions must 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 a STATIC_CACHE. Pi is the one exception, and its page says why.
  • extract may be expensive; it is cached and fanned out across cores. It must return SessionData::error rather than partial totals when a read fails, because the cache refuses to persist an errored extraction and a fabricated $0.00 would be indistinguishable from a free plan.
  • extract_context returns None where the harness records no window, and that must stay None rather than a guess: the denominator sets every percentage in the UI and where the auto-compaction marker sits.
  • delete may refuse. Windsurf’s returns Unsupported with 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

FileWhat to add
session/mod.rsthe rayon::join in list_all; effective_mtime_ms; Session::resume_argv; is_waiting_for_input_event; Surface::label
cache.rsdisk_key (only if the file does not back one session alone), the extract dispatch, and the trace span name
loader.rsread_tail’s three dispatches, plus any provider-specific liveness inference
proc.rscould_be_agent, the per-provider process test, and how a session id appears on the command line
doctor.rsa row in sources(), so a missing directory reports as “not installed” rather than as zero sessions
access.rsrules files, config files, and the hook::Harness mapping if there is one
session/search.rshow / searches its transcripts — the default line scan, or a query if the store is shared
serve/chat.rsa 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.rsthe --list grouping and the --json account block
hook/installers.rsHarness 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.