Inside Bumeter: reading AI coding logs while they are still being written
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:
- Nothing leaves the machine. No telemetry, no upload.
- 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.
- Live, but cheap. Numbers should move while you work, from a tray app that is idle most of the day.
- One brain. The desktop app, the
bumcommand 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:
- It reads from the saved offset up to the last
\nonly. A line still being written is left for the next pass, so a half-written JSON line is never parsed as an error. - With the offset it stores the parser's state (
ParseCtx) in the database. After a restart it resumes at the exact byte and the exact parsing state, without re-reading gigabytes of history. - It identifies a file by
dev:inoon Unix, so a file replaced under the same name is read again from the start instead of from a stale offset. - A file that cannot be read (permissions, deleted mid-scan) is skipped, and the scan carries on.
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:
bum statuslinenever fails. Every error is swallowed, because a non-zero exit or a stray message would break Claude Code's status line.- The status line runs on every refresh, so it is kept short.
--teewrites the file and passes the input straight through, with no socket call and no transcript read. The show mode waits at most 200 ms for the app. A slow app means a missing forecast for one refresh, never a frozen line. - The record form keeps working after Bumeter is gone. If
bumhas been uninstalled,|| catstill hands the JSON to your command. - Editing
settings.jsonfollows a few rules: the first change is backed up, every write is atomic and keeps the file's permissions, and a symlinked dotfile stays a symlink. Undo restores the original bytes when nothing else changed, and refuses rather than guesses when you edited the status line since. A hook Bumeter cannot read back safely is never rewritten.
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:
| Door | Who uses it | Transport |
|---|---|---|
Tauri command call | the desktop app | async IPC, so the window never freezes |
bum-service | future TUI / editor integrations | one JSON line per request on a Unix socket (mode 0600) or a Windows named pipe |
bum CLI | people, scripts and AI agents | bum 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.