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

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.