cctop 0.3.1

An htop-like terminal monitor for AI coding agent sessions (Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Pi, Windsurf)
# 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.88 or newer (the code uses let-chains):

```bash
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:

```bash
export RUSTFLAGS="-D warnings"
cargo fmt --all --check
cargo clippy --all-targets
cargo test
cargo publish --dry-run --allow-dirty   # what `verify / package` runs
```

## Cross-platform is not optional

`verify` builds on Linux, macOS **and** Windows, and all three gate a release.
The traps, all of which have bitten:

- **Windows has no ptys or unix sockets.** `shim` is `#[cfg(unix)]` with
  `shim_stub.rs` standing in. A stub may only carry what Windows actually
  reaches — an item whose callers are all gated is dead code, which
  `-D warnings` rejects. Adding a courtesy stub breaks the build.
- **A `cfg`'d-out caller makes its callee dead code.** If every caller of a
  function is `#[cfg(unix)]` or `#[cfg(target_os = "linux")]`, the function
  needs the same gate.
- **macOS resolves symlinks.** `tempfile::tempdir()` gives `/var/folders/…`;
  FSEvents reports `/private/var/folders/…`. Canonicalise before comparing a
  path the watcher reported against one you built.
- **Windows filenames reject `|`, `:`, `*`, `?`.** A fixture using them needs
  `#[cfg(unix)]`.
- **Paths in tests.** A transcript written on Unix spells absolute paths with a
  leading `/`, which `Path::is_absolute` calls false on Windows. Compare against
  `std::path::MAIN_SEPARATOR` rather than hard-coding `/`.

## `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 `src/hook.rs`.

This is why the `hook` dispatch in `main.rs` is not behind `#[cfg(unix)]`:
`--install-hooks` writes the settings file on any platform, and a hook that fell
through to clap would exit non-zero on every fire.

## 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

Change the package `version` in `Cargo.toml` and push that commit to `main`.
GitHub Actions derives the matching `v<version>` tag, creates the GitHub
release, builds the platform archives, and publishes the crate. **The version
bump is the release** — there is no separate confirmation step, and
`cargo publish` to crates.io cannot be undone. Do not create a release tag by
hand for a normal version bump.