Inside Bumeter: reading AI coding logs while they are still being written

#architecture#rust#local-first

Bumeter answers two questions for people who code with AI tools: where did my tokens go, and how close am I to being blocked. It does this without an account, without a server and without an API key. Everything it knows comes from files the tools already write on your disk.

That sounds simple until you list the constraints:

  1. Nothing leaves the machine. No telemetry, no upload.
  2. Never break the tool being watched. Bumeter reads Claude Code's logs and sits inside its status line. If Bumeter crashes, Claude Code must not notice.
  3. Live, but cheap. Numbers should move while you work, from a tray app that is idle most of the day.
  4. One brain. The desktop app, the bum command line tool and future integrations must give the same answer to the same question.

This post walks through how the architecture follows from those four rules.

The shape of it

flowchart TB
  subgraph SRC["Logs on your machine"]
    C1["Claude Code<br/>~/.claude/projects/**.jsonl"]
    K1["Kiro<br/>~/.kiro/sessions/**.json"]
    O1["OpenCode<br/>opencode.db (SQLite)"]
    L1["limits.json<br/>written by the status line hook"]
  end
  W["watcher<br/>inotify · FSEvents · ReadDirectoryChangesW"]
  H["host loop<br/>30 ms debounce · 1 s tick · periodic rescan"]
  E["engine + tailer<br/>resume from saved offset"]
  ST[("SQLite")]
  HD["handlers<br/>dispatch(method, params) → JSON"]
  T["Desktop app (Tauri)"]
  SV["JSON-RPC socket"]
  CLI["bum CLI"]
  C1 & K1 & O1 & L1 --> W
  W --> H --> E --> ST --> HD
  HD --> T
  HD --> SV
  HD --> CLI

It is a Rust workspace of small crates, each with one job: bum-core (the engine, the database and every query), bum-adapters (one module per AI tool), bum-skills (managing Claude Code skills), bum-service (the socket), bum-cli, and the Tauri app with a plain TypeScript UI.

Reading files that are still being written

Claude Code appends one JSON line to a session file for every event. Bumeter wants those lines within a second or so, but a busy turn can write dozens of lines in a burst, and a tray app should not burn CPU re-reading a file per line.

The watcher uses the operating system's file events (inotify, FSEvents, ReadDirectoryChangesW). It ignores Bumeter's own database and WAL files, otherwise every write would trigger another read.

The host loop collects those events for 30 ms before acting, so a burst of writes becomes one read. It also ticks once a second and rescans periodically. File events are an optimisation, not something Bumeter relies on: when Linux runs out of inotify watches, or a folder cannot be watched, the data still arrives, only later.

The tailer is where most of the care goes:

Kiro is the odd one out: it rewrites a whole JSON file instead of appending to it. Its adapter declares whole_file() = true and parses the whole file. OpenCode keeps its data in SQLite, so its adapter is a DbSource that reads from a watermark. It opens that database read-only and never writes to it.

A quota number with no API

The most useful number Bumeter shows (how much of your 5-hour and 7-day Claude limit is used) is not in the logs at all. No endpoint exposes it either. The only place Claude Code reveals it is the JSON it pipes into your status line command on every refresh.

So Bumeter becomes part of the status line. bum statusline reads that JSON from stdin, writes the numbers to limits.json, and the watcher picks the file up like any other log. Each sample is stored, and forecast.rs turns the history into a burn rate and an estimated time of running out.

Many people already have a carefully built status line, and taking it over would be rude. Bumeter wraps it instead, in one of two forms:

# Show Bumeter's part after your own line
"bum" statusline --around '<your command>'

# Only record the numbers; your line stays exactly as it was
{ "bum" statusline --tee 2>/dev/null || cat; } | { <your command>; }

Constraint 2 shapes every detail here:

One core, three doors

Every question the UI can ask is a method name and some JSON parameters, answered by one function:

handlers::dispatch(method, params) -> JSON

Three front ends call it:

DoorWho uses itTransport
Tauri command callthe desktop appasync IPC, so the window never freezes
bum-servicefuture TUI / editor integrationsone JSON line per request on a Unix socket (mode 0600) or a Windows named pipe
bum CLIpeople, scripts and AI agentsbum status, bum report, bum skills …

Because the logic lives behind dispatch, bum report in a terminal and the Overview screen in the app give the same answer from the same code.

Only one process writes to the database at a time. A host.lock file guards it. When the desktop app starts and finds bum daemon already running, it asks the daemon to stop and takes over the host. Readers open the database read-only, so the UI never competes with the writer.

The UI is told that data changed, not what changed. The host sends a data-changed event, merged to at most one every 250 ms, and each screen asks again for what it shows. That is simple, and in practice it is fast enough.

Adding a tool is one line

Each AI tool is an adapter implementing a small trait:

pub trait LogSource: Send {
    fn id(&self) -> &'static str;
    fn info(&self) -> SourceInfo;
    fn watch_roots(&self) -> Vec<PathBuf>;
    fn accepts(&self, path: &Path) -> bool;
    fn parse_line(&self, path: &Path, ctx: &mut ParseCtx, line: &str) -> Result<Vec<RawEvent>, LineError>;
    // Tools that rewrite a whole file (Kiro) override these two.
    fn whole_file(&self) -> bool { false }
    fn parse_file(&self, path: &Path) -> Result<Vec<RawEvent>, LineError> { … }
}

Every tool is listed in exactly one place, registry() in bum-adapters. The UI has no tool names in it: labels, colours and what each tool supports come from SourceInfo over a sources call. A new tool is an adapter, one line in the registry and its tests, with no UI change.

SourceInfo is also where the app stays truthful about what it knows. Kiro does not log real token counts, so its adapter estimates them from per-turn percentages and sets real_tokens = false. The UI then labels those numbers as estimates instead of presenting a guess as a fact.

Changes you can take back

Bumeter mostly reads, but it can also change things for you: disable a skill that costs tokens in every session, or hook the status line. Every change follows the same pattern:

flowchart LR
  P["disable a skill"] --> PLAN["plan()<br/>list exactly what will change"]
  PLAN --> Q{"confirmed?"}
  Q -->|no| STOP["print the plan · change nothing"]
  Q -->|yes| APPLY["apply()"]
  APPLY --> J["journal: the way back"]
  J --> UNDO["bum skills undo ID"]

From the CLI, bum skills disable without --yes only prints the plan and exits non-zero. This rule is aimed at AI agents: an agent that runs the command to "see what happens" changes nothing.

What it does not do

There is no account and no telemetry. Bumeter goes online for three things only: checking GitHub Releases for a new version, searching skills.sh when you browse skills, and downloading a skill you chose to install from GitHub. Nothing about you or your code is sent anywhere. The privacy post lists exactly what is read and written.

Bumeter is free and open source (GPL-3.0), and everything stays on your machine. The source is at github.com/chieund/bumeter. Start with crates/bum-core/src/host.rs, engine.rs and tailer.rs.

Download Bumeter