claudemeter is a python script that watches how much of my Claude allowance is gone—the five-hour session window, the weekly one, and the weekly cap scoped to a single model (Fable)—and, underneath the bars, which session spent it. It runs as a small systemd service, draws an HTML timeline on demand, and costs no tokens.
Written with the help of Claude Opus 5 on 30 August (2026). Version 1.7.0.
GET /api/oauth/usage, which is what Claude Code's own /usage reads. It answers with the whole picture as plain JSON, 1.9 kB, in a third of a second, and no model runs, so it is billed nothing. The account's own OAuth token from ~/.claude/.credentials.json is the only thing it needs.
There is no usage figure in the ordinary API—but every reply to /v1/messages carries it in its response headers, anthropic-ratelimit-unified-5h-utilization and its friends. So one could buy those numbers with the smallest purchase available: one Haiku turn, max_tokens:1, twenty-two tokens in and one out. That is about 6,600 tokens a day at five-minute resolution.
This is still small (against my own average of ≈230 million, is one part in 34,000) but one can get it for free from /api/oauth/usage.
The free endpoints one would try first do not help: /v1/messages/count_tokens and /v1/models are both unbilled and neither returns a single rate-limit header. Had the endpoint not existed, a billed turn really would have been the only way.
The endpoint returns a limits array, and on my account it holds three:
| Kind | What it caps | Resets |
|---|---|---|
session |
everything, over five hours | rolling |
weekly_all |
everything, over seven days | Saturday |
weekly_scoped |
one model—here Fable—over seven days | Saturday |
One of them carries is_active, and that is the one this week's work is actually charged against. It matters: at the moment of writing I was at 9% of the flat weekly allowance and 14% of the Fable one. The scoped cap is the one that runs out first, and the header route never mentioned it at all—reading the headers, claudemeter was blind to the limit that will actually stop me. Every limit in force gets its own bar, its own panel in the timeline, and its own field in the status line, with a ▸ against the binding one.
A limit gives one number for the account and says nothing about who spent it. ~/.claude/projects/*/*.jsonl does: every assistant turn carries its model, its four token counts—fresh input, cache read, five-minute cache write, one-hour cache write—and a UTC timestamp. Priced at the published per-MTok rates (a cached token a tenth of a fresh one, a cache write a quarter more or double), each session gets a share of the window, and the measured total makes the shares absolute.
So the bars are measured and the attribution underneath them is inferred. A session that spends nothing on this machine but shows up in the total is work done somewhere else—the web app, the phone—and it sits inside the figure without appearing in the list.
The window start is simply reset − span, which is why a breakdown works from the very first probe, with no calibration and no history to accumulate.
The poller slows down when nothing is happening, and to know that it first asked the transcripts' modification times. That was wrong, for the reason claude-recent already knew: a Claude Code session left open keeps stamping housekeeping into its file long after the conversation stopped. Measured on a live machine, one transcript had been written 0.4 minutes ago and its last real exchange was 67 minutes old. So the mtime said "busy" almost always and the back-off it was gating essentially never fired. Since 1.1.0 it asks the priced turns themselves when the last one was, which is exact and costs nothing, being already parsed.
The whole point of the thing is to not be one of the things worth watching.
| What | Cost |
|---|---|
| One probe | zero tokens; 1.9 kB and ~0.3 s on the wire |
| A day of probing | zero tokens, some 250 kB of traffic |
| The daemon | 24 MB resident, asleep between probes, Nice=10
|
| A full transcript rescan | 36,000 turns over 550 MB in 0.35 s |
| Every other scan | only the bytes added since the last one |
--statusline |
35 ms, no network, no tokens |
| On disk | some 4 MB, growing ~150 kB a day |
claudemeter # every limit in force, burn rate, projection
claudemeter --offline # ... without asking the server
claudemeter -s # who spent the five-hour window
claudemeter -s -w # ... the weekly limit that is furthest along
claudemeter -s --projects # group by project, not by session
claudemeter -s --models # ... or by model
claudemeter --probe # ask once, right now
claudemeter --plot -d 7 # an HTML timeline of the last seven days
claudemeter --plot 04FF0 # ... written over the plot that tag names
claudemeter --statusline # one line, for Claude Code's status bar
claudemeter --json # everything, machine-readable
claudemeter --rescan # rebuild the spend table from every transcript
claudemeter -v # the version
Since asking is free, the bare command asks rather than reciting whatever the daemon last wrote; if the link is down the record stands. It reads:
5-hour session ████···································· 2%
resets in 4h 49m (Sun 18:59) 11.3%/h
weekly ████···································· 9%
resets in 6d 2h (Sat 16:59) 0.4%/h
▸ weekly · Fable ██████·································· 14%
resets in 6d 2h (Sat 16:59) 0.7%/h spent by Sat 00:15
and -s says where those per cents went, one line per session with its project beside it.
--plot writes a self-contained page to ~/.claudemeter/claudemeter-XXXXX.html, tagged with an af tag so it can be pointed at afterwards. One panel per limit—each sawtoothing up and dropping at its own reset—and underneath them a stacked bar per hour showing which project burnt what. The line breaks at every reset and wherever the record has a hole, so a gap is never drawn as a slope. It reads the theme, hovers with a crosshair, and carries the same numbers as a table for whoever would rather read them.
Given a tag it writes over that plot instead of dropping another beside it (1.3.0): claudemeter --plot 04FF0 refreshes claudemeter-04FF0.html in place, keeping the tag in the title and in the line under the charts, so a plot left open in a tab—or linked to from a page like this one—updates under the name it already has. Twenty-three of them had piled up in ~/.claudemeter by the time this went in, every one but the last of them dead. The tag may be given bare, as a filename, or as the whole path the tool has just printed, whichever is to hand. A tag naming no file is not taken as a new name: it is almost always a mistyped or half-remembered one, and filing the plot under it would put it where nothing is pointing. It says so on stderr—stderr, so that p=$(claudemeter --plot 04FF0) still yields the path and nothing else—and falls through to a fresh tag.
The path it prints is a link (1.2.2): what appears is the file:// URL, which Konsole finds with its own URL filter—its default AllowedLinkSchema is http://;https://;file://—wrapped in an OSC 8 escape for terminals that read one, terminated with BEL, the form all of them accept. Ctrl+click follows it, unless Open files and links by direct click is set in the profile. Wrapping the escape round the bare path, which is what 1.2.1 did, is correct and useless: a terminal that does not read OSC 8 is left looking at a path, and a path is not a link to anything. Piped, none of it happens—p=$(claudemeter --plot) gets a bare path, as everything downstream expects.
Two faults, found on 31 August (2026) by asking why the five-hour panel was a scatter of dots with nothing joining them, and fixed in 1.3.1 and 1.3.2. Neither was sparse measurement: over the first thirty hours the daemon had taken 147 readings and lost or disconnected almost all of them.
The first (1.3.1) is that a window with nothing in it was thrown away. When nothing has been spent for five hours no window is open, so the server sends percent: 0 with resets_at: null—and windows() dropped the whole reading over the missing reset. Ten measurements went that way in the first day, and they were the overnight ones, so the panel looked unmeasured across the night when it had in fact been measured nine times and read zero every time. The reset is genuinely unknown there; the utilisation is not, and it is the utilisation that is plotted. A closed window now comes through with its reset and start as None, and each reader says no window open rather than counting down to a turnover that does not exist.
The second (1.3.2) is worth more than it sounds: the curve was being cut to pieces by one second. resets_at carries microseconds, and they wander either side of the minute the window really turns on—19:09:59.798902 from one probe, 19:10:00.520943 from the next, the same window both times. iso() truncated, so those became two different integers and therefore two different windows. The segment rule breaks the curve wherever the reset changes, because a real turnover is a cliff and must never be drawn as a slope; 67 of these phantom turnovers cut a 146-point session curve into 74 segments, most of them a single point—and a one-point segment draws as a dot. Hence the scatter. iso() now rounds, and window identity is a tolerance rather than an equality: same_window() calls two resets the same window within two minutes, which no genuine turnover ever is, the shortest window being five hours. The same curve is now 7 segments and no lone dots, and every break in it is real—two window turnovers, a window closing, and a suspend.
What remains is the back-off, which is deliberate, and the machine's own sleep. The poller probes every five minutes while something is happening and doubles to an hour when nothing is, so a quiet night is one point an hour on purpose; and a gap wider than ninety minutes is drawn as a break, which on 31 August correctly caught the two and a half hours the laptop spent suspended.
Since 1.2.0 the time axis zooms (the vertical one is a percentage and has nowhere to go). Drag across any chart to keep that stretch of it. Under the tiles is a rail holding the whole period, with the kept stretch drawn over it as a window: slide it by its middle, pull it by either edge, or drag on bare rail to draw a new one. Double-click a chart, press Escape, or use Show all to come back; the readout beside the button names the window and its length.
Every chart reads one window, so the three limit panels and the stack cannot drift out of step with each other. The y axis follows it too—zoomed into a quiet afternoon, a scale set by the week's peak would draw a flat line along the bottom—and so does the tick spacing, which walks a ladder from five minutes to a week: a week of history gets days, six hours gets hours, half an hour gets five-minute marks. A tick that lands on local midnight is labelled with the day rather than the time, or two days of hourly ticks would read 12:00 AM, 12:00 PM, 12:00 AM, 12:00 PM and name neither of them.
The rail is drawn from the spend, not from the utilisation curve. That is not a preference: probing only started when the daemon did, so the curve is a sliver at the right-hand end and would leave the rail blank, while the transcripts reach back as far as the plot does. It is a map of where the busy stretches are, which is what one steers by.
Two things had to be right, and neither was obvious. Showing everything, the window is the rail—so every press lands inside it, and a slide with nowhere to slide does nothing at all. That is the state the page opens in and the first thing anyone tries, so unzoomed an interior press draws a new window instead. And a drag on the rail must not redraw the rail: rebuilding that <svg> under the finger drops the pointer capture and the drag dies after one frame. So moving the window redraws the charts and merely repositions the window; the rail itself is rebuilt only when the page is.
The table underneath stays over the whole period whatever the charts are showing, and says so. It is the only place the projects outside the stack's top five are still counted one by one, and a zoomed total would lose them.
The poller sizes its own interval: five minutes while there is real Claude activity on this machine or the five-hour window is past half, doubling to an hour when there is neither.
systemctl --user enable --now claudemeter # start it
systemctl --user status claudemeter # is it alive
tail -f ~/.claudemeter/daemon.log # what it is seeing
The OAuth token is never refreshed. ~/.claude/.credentials.json holds a rotating pair, and refreshing it from a side process could rotate the copy Claude Code is holding and log me out. So claudemeter reads the expiry, stops probing when it has passed, and says so; opening Claude Code renews it.
Two scanners sharing one index would both read the same offset and both append the same turns, so the transcript scan is taken under a lock—the daemon scans every few minutes, and a typed command scans whenever it is typed.
Claude Code can run a command of one's own and print its output along the bottom of the terminal. --statusline only reads the last line of the record—no probe, no network—so it can be refreshed as often as the terminal likes:
"statusLine": { "type": "command", "command": "/home/laussy/bin/claudemeter --statusline" }
in ~/.claude/settings.json, which then reads 5h 2% (4h 49m) · 7d 9% (6d 2h) · Fable 14% (6d 2h) under every prompt.
--plot refreshes the most recent plot when that plot is less than twelve hours old, keeping its path and its tag, instead of leaving a new file behind on every run; only a plot gone cold gets a fresh tag from af. Twelve hours is longer than a working session and shorter than a night. --plot TAG is unchanged.-s, a line under the bars, a --cost report (the five hours, the day, the week, the month and the whole record, by model and by project), and a figure in the plot. Nothing of it is billed, but it is the honest way to set one session beside another. An absolute makes a stale price a wrong number, so the table was checked against the published rates; Fable 5.1 prices cache reads flat rather than at a tenth of its input rate. --reprice carries a price change to the turns already recorded, which --rescan cannot do since Claude Code prunes the transcripts it would rebuild from.message.id. The bars were never affected, the shares between sessions mildly.#!/usr/bin/env python3
# _ _ _
# __| |__ _ _ _ __| |___ _ __ ___| |_ ___ _ _
# / _| / _` | || / _` / -_) ' \/ -_) _/ -_) '_|
# \__|_\__,_|\_,_\__,_\___|_|_|_\___|\__\___|_|
# V1.7.0 Laussy&Claude
# V1.7.0: the plot marks every new week. The weekly allowance turns over
# on a Saturday at 18:00, and a red vertical line now stands at each
# turnover in every chart and on the rail, so a week's climb can be
# read from its own start. The times are the probes' own: each
# distinct weekly reset, less seven days. Before probing began there
# is nothing to read them from, so the record is carried back in
# seven-day steps from the earliest one seen, and those lines are
# dashed -- inferred, not measured.
# V1.6.1: and a RESUMED session counted its turns twice more. 1.6.0 read
# one charge per message.id but kept the set per FILE, and resuming a
# conversation copies the whole transcript into a new file under a
# new session id, so every turn before the resume existed in two
# files and was counted out of both: 566 messages here, $243. They
# are easy to recognise once looked for -- the copy carries the
# original's timestamp to the second, so the pair is identical in
# everything but the session it is filed under. The de-duplication
# therefore moved to the READ, where it is global and needs nothing
# remembered between runs; the turn is credited to whichever of the
# two sessions the sorted table reaches first, which is arbitrary
# between a session and its own continuation and wrong for neither.
# V1.6.0: EVERY FIGURE THIS TOOL HAS EVER PRINTED WAS ABOUT TWICE WHAT IT
# SHOULD HAVE BEEN. Caught by Fabrice within minutes of 1.5.0 putting
# dollars on the screen -- a percentage has nothing to be compared
# against and looked fine for a fortnight; $1,527 for a week did not,
# and he was right. One assistant turn is ONE API call with ONE
# usage, but Claude Code writes it to the transcript as one record
# PER CONTENT BLOCK -- thinking, then text, then each tool_use -- and
# every one of those records repeats the same message.usage verbatim.
# The scan counted lines, so a turn that thought, spoke and called a
# tool was charged three times over. Measured across the whole of
# ~/.claude/projects: $8,136 counted by record against $3,992 counted
# by message, an inflation of 2.04x.
# The tell was in the token counts and not in the money: three rows
# 14 seconds apart, same session, same model, identical to the token
# in all five counts, including a 908,799-token one-hour cache write
# that no two turns could ever repeat by chance.
# So a turn is now counted once per message.id. The id is kept as a
# twelfth column in spend.tsv, and the last 32 ids per file ride in
# index.json so that a turn whose blocks straddle two incremental
# scans is still seen once. The percentages were affected too, though
# only mildly and never the bars: the overcount is per-turn and
# varies with how many blocks a turn has, so a session full of tool
# calls was weighted above one that mostly wrote prose.
# --rescan stops losing history, which is what made this awkward to
# repair: it rebuilds from the transcripts that exist AND carries
# forward the sessions whose transcript has been pruned, collapsing
# repeats in those by the only means left without a message id --
# identical counts in the same session inside five minutes, which
# reproduces the message-id answer to 0.13% where both can be
# measured.
# V1.5.0: it says what a session COST. The figure was there all along --
# weigh() has priced every turn since 1.0.0 -- but only ever as a
# ratio, used to split the server's percentage between the sessions
# that earned it, and the absolute it passed through on the way was
# thrown away. It is kept now and shown: a money column in -s, a line
# under the bars in the plain view, a --cost report setting the five
# hours, the day, the week, the month and the whole record side by
# side with a per-model and per-project breakdown under them, a
# column in the plot's table and a figure on its tiles. None of it is
# billed -- the subscription covers these turns -- so what it answers
# is what the conversation WOULD have cost on the API, which is also
# the only honest way to set one session beside another.
# Two things follow from an absolute mattering where only a ratio
# did. A STALE PRICE IS NOW A WRONG NUMBER and not merely a misplaced
# slice, so the table was checked against the published rates and
# gained claude-fable-5-1 and claude-mythos-5-1. And Fable 5.1 does
# not obey the tenth-of-the-input-rate rule every other model follows
# for cache reads: it prices them FLAT at $0.25/MTok, where a tenth
# of $10 would say $1.00 -- four times too much, and on a cache-heavy
# run cache reads are most of the bill. CACHE_READ_FLAT carries the
# exception; --reprice is what makes a price change reach the turns
# already in spend.tsv, and --rescan is NOT: a rebuild can only see
# the transcripts that still exist, and Claude Code prunes them --
# 169 sessions were in the table here against 112 files left on disk,
# so the rebuild dropped a third of the record, and $3,404 with it.
# --reprice multiplies the token counts already in the table by the
# new rates and loses nothing; --rescan now keeps what it replaces in
# spend.tsv.bak and says how many turns it could no longer see.
# spend.tsv is also written in TIME ORDER now. It never was: an
# incremental scan appends one transcript after another and --rescan
# walked the whole of ~/.claude/projects the same way, so the end of
# the file was whichever project came last in the glob, not whichever
# turn came last in the day. last_activity() reads only the final
# 64 kB and takes the newest stamp it finds there, so after a rescan
# it could report a project's last turn instead of the machine's --
# and the daemon's idle back-off would double away from a session
# that was in full flow. Each batch is sorted before it is appended,
# which makes a rebuilt file sorted outright.
# V1.4.0: --plot on its own no longer leaves a new file behind every time
# it is run. A plot is a live view of a moving window, not a document,
# and the fresh-tag-per-run rule meant that watching the allowance
# over an afternoon silted ~/.claudemeter up with a dozen files, of
# which only the last was worth anything -- and the tab already open
# on the previous one went stale rather than following along. So a
# bare --plot now REFRESHES the most recent plot when that plot is
# less than twelve hours old, printing the same path and keeping the
# same tag; only when the newest one has gone cold (or there is none)
# does it take a fresh tag from `af`. Twelve hours is chosen to be
# longer than a working session and shorter than a night, so a plot
# stays one file for as long as one keeps looking at it and the next
# day starts a new one that can be referred to by its own name.
# --plot TAG is unchanged and still writes over exactly that file;
# --plot new (or --new-plot) forces a fresh tag when one is wanted.
# V1.3.2: the plot was in pieces, and the cause was ONE SECOND. The server
# stamps resets_at with sub-second noise straddling a second boundary
# -- 19:09:59.798902 on one probe, 19:10:00.520943 on the next, the
# SAME window both times -- and iso() truncated, so the reset appeared
# to flip 19:09:59 / 19:10:00 / 19:09:59 as the probes came in. The
# segment rule breaks the curve wherever the reset changes, because a
# real turnover is a cliff and must never be drawn as a slope, so 67
# of these phantom turnovers cut a 146-point session curve into 74
# segments, most of them a single point. A one-point segment draws as
# a dot: hence a panel of scattered dots that looked like sparse
# measurement and was nothing of the kind. Two changes. iso() ROUNDS
# to the nearest second instead of truncating, and window identity is
# a TOLERANCE, not an equality -- same_window() calls two resets the
# same window when they are within two minutes, which no genuine
# turnover ever is (the shortest window is five hours).
# V1.3.1: a five-hour window with nothing in it is no longer thrown away.
# When nothing has been spent in five hours there is no window open,
# so the server sends `percent: 0` with `resets_at: null` -- and
# windows() dropped the whole reading over the missing reset, losing
# ten measurements of "the session limit is at zero" in the first day
# alone. They were exactly the overnight ones, so the session panel
# looked unmeasured across the night when in truth it had been
# measured nine times and read zero every time. A closed window now
# comes through with reset and start as None, and every reader says
# "closed" rather than counting down to a reset that does not exist.
# The plot draws it: the curve falls to zero at the reset and stays
# there. The cliff rule had to be narrowed to keep that -- a segment
# breaks when the reset CHANGES ON AN OPEN window (a real turnover,
# drawn as a cliff, never as a slope), but closed-to-open is a rise
# from zero and joins up.
# V1.3.0: --plot takes a tag, and writes over the plot that tag already
# names instead of adding another file beside it. --plot on its own
# still takes a fresh tag from `af`; --plot 04FF0 refreshes
# claudemeter-04FF0.html in place, so a plot kept open in a tab, or
# linked to from somewhere, updates under the name it already has.
# (~/.claudemeter had twenty-two near-identical HTML files in it by
# the time this was written, all but the last of them dead.) The tag
# may be given as a bare tag, a filename or the full path the tool
# printed -- whichever is to hand. A tag naming no file is not taken
# as a new name: it is almost always a mistyped or half-remembered
# one, so it says so and falls through to a fresh tag rather than
# filing the plot under something that means nothing.
# V1.2.2: -v says the version, and the link from --plot is one a terminal
# will actually take. 1.2.1 wrapped a bare PATH in OSC 8, which is the
# polite way and leaves a terminal that does not read OSC 8 with a
# path -- and a path is not a link to anything. What is printed now is
# the file:// URL itself, which Konsole's ordinary URL filter finds on
# its own (its default AllowedLinkSchema is http, https and file), with
# the OSC 8 still round it for terminals that do read it, and with the
# BEL terminator rather than ESC-backslash, which is the form every
# one of them accepts. Ctrl+click follows it in Konsole unless
# OpenLinksByDirectClick is set in the profile.
# V1.2.1: --plot prints the file as a terminal hyperlink, so it opens on a
# click instead of being copied out and pasted into a browser. Only
# when there is a terminal to click in: piped or redirected it stays a
# bare path, because `p=$(claudemeter --plot)` is how everything else
# gets hold of the file.
# V1.2.0: the time axis zooms. Drag across any chart to keep that stretch
# of it; a rail under the tiles shows the whole period with the kept
# stretch as a window you can slide by its middle or stretch by either
# edge. Double-click a chart, press Escape, or the "Show all" button
# to come back. Every chart reads one window, so they cannot drift out
# of step; the y axis and the time ticks rescale to what is on screen,
# an hour of history getting hour-and-minute labels where a week gets
# days. The table underneath stays over the whole period on purpose,
# and says so: it is the only place the projects outside the stack's
# top five are still counted separately.
# V1.1.0: it asks GET /api/oauth/usage — which is what Claude Code's own
# /usage reads — instead of buying the figures with a Haiku turn. No
# model runs, so a probe now costs NOTHING: zero tokens, 1.9 kB. It
# also carries limits the headers never mentioned, including the
# weekly cap scoped to ONE MODEL, which on a Fable week is the first
# one to bite. Every limit in force gets its own bar, its own panel
# and its own line in the status bar. And the idle back-off finally
# works: it was testing transcript MTIME, but an open session keeps
# stamping its file long after the last real turn (67 minutes stale,
# measured), so it read "busy" almost always. It now asks the priced
# turns themselves when the last one was.
# V1.0.3
# V1.0.3: latest() seeks to the end of probes.jsonl instead of reading it
# whole, so --statusline stays the same cost on day 400 as on day one.
# V1.0.2
# V1.0.2: the header carries two decimals OF A FRACTION, so it can only ever
# say 72% or 73% — never 72.31%. The bars round to the point they are
# actually measured to; only the attributed shares, which are divided
# out of it, keep a decimal.
# V1.0.1
# V1.0.1: the utilisation headers are FRACTIONS of the allowance, not per
# cents — 0.71 means 71% gone, not 0.71%. Every reader now goes through
# util(), which converts once. (fallback-percentage: 0.5 in the same
# header block should have given it away.)
# V1.0.0
# How much of the 5-hour and weekly Claude allowance is gone, over time,
# and which session spent it.
#
# TWO SOURCES, and neither one alone answers the question.
#
# THE TRUTH IS IN THE HEADERS. Every call to api.anthropic.com comes back
# with anthropic-ratelimit-unified-5h-utilization and -7d-utilization: the
# real percentages, for the whole account, counting the phone and claude.ai
# and every other machine, weighted the way Anthropic actually weighs them.
# Nothing local can reproduce those numbers. So they are asked for, with a
# request small enough to be free in every practical sense: one Haiku turn,
# max_tokens=1, twenty-odd tokens in and one out, about 2 kB on the wire.
# The percentages it reports include itself, and it does not move them.
#
# THE BREAKDOWN IS IN THE TRANSCRIPTS. A header gives one number for the
# whole account and says nothing about who spent it. ~/.claude/projects/
# *//*.jsonl does: every assistant turn carries its model, its four token
# counts and its timestamp. Priced at the published per-MTok rates (cache
# reads a tenth, cache writes a quarter or double), that gives each session
# a share of the window, and the header total makes the shares absolute.
#
# So: the bars are measured, the attribution underneath them is inferred.
# A session that spends nothing locally but shows up in the headers is
# "elsewhere" — the web app, the phone, another machine.
#
# THE COST OF WATCHING. A probe is ~0.0001 c of Haiku and 2 kB of traffic.
# The daemon probes every 5 min while there is Claude activity on this
# machine, and backs off by doubling to an hour when there is not, so an
# idle day costs about two dozen probes. The transcript scan is incremental
# (byte offsets are remembered) and a full cold rebuild of 550 MB takes a
# third of a second. Nothing is uploaded; the only thing that leaves the
# machine is the word "hi".
#
# THE TOKEN IS NEVER REFRESHED. ~/.claude/.credentials.json holds a
# rotating OAuth pair, and refreshing it from here could rotate the copy
# Claude Code is holding and log him out. So when it goes stale the daemon
# stops probing and says so; opening Claude Code renews it.
#
# claudemeter the two bars, now
# claudemeter -s per-session breakdown of the 5-hour window
# claudemeter -s -w ... of the weekly window
# claudemeter --probe ask the server once, right now
# claudemeter --daemon the poller (systemctl --user start claudemeter)
# claudemeter --plot [-d N] an HTML timeline of the last N days
# claudemeter --plot TAG ... written over the plot TAG names
# claudemeter --cost what it would all have cost at list prices
# claudemeter --reprice redo those dollars after a price change
# claudemeter --statusline one line, for Claude Code's statusLine
# claudemeter --statusline --cost ... with the window's dollars on it
# claudemeter --json everything, machine-readable
#
# Written 30 August 2026.
import argparse
import calendar
import fcntl
import glob
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request
from collections import defaultdict
VERSION = "1.7.0"
HOME = os.path.expanduser("~")
CREDS = os.path.join(HOME, ".claude", ".credentials.json")
PROJECTS = os.path.join(HOME, ".claude", "projects")
STATE = os.path.join(HOME, ".claudemeter")
PROBES = os.path.join(STATE, "probes.jsonl")
SPEND = os.path.join(STATE, "spend.tsv")
INDEX = os.path.join(STATE, "index.json")
API = "https://api.anthropic.com/api/oauth/usage"
# $ per MTok (input, output), from the published rates. These fixed only
# the RATIOS between models until 1.5.0 — the absolute scale of a share is
# set by the header above it — but --cost quotes the absolute itself, so a
# stale price is now a wrong dollar and not merely a misplaced slice.
PRICES = {
"claude-fable-5": (10.0, 50.0),
"claude-fable-5-1": (10.0, 50.0),
"claude-mythos-5": (10.0, 50.0),
"claude-mythos-5-1": (10.0, 50.0),
"claude-opus-5": (5.0, 25.0),
"claude-opus-4-8": (5.0, 25.0),
"claude-opus-4-7": (5.0, 25.0),
"claude-opus-4-6": (5.0, 25.0),
"claude-sonnet-5": (2.0, 10.0),
"claude-sonnet-4-6": (3.0, 15.0),
"claude-haiku-4-5": (1.0, 5.0),
}
DEFAULT_PRICE = (5.0, 25.0)
CACHE_READ = 0.1 # a cached token costs a tenth of a fresh one
CACHE_W5M = 1.25 # writing a 5-minute cache entry costs a quarter more
CACHE_W1H = 2.0 # ... a one-hour entry, double
# ... except where a model prices its cache reads OUTRIGHT, in $ per MTok,
# instead of as a fraction of its input rate. Fable 5.1 reads at $0.25
# where the tenth rule would say $1.00, and on a long agentic run cache
# reads are most of the tokens, so the rule would overstate it fourfold.
# (Whether Mythos 5.1 shares the rate is not published: it falls through
# to the tenth, which is the conservative way round.)
CACHE_READ_FLAT = {
"claude-fable-5-1": 0.25,
}
FIVE_HOURS = 5 * 3600
SEVEN_DAYS = 7 * 86400
# ---------------------------------------------------------------- pricing
def longest_prefix(table, model):
"""Longest published prefix wins, so date suffixes and [1m] fall through
— and so claude-fable-5-1 beats claude-fable-5 rather than inheriting
from it, which is the whole reason the rule is longest and not first."""
m = (model or "").replace("[1m]", "")
best = None
for k in table:
if m.startswith(k) and (best is None or len(k) > len(best)):
best = k
return table[best] if best is not None else None
def price_of(model):
return longest_prefix(PRICES, model) or DEFAULT_PRICE
def cache_read_price(model, pin):
flat = longest_prefix(CACHE_READ_FLAT, model)
return flat if flat is not None else CACHE_READ * pin
def weigh(model, inp, cw5, cw1h, cr, out):
"""What one turn would have cost at list prices, in dollars."""
pin, pout = price_of(model)
return (inp * pin
+ cw5 * CACHE_W5M * pin
+ cw1h * CACHE_W1H * pin
+ cr * cache_read_price(model, pin)
+ out * pout) / 1e6
# ------------------------------------------------------------ transcripts
def scan(force=False):
"""Fold new assistant turns into spend.tsv. Only the bytes that were not
there last time are read; a file that shrank or moved is re-read whole.
Under a lock: the daemon scans every few minutes and a terminal command
scans whenever it is typed, and two scanners sharing one index would both
read the same offset and both append the same turns."""
os.makedirs(STATE, exist_ok=True)
lock = open(os.path.join(STATE, ".scan.lock"), "w")
fcntl.flock(lock, fcntl.LOCK_EX)
try:
return _scan(force)
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
lock.close()
# How far apart two records with byte-identical token counts can be and
# still be one turn. Only used where the message id is gone; see rebuild().
REPEAT_WINDOW = 300
# How many message ids ride in the index, so that a turn whose blocks fall
# either side of an incremental scan is still counted once. The blocks of
# one message are written consecutively, so this only has to outlast a
# subagent writing into the same file at the same moment.
CARRY = 32
def _scan(force=False):
idx = {}
if not force and os.path.exists(INDEX):
try:
idx = json.load(open(INDEX))
except Exception:
idx = {}
if force and os.path.exists(SPEND):
# Kept, not dropped: a rebuild can only see the transcripts that
# still exist, and Claude Code prunes them. See reprice().
os.replace(SPEND, SPEND + ".bak")
rows = []
for path in glob.glob(os.path.join(PROJECTS, "*", "*.jsonl")):
try:
size = os.path.getsize(path)
except OSError:
continue
ent = idx.get(path, 0)
# Before 1.6.0 the index held a bare offset; it now also holds the
# last few message ids counted out of the file.
offset = ent.get("o", 0) if isinstance(ent, dict) else (ent or 0)
carry = list(ent.get("ids") or []) if isinstance(ent, dict) else []
if offset > size: # truncated or replaced
offset, carry = 0, []
if offset == size:
continue
project = os.path.basename(os.path.dirname(path))
session = os.path.basename(path)[:-6]
seen = set(carry)
with open(path, "rb") as fh:
fh.seek(offset)
data = fh.read()
# A partial last line is left for next time.
cut = data.rfind(b"\n") + 1
for line in data[:cut].split(b"\n"):
if b'"output_tokens":' not in line:
continue
try:
d = json.loads(line)
except Exception:
continue
msg = d.get("message") or {}
u = msg.get("usage") or {}
if not u:
continue
model = msg.get("model") or "?"
if model == "<synthetic>":
continue
# ONE API call, ONE usage -- but Claude Code writes the
# turn out one record per content block (thinking, text,
# each tool_use) and repeats the whole usage on every one
# of them. Counting records instead of messages doubled
# every figure this tool printed before 1.6.0.
mid = msg.get("id") or ""
if mid:
if mid in seen:
continue
seen.add(mid)
carry.append(mid)
ts = d.get("timestamp") or ""
try:
# The transcripts stamp UTC; timegm reads it as UTC,
# which mktime would not (it would apply the local
# offset, and get it wrong again across a DST edge).
epoch = calendar.timegm(
time.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S"))
except Exception:
continue
cc = u.get("cache_creation") or {}
cw5 = cc.get("ephemeral_5m_input_tokens", 0)
cw1h = cc.get("ephemeral_1h_input_tokens", 0)
if not (cw5 or cw1h):
cw5 = u.get("cache_creation_input_tokens", 0)
inp = u.get("input_tokens", 0)
cr = u.get("cache_read_input_tokens", 0)
out = u.get("output_tokens", 0)
w = weigh(model, inp, cw5, cw1h, cr, out)
sub = "1" if d.get("isSidechain") else "0"
rows.append(f"{epoch}\t{session}\t{project}\t{model}\t"
f"{inp}\t{cw5}\t{cw1h}\t{cr}\t{out}\t{w:.8f}\t"
f"{sub}\t{mid}")
idx[path] = {"o": offset + cut, "ids": carry[-CARRY:]}
if rows:
# In TIME order, which the glob above does not give: it walks one
# project after another, so without this the end of the file is
# whichever project sorted last, not whichever turn happened last —
# and last_activity() reads only the end of the file. Sorting each
# batch makes a --rescan sorted outright and an incremental append
# out of order by at most one batch.
rows.sort(key=lambda r: int(r.split("\t", 1)[0]))
with open(SPEND, "a") as fh:
fh.write("\n".join(rows) + "\n")
json.dump(idx, open(INDEX, "w"))
return len(rows)
def rebuild():
"""Rebuild the spend table from the transcripts without losing the
sessions whose transcript is no longer there.
A plain rebuild cannot be right on its own: Claude Code prunes old
transcripts, and 169 sessions were in the table here against 112 files
left on disk, so rebuilding from the files alone dropped a third of the
record. The live sessions are therefore rebuilt from their transcripts
-- which is the only way to apply the message.id rule of 1.6.0 -- and
the pruned ones are carried across from the old table with their
repeats collapsed the only way left without an id: identical token
counts, in the same session, inside REPEAT_WINDOW. Where both methods
can be measured against each other that collapse reproduces the id
answer to 0.13%, which is as close as a heuristic is going to get."""
live = {os.path.basename(f)[:-6]
for f in glob.glob(os.path.join(PROJECTS, "*", "*.jsonl"))}
orphans, was, kept = [], 0.0, 0.0
if os.path.exists(SPEND):
last = {}
for line in open(SPEND):
f = line.rstrip("\n").split("\t")
if len(f) < 11:
continue
was += float(f[9])
if f[1] in live:
continue # rebuilt from the transcript
e, key = int(f[0]), tuple(f[1:9])
if last.get(key) is not None and e - last[key] <= REPEAT_WINDOW:
last[key] = e
continue # a second block of one turn
last[key] = e
kept += float(f[9])
orphans.append((e, "\t".join(f)))
os.replace(SPEND, SPEND + ".bak")
if os.path.exists(INDEX):
os.remove(INDEX)
n = scan(force=True)
lock = open(os.path.join(STATE, ".scan.lock"), "w")
fcntl.flock(lock, fcntl.LOCK_EX)
try:
rows = []
if os.path.exists(SPEND):
rows = [(int(l.split("\t", 1)[0]), l.rstrip("\n"))
for l in open(SPEND) if l.count("\t") >= 10]
rows += orphans
rows.sort(key=lambda r: r[0])
with open(SPEND, "w") as fh:
fh.write("\n".join(r[1] for r in rows) + "\n")
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
lock.close()
now = sum(float(r[1].split("\t")[9]) for r in rows)
return n, len(orphans), was, now
def reprice():
"""Recompute the dollar column of spend.tsv from the token counts that
are already in it, and put the file in time order.
This is what to run after a price changes, NOT --rescan. A rescan
rebuilds the table from ~/.claude/projects, and Claude Code prunes old
transcripts: 169 sessions were in the table here against 112 files left
on disk, so a rebuild silently dropped a third of the record and a
third of the money with it. The token counts never need rebuilding —
only what they are multiplied by."""
if not os.path.exists(SPEND):
return 0, 0.0, 0.0
lock = open(os.path.join(STATE, ".scan.lock"), "w")
fcntl.flock(lock, fcntl.LOCK_EX)
try:
rows, was, now = [], 0.0, 0.0
with open(SPEND) as fh:
for line in fh:
f = line.rstrip("\n").split("\t")
if len(f) < 11:
continue
w = weigh(f[3], int(f[4]), int(f[5]), int(f[6]),
int(f[7]), int(f[8]))
was += float(f[9])
now += w
f[9] = f"{w:.8f}"
rows.append((int(f[0]), "\t".join(f)))
rows.sort(key=lambda r: r[0])
tmp = SPEND + ".new"
with open(tmp, "w") as fh:
fh.write("\n".join(r[1] for r in rows) + "\n")
os.replace(tmp, SPEND)
return len(rows), was, now
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
lock.close()
def one_per_message(fields, seen):
"""True the first time a turn is met, False for every copy after it.
The table can hold a turn twice over even with the scan counting one
record per message: resuming a conversation copies the transcript into
a fresh file under a fresh session id, so everything before the resume
is filed under both. The copy keeps the original's timestamp to the
second, so the two are identical but for the session, and whichever the
sorted table reaches first is the one credited -- arbitrary between a
session and its own continuation, and wrong for neither."""
mid = fields[11] if len(fields) > 11 else ""
if not mid:
return True # written before 1.6.0, or a turn with no id
if mid in seen:
return False
seen.add(mid)
return True
def spend_rows(since=0):
if not os.path.exists(SPEND):
return []
out, seen = [], set()
with open(SPEND) as fh:
for line in fh:
f = line.rstrip("\n").split("\t")
if len(f) < 11 or not one_per_message(f, seen):
continue
e = int(f[0])
if e < since:
continue
out.append((e, f[1], f[2], f[3], float(f[9]), f[10] == "1"))
return out
def last_activity():
"""When the last turn was actually PAID FOR — not when a file was last
touched. A Claude Code session left open keeps stamping housekeeping
into its transcript long after the conversation stopped (67 minutes
stale, measured), so an mtime says "busy" almost always, and the idle
back-off it was gating never fired."""
try:
with open(SPEND, "rb") as fh:
fh.seek(0, os.SEEK_END)
fh.seek(max(0, fh.tell() - 65536))
tail = fh.read().split(b"\n")
except OSError:
return 0
newest = 0
for line in tail:
f = line.split(b"\t")
if len(f) < 11:
continue
try:
newest = max(newest, int(f[0]))
except ValueError:
pass
return newest
# ------------------------------------------------------------------ probe
def token():
try:
d = json.load(open(CREDS))["claudeAiOauth"]
except Exception as e:
return None, f"credentials unreadable ({e})"
if d.get("expiresAt", 0) / 1000.0 < time.time():
return None, "token stale — open Claude Code once to renew it"
return d["accessToken"], None
def probe(record=True):
"""Ask what is left. A plain GET against the endpoint Claude Code's own
/usage reads: no model runs, so it is billed nothing at all — the earlier
trick of buying the figures with a one-token Haiku turn is retired."""
tok, err = token()
if not tok:
return None, err
req = urllib.request.Request(API, headers={
"authorization": f"Bearer {tok}",
"anthropic-beta": "oauth-2025-04-20",
"accept": "application/json",
"user-agent": f"claudemeter/{VERSION}",
})
try:
with urllib.request.urlopen(req, timeout=30) as r:
payload = json.loads(r.read())
except urllib.error.HTTPError as e:
return None, f"HTTP {e.code}"
except Exception as e:
return None, str(e)
if not isinstance(payload, dict) or "limits" not in payload:
return None, "unrecognised reply"
rec = {"t": int(time.time()), "u": payload}
if record:
os.makedirs(STATE, exist_ok=True)
with open(PROBES, "a") as fh:
fh.write(json.dumps(rec) + "\n")
return rec, None
def probes(since=0):
if not os.path.exists(PROBES):
return []
out = []
with open(PROBES) as fh:
for line in fh:
try:
d = json.loads(line)
except Exception:
continue
if d.get("t", 0) >= since:
out.append(d)
return out
def latest():
"""The last probe, without reading the file that holds all of them —
--statusline is run on every redraw of the terminal, so it may not grow
a cost as the record does."""
try:
with open(PROBES, "rb") as fh:
fh.seek(0, os.SEEK_END)
end = fh.tell()
fh.seek(max(0, end - 4096))
tail = fh.read().split(b"\n")
except OSError:
return None
for line in reversed(tail):
if not line.strip():
continue
try:
return json.loads(line)
except Exception:
continue
return None
# ----------------------------------------------------------------- window
SPAN = {"session": FIVE_HOURS}
def span_name(key):
"""How long the window a key names covers, said in words.
Only wanted when a window is CLOSED, where the message is "nothing
spent in the last five hours" and there is no reset to quote."""
return "five hours" if key.split(":", 1)[0] == "session" else "seven days"
def iso(t):
"""An ISO stamp as a whole number of seconds, ROUNDED, not truncated.
resets_at carries microseconds, and they wander either side of the
minute the window actually turns over on: 19:09:59.798902 from one
probe and 19:10:00.520943 from the next, the same window both times.
Truncating made those two different integers and so two windows."""
try:
return round(__import__("datetime").datetime.fromisoformat(t).timestamp())
except Exception:
return None
# Two resets this close are the same window. Nothing real moves a reset by
# less than the window's own span, and the shortest is five hours, so the
# only thing a tolerance can swallow is the server's own jitter -- which is
# sub-second, and which rounding mostly settles anyway. Equality here is
# what cut the session curve into single-point segments.
SAME_WINDOW = 120
def same_window(a, b):
"""Whether two readings belong to the same window.
Both closed is the same window (no window at all, held over); one closed
and one open is a genuine change and must break the line."""
if a is None or b is None:
return a is None and b is None
return abs(a - b) <= SAME_WINDOW
def windows(rec):
"""Every limit in force, newest reading first.
Returns (key, label, per cent, window start, reset, active). There are
more than two: besides the five-hour session and the flat weekly cap
there is a weekly cap scoped to a single model, and on a week spent in
one model that is the one that runs out first. The shape is whatever
the account has, so nothing here is hard-coded to a pair."""
if "u" in rec:
out = []
for lim in rec["u"].get("limits") or []:
kind = lim.get("kind") or "?"
reset = iso(lim.get("resets_at") or "")
pct = lim.get("percent")
if pct is None:
continue
# A CLOSED window: a reading with no reset. Nothing has been
# spent for a whole span, so no window is open and the server
# has no turnover time to name -- but `percent` is there and it
# is 0, which is a measurement, not a hole. Dropping it over
# the missing reset (which 1.3.0 did) blanked the session panel
# across every quiet night. It comes through with reset and
# start as None, and each reader decides what to say.
span = SPAN.get(kind, SEVEN_DAYS)
start = reset - span if reset is not None else None
scope = ((lim.get("scope") or {}).get("model") or {}).get("display_name")
key = kind + (":" + scope if scope else "")
label = ("5-hour session" if kind == "session"
else f"weekly · {scope}" if scope else "weekly")
out.append((key, label, float(pct), start, reset,
bool(lim.get("is_active"))))
return out
# Records written before 1.1.0 hold raw headers, whose utilisation is a
# FRACTION. They are kept readable so the history joins up.
h, out = rec.get("h", {}), []
for hk, key, label, span in (("5h", "session", "5-hour session", FIVE_HOURS),
("7d", "weekly_all", "weekly", SEVEN_DAYS)):
try:
pct = float(h[f"{hk}-utilization"]) * 100.0
reset = int(h[f"{hk}-reset"])
except (KeyError, ValueError):
continue
out.append((key, label, pct, reset - span, reset, False))
return out
def attribute(u, start, end, group):
"""Split a measured utilisation across the sessions that were spending
inside its window. Exact in sum; the split is priced, not measured."""
rows = [r for r in spend_rows(start) if r[0] <= end]
tot = sum(r[4] for r in rows) or 0.0
buckets = defaultdict(float)
for e, sess, proj, model, w, sub in rows:
if group == "session":
key = (sess, proj)
elif group == "project":
key = (proj, "")
else:
key = (model, "")
buckets[key] += w
share = []
for key, w in buckets.items():
share.append((key, u * w / tot if tot else 0.0, w))
share.sort(key=lambda x: -x[1])
return share, tot
# ------------------------------------------------------------------- cost
def money(d):
"""Dollars, always to the cent: a column of them has to line up, and
below a dollar the cents are the whole of the number."""
return f"${d:,.2f}"
def totals(rows, start=0, end=None, group=None):
"""What a stretch of the record would have cost at list prices, and —
with a group — what each session, project or model in it came to.
The rows are handed in rather than read: every caller wants several
stretches of the same record, and the record is read whole."""
end = time.time() if end is None else end
sel = [r for r in rows if start <= r[0] <= end]
tot = sum(r[4] for r in sel)
if group is None:
return tot, []
i = {"session": 1, "project": 2, "model": 3}[group]
by = defaultdict(float)
for r in sel:
by[r[i]] += r[4]
return tot, sorted(by.items(), key=lambda kv: -kv[1])
def rows_since(start, tail=4 << 20):
"""The priced turns since `start`, read from the END of spend.tsv when
the end is enough to hold them.
--statusline runs on every redraw of the terminal and must not grow a
cost as the record does, which is why latest() seeks rather than reads.
The same applies here — but a tail can only be trusted when it reaches
back past `start`, so if its oldest row is younger than that the whole
file is read after all. (The tail is trustworthy at all only because
scan() now appends in time order.)"""
try:
with open(SPEND, "rb") as fh:
fh.seek(0, os.SEEK_END)
end = fh.tell()
fh.seek(max(0, end - tail))
chunk = fh.read()
except OSError:
return []
if end > tail:
chunk = chunk[chunk.find(b"\n") + 1:] # drop the half line
out, seen = [], set()
for line in chunk.decode("utf-8", "replace").split("\n"):
f = line.split("\t")
if len(f) < 11 or not one_per_message(f, seen):
continue
e = int(f[0])
if e >= start:
out.append((e, f[1], f[2], f[3], float(f[9]), f[10] == "1"))
if end > tail and (not out or min(r[0] for r in out) > start):
return spend_rows(start)
return out
def window_costs(rows, wins):
"""(five-hour, weekly) dollars for the windows a probe reports, either
of which is None when that window is not open."""
five = next((w for w in wins if w[0] == "session" and w[3] is not None), None)
week = next((w for w in wins if w[0] != "session" and w[3] is not None), None)
return (totals(rows, five[3])[0] if five else None,
totals(rows, week[3])[0] if week else None)
# ---------------------------------------------------------------- display
RESET, DIM, BOLD = "\033[0m", "\033[2m", "\033[1m"
def colour(u):
return "\033[32m" if u < 50 else "\033[33m" if u < 80 else "\033[31m"
def bar(u, width=40):
filled = int(round(width * min(u, 100) / 100.0))
return colour(u) + "█" * filled + DIM + "·" * (width - filled) + RESET
def hms(sec):
sec = max(0, int(sec))
d, sec = divmod(sec, 86400)
h, sec = divmod(sec, 3600)
m = sec // 60
if d:
return f"{d}d {h}h"
if h:
return f"{h}h {m:02d}m"
return f"{m}m"
def rate_and_eta(u, start, reset):
"""Burn rate over the window so far, and when that rate would exhaust it.
A closed window has neither, and says so with a rate of zero: there is
no elapsed time to divide by and no reset to run out at."""
if start is None or reset is None:
return 0.0, None
now = time.time()
elapsed = max(now - start, 60)
per_hour = u / (elapsed / 3600.0)
if per_hour <= 0.01:
return per_hour, None
eta = now + (100.0 - u) / per_hour * 3600.0
return per_hour, (eta if eta < reset else None)
def status(args):
scan()
rec = latest()
# Asking costs nothing now, so a typed command asks rather than reciting
# whatever the daemon last wrote. If the link is down, the record stands.
if not getattr(args, "offline", False) and (
not rec or time.time() - rec["t"] > 120):
fresh, err = probe()
rec = fresh or rec
if not rec:
print("No probe yet. Run claudemeter --probe "
"or start the daemon.", file=sys.stderr)
return 1
wins = windows(rec)
age = time.time() - rec["t"]
print()
for key, label, u, start, reset, active in wins:
per_hour, eta = rate_and_eta(u, start, reset)
mark = f"{BOLD}\u25b8{RESET} " if active else " "
print(f" {mark}{BOLD}{label:<15}{RESET}{bar(u)} {colour(u)}{u:4.0f}%{RESET}")
if reset is None:
print(f" {DIM}{'':<15}no window open — nothing spent in "
f"the last {span_name(key)}{RESET}\n")
continue
line = (f" {DIM}{'':<15}resets in {hms(reset - time.time()):<9}"
f" ({time.strftime('%a %H:%M', time.localtime(reset))})"
f" {per_hour:.1f}%/h")
if eta:
line += (f" {colour(90)}spent by "
f"{time.strftime('%a %H:%M', time.localtime(eta))}{RESET}{DIM}")
print(line + RESET)
print()
if any(a for *_, a in wins):
print(f" {DIM}\u25b8 the limit this week's work is actually "
f"charged against{RESET}")
rows = spend_rows(0)
five, week = window_costs(rows, wins)
bits = []
if five is not None:
bits.append(f"{DIM}five hours{RESET} {money(five)}")
if week is not None:
bits.append(f"{DIM}this week{RESET} {money(week)}")
if rows:
bits.append(f"{DIM}all told{RESET} {money(sum(r[4] for r in rows))}")
if bits:
print(f" {DIM}at API list prices{RESET} "
+ f" {DIM}\u00b7{RESET} ".join(bits))
when = "just now" if age < 90 else f"{hms(age)} ago"
stale = f"{colour(95)}stale{RESET} " if age > 1800 else ""
print(f" {DIM}measured {stale}{when} "
f"({len(probes())} probes on record, none of them billed){RESET}")
print()
return 0
def pick(wins, weekly):
"""The window a breakdown is about: the five-hour one, or — with -w — the
weekly limit that is furthest along, which is the one that will bite."""
cand = [w for w in wins if (w[0] != "session") == bool(weekly)]
if not cand:
return None
return max(cand, key=lambda w: w[2]) if weekly else cand[0]
def sessions(args):
scan()
rec = latest()
if not rec:
print("No probe yet.", file=sys.stderr)
return 1
win = pick(windows(rec), args.weekly)
if not win:
print("No such window in the last probe.", file=sys.stderr)
return 1
key, label, u, start, reset, active = win
if start is None:
print(f"\n {DIM}the {label} window is not open — nothing spent in "
f"the last {span_name(key)}.{RESET}\n")
return 0
group = "model" if args.models else "project" if args.projects else "session"
share, tot = attribute(u, start, time.time(), group)
print()
print(f" {BOLD}{label} window{RESET} {colour(u)}{u:.0f}%{RESET} used"
f" {DIM}since {time.strftime('%a %H:%M', time.localtime(start))}{RESET}")
print()
if not share or u <= 0:
print(f" {DIM}nothing spent from this machine in this window.{RESET}\n")
return 0
for (a_, b_), pct, w in share[:args.top]:
if pct < 0.05:
continue
name = a_ if group != "session" else f"{a_[:8]} {DIM}{b_}{RESET}"
print(f" {pct:6.1f}% {bar(pct * 100 / max(u, 0.01), 20)}"
f" {money(w):>10} {name}")
print(f" {DIM}{'':6} {'':20} {money(tot):>10} "
f"at API list prices, none of it billed{RESET}")
print()
print(f" {DIM}shares are priced from the local transcripts and "
f"normalised to the {u:.0f}% the server reports;{RESET}")
print(f" {DIM}anything spent on claude.ai or another machine is "
f"inside that total but not in the list.{RESET}")
print()
return 0
def midnight(now):
"""The start of the LOCAL day `now` is in — not now minus 86400, which
is a rolling day and answers a different question."""
t = time.localtime(now)
return time.mktime(t[:3] + (0, 0, 0, 0, 0, -1))
def pretty(name):
"""A project key as a path again. They are directories with the
slashes turned into dashes, so the home prefix is every one of them
and says nothing; what is left is where the work was."""
if name.startswith("-home-laussy"):
return name[len("-home-laussy"):].lstrip("-") or "~"
return name
def cost_report(args):
"""What the turns on this machine would have cost at list prices.
Nothing here is a bill: the subscription pays for these turns and the
figure exists to set one stretch of work beside another, and to say
what the same work would come to through the API."""
scan()
rows = spend_rows(0)
if not rows:
print("Nothing priced yet — run claudemeter --rescan", file=sys.stderr)
return 1
now = time.time()
rec = latest()
wins = windows(rec) if rec else []
five = next((w for w in wins if w[0] == "session" and w[3] is not None), None)
week = next((w for w in wins if w[0] != "session" and w[3] is not None), None)
first = min(r[0] for r in rows)
since = lambda t: "since " + time.strftime("%a %-d %b, %H:%M",
time.localtime(t))
spans = [("the 5-hour window", five[3] if five else None,
since(five[3]) if five else "no window open"),
("today", midnight(now), "since midnight"),
("the weekly window", week[3] if week else None,
since(week[3]) if week else "no window open"),
("30 days", now - 30 * 86400, since(now - 30 * 86400)),
("all time", first, since(first))]
print()
print(f" {BOLD}what it would have cost at the published API rates{RESET}")
print()
for label, start, note in spans:
amount = "" if start is None else money(totals(rows, start)[0])
print(f" {label:<20}{amount:>13} {DIM}{note}{RESET}")
print()
# A breakdown of the longest window the server is actually holding
# open, since that is the stretch the bars above are about.
base = (week[3] if week else five[3] if five else midnight(now))
head = ("the weekly window" if week
else "the 5-hour window" if five else "today")
for group in ("model", "project"):
tot, rank = totals(rows, base, group=group)
if not rank:
continue
shown = [(pretty(n), w) for n, w in rank[:args.top]]
# The column is as wide as it has to be, not as wide as the longest
# project path in existence: one deep tree would otherwise push the
# money off the far side of the terminal for every row.
wide = min(max((len(n) for n, _ in shown), default=0), 44)
print(f" {BOLD}by {group}{RESET} {DIM}over {head}{RESET}")
print()
for name, w in shown:
if len(name) > wide:
name = name[:wide - 1] + "\u2026"
pct = 100 * w / tot if tot else 0
print(f" {name:<{wide}}{money(w):>12} {DIM}{pct:4.1f}%{RESET}")
print()
print(f" {DIM}cache reads are priced at a tenth of the input rate "
f"(Fable 5.1 flat at $0.25/MTok),{RESET}")
print(f" {DIM}5-minute cache writes at 1.25\u00d7 and 1-hour writes at "
f"2\u00d7 it. Nothing here is billed:{RESET}")
print(f" {DIM}the subscription covers these turns, and whatever was "
f"spent on claude.ai or{RESET}")
print(f" {DIM}another machine is not in the figures at all.{RESET}")
print()
return 0
def statusline(args):
rec = latest()
if not rec:
return 0
bits = []
for key, label, u, start, reset, active in windows(rec):
tag = ("5h" if key == "session"
else key.split(":", 1)[1] if ":" in key else "7d")
bits.append(f"{tag} {u:.0f}%" if reset is None
else f"{tag} {u:.0f}% ({hms(reset - time.time())})")
if getattr(args, "cost", False):
# Opt-in, because it is the one thing here that has to look at the
# spend table at all — rows_since() reads the tail of it, not all.
five = next((w for w in windows(rec)
if w[0] == "session" and w[3] is not None), None)
if five:
bits.append(money(totals(rows_since(five[3]), five[3])[0]))
print(" \u00b7 ".join(bits))
return 0
def as_json(args):
scan()
rec = latest()
rows = spend_rows(0)
now = time.time()
out = {"version": VERSION, "probe": rec, "windows": [],
# Dollars at the published per-MTok rates, for the turns this
# machine wrote. Not a bill — the subscription covers them.
"cost": {"currency": "USD", "basis": "api-list-prices",
"today": totals(rows, midnight(now))[0],
"last_30_days": totals(rows, now - 30 * 86400)[0],
"all_time": sum(r[4] for r in rows),
"since": min((r[0] for r in rows), default=None)}}
if rec:
for key, label, u, start, reset, active in windows(rec):
share = [] if start is None else \
attribute(u, start, time.time(), "session")[0]
out["windows"].append({
"key": key, "label": label, "percent": u, "active": active,
"start": start, "reset": reset, "measured_at": rec["t"],
"cost": None if start is None else totals(rows, start)[0],
"sessions": [{"session": a_, "project": b_, "percent": p,
"weight": w, "cost": w}
for (a_, b_), p, w in share],
})
print(json.dumps(out, indent=2))
return 0
# ----------------------------------------------------------------- daemon
def daemon(args):
base, cap = args.interval, args.max_interval
interval = base
last_u = None
while True:
scan()
rec, err = probe()
now = time.time()
if err:
# A stale token or a dead link is not worth hammering.
interval = min(max(interval * 2, 600), cap)
print(f"[{time.strftime('%H:%M:%S')}] {err} "
f"— next in {hms(interval)}", flush=True)
else:
wins = windows(rec)
sess = [w for w in wins if w[0] == "session"]
u = sess[0][2] if sess else max([w[2] for w in wins] or [0])
moved = last_u is None or abs(u - last_u) >= 1
busy = (now - last_activity()) < 2 * base
hot = u >= 50
interval = base if (moved or busy or hot) else min(interval * 2, cap)
last_u = u
print(f"[{time.strftime('%H:%M:%S')}] "
+ " ".join(f"{l} {p:.0f}%" for _, l, p, *_ in wins)
+ f" — next in {hms(interval)}", flush=True)
time.sleep(interval)
# ------------------------------------------------------------------- plot
# The reference categorical palette, in its validated order. Adjacent pairs
# clear the CVD and normal-vision floors in both modes; the light steps for
# aqua, yellow and magenta sit under 3:1 on a light surface, which is why the
# stack ships a legend with visible labels AND a table view underneath.
SERIES_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300"]
SERIES_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"]
def af_tag():
"""A fresh 5-char tag from ~/bin/AF, so the file can be referred to later."""
import subprocess
try:
t = subprocess.run(["af"], capture_output=True, text=True, timeout=5)
tag = t.stdout.strip().split()[-1]
if tag and len(tag) <= 8:
return tag
except Exception:
pass
return time.strftime("%m%d%H%M")
# How stale the newest plot may be and still be refreshed in place by a
# bare --plot. Longer than a working session, shorter than a night: a plot
# stays one file for as long as one keeps looking at it, and tomorrow's is
# a new file with a name of its own.
PLOT_REFRESH = 12 * 3600
def newest_plot():
"""The most recently written plot in STATE, as (tag, path, age), or None."""
best = None
for path in glob.glob(os.path.join(STATE, "claudemeter-*.html")):
try:
mtime = os.path.getmtime(path)
except OSError:
continue
if best is None or mtime > best[1]:
best = (path, mtime)
if best is None:
return None
path, mtime = best
tag = os.path.basename(path)[len("claudemeter-"):-len(".html")]
return tag, path, time.time() - mtime
def plot_target(want):
"""Where the HTML goes, and under which tag.
A plot is a view of a window that keeps moving, so re-running --plot
while one is still being looked at should move THAT plot on rather than
add another file beside it. A bare --plot therefore writes over the
most recent plot when it is less than PLOT_REFRESH old -- same path,
same tag, so a tab left open on it, or a link to it, follows along --
and takes a fresh tag from `af` only when the newest plot has gone cold
or there is none. `--plot new` forces a fresh tag when one is wanted.
--plot TAG writes over the file that tag already names, whatever its
age. The tag may arrive as a bare tag, as a filename, or as the whole
path the tool itself printed, so whatever is to hand can be handed back.
A TAG naming no file is not an error and not a new name either: it is
almost always a mistyped or half-remembered one, and honouring it would
file the plot under a tag that means nothing. It says so on stderr --
stderr, so that `p=$(claudemeter --plot 04FF0)` still yields the path
and nothing else -- and takes a fresh tag rather than touching
the plot a bare --plot would have refreshed."""
if want is not True and want and str(want).lower() not in ("new", "fresh"):
tag = os.path.basename(str(want))
if tag.endswith(".html"):
tag = tag[:-len(".html")]
if tag.startswith("claudemeter-"):
tag = tag[len("claudemeter-"):]
out = os.path.join(STATE, f"claudemeter-{tag}.html")
if tag and os.path.exists(out):
return tag, out
print(f"No plot tagged {tag} in {STATE} \u2014 writing a new one instead.",
file=sys.stderr)
elif want is True:
recent = newest_plot()
if recent and recent[2] < PLOT_REFRESH:
return recent[0], recent[1]
tag = af_tag()
return tag, os.path.join(STATE, f"claudemeter-{tag}.html")
def calibrate(recs):
"""Per cent of the weekly allowance per dollar-equivalent of local spend.
Every probe carries both the truth (its 7d utilisation) and, through the
transcripts, the local weight inside that same window. Their ratio is the
conversion; the median over the probes shrugs off the ones taken when the
window held almost nothing, and off whatever was spent on claude.ai."""
ks = []
for r in recs:
for key, label, u, start, reset, active in windows(r):
if key == "session" or ":" in key or start is None:
continue # the flat weekly cap is the one to scale by
if u < 1.0: # too little in the window to fix a ratio by
continue
w = sum(x[4] for x in spend_rows(start) if x[0] <= r["t"])
if w > 0:
ks.append(u / w)
if not ks:
return None
ks.sort()
return ks[len(ks) // 2]
def link(path):
"""The file, printed so that a terminal will let you click it.
Two mechanisms, because neither one covers every terminal on its own.
The TEXT is the file:// URL, not the path. That is the part 1.2.1 got
wrong: it printed the path and hung an OSC 8 escape on it, which is
correct and useless, since a terminal that does not read OSC 8 is then
left looking at a path -- and a path is not a link to anything. A URL it
can find by itself: Konsole's UrlFilter matches [a-z][a-z0-9+-.]*:// and
its default AllowedLinkSchema is "http://;https://;file://". So the URL
alone is already clickable, before any escape is involved.
The ESCAPE is OSC 8 round it, for terminals that do read it, terminated
with BEL rather than ESC-backslash. Both are legal; BEL is the form
everything accepts, and it is the safer bet against a terminal that
swallows an OSC it half understands.
Piped or redirected, none of it: a BARE path and nothing else, because
`p=$(claudemeter --plot)` is how every other tool, and every test, gets
hold of the file. TERM=dumb counts as not a terminal for the same reason.
as_uri() rather than "file://" + path: it percent-encodes, so a state
directory with a space or a hash in its name still gives a valid URL."""
if not sys.stdout.isatty() or os.environ.get("TERM", "dumb") == "dumb":
return path
uri = pathlib.Path(path).as_uri()
return f"\033]8;;{uri}\a{uri}\033]8;;\a"
def week_starts(recs, since, until):
"""Where each weekly window began, as [t, measured] pairs in the period.
Measured: every distinct start the flat weekly limit has reported (reset
less seven days, told apart by same_window()). The transcripts go back
further than the probes, so before the earliest of those the turnover is
carried back in seven-day steps and flagged as inferred."""
seen = []
for r in recs:
for key, label, u, start, reset, active in windows(r):
if key == "weekly_all" and start is not None \
and not any(same_window(start, t) for t in seen):
seen.append(start)
if not seen:
return []
seen.sort()
out = [[t, True] for t in seen]
t = seen[0] - SEVEN_DAYS
while t >= since:
out.insert(0, [t, False])
t -= SEVEN_DAYS
return [w for w in out if since <= w[0] <= until]
def plot(args):
scan()
recs = probes()
if not recs:
print("No probes yet — nothing to plot. Start the daemon, or run "
"claudemeter --probe a few times.", file=sys.stderr)
return 1
now = time.time()
since = now - args.days * 86400
# --- one curve per limit in force, broken wherever the record has a hole
# --- or the window turned over (a reset is a cliff, not a slope).
order, seen = [], {}
for r in recs:
if r["t"] < since:
continue
for key, label, u, start, reset, active in windows(r):
if key not in seen:
seen[key] = {"key": key, "label": label, "active": active,
"segs": [], "cur": [], "prev": None}
order.append(key)
d = seen[key]
d["label"], d["active"] = label, active
# A reset is a cliff, not a slope -- but only when the window
# that held it was OPEN. Closed-to-open is a rise from zero and
# joins up; breaking there would put a gap in front of every
# window that opens after a quiet stretch. And "changed" means
# changed by more than the server's jitter: see same_window().
if d["prev"] and (r["t"] - d["prev"][0] > 5400
or (d["prev"][1] is not None
and not same_window(reset, d["prev"][1]))):
if d["cur"]:
d["segs"].append(d["cur"])
d["cur"] = []
d["cur"].append([r["t"], u])
d["prev"] = (r["t"], reset)
limits = []
for key in order:
d = seen[key]
if d["cur"]:
d["segs"].append(d["cur"])
limits.append({"key": key, "label": d["label"],
"active": d["active"], "segs": d["segs"]})
weeks = week_starts(recs, since, now)
# --- the stack: spend per bucket per project, in per cent of the weekly
# --- allowance, using the conversion the probes themselves fix.
k = calibrate(recs)
bucket = 3600 if args.days > 2 else 900
all_rows = spend_rows(0)
rows = [r for r in all_rows if r[0] >= int(since)]
by_proj = defaultdict(float)
for e, sess, proj, model, w, sub in rows:
by_proj[proj] += w
ranked = sorted(by_proj, key=lambda p: -by_proj[p])
names = ranked[:5] + (["other"] if len(ranked) > 5 else [])
slot = {p: i for i, p in enumerate(names)}
grid = defaultdict(lambda: [0.0] * len(names))
for e, sess, proj, model, w, sub in rows:
grid[int(e // bucket) * bucket][slot.get(proj, len(names) - 1)] += w * (k or 0)
stack = [[b] + v for b, v in sorted(grid.items())]
data = {
"generated": int(now), "since": int(since), "until": int(now),
"days": args.days, "bucket": bucket, "calibrated": k is not None,
"limits": limits, "names": names, "stack": stack, "weeks": weeks,
# Per cent of the weekly allowance AND dollars at list prices:
# the first is what the bars are drawn in, the second is what the
# same tokens would have come to through the API.
"totals": [(p, by_proj[p] * (k or 0), by_proj[p]) for p in ranked],
"probes": len([r for r in recs if r["t"] >= since]),
"latest": [{"key": w[0], "label": w[1], "percent": w[2],
"reset": w[4], "active": w[5],
"cost": None if w[3] is None
else totals(all_rows, w[3])[0]}
for w in windows(recs[-1])],
"seriesLight": SERIES_LIGHT, "seriesDark": SERIES_DARK,
}
tag, out = plot_target(args.plot)
with open(out, "w") as fh:
fh.write(HTML.replace("__DATA__", json.dumps(data)).replace("__TAG__", tag))
print(link(out))
return 0
HTML = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Claude allowance __TAG__</title>
<style>
:root{
color-scheme:light;
--surface-0:#f6f5f2; --surface-1:#fcfcfb; --line:#e2e0da;
--text-primary:#0b0b0b; --text-secondary:#52514e; --text-muted:#87857e;
--grid:#eceae4; --accent:#2a78d6; --accent-soft:#2a78d61a;
--good:#0ca30c; --warning:#fab219; --critical:#d03b3b;
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
color-scheme:dark;
--surface-0:#121211; --surface-1:#1a1a19; --line:#33322f;
--text-primary:#ffffff; --text-secondary:#c3c2b7; --text-muted:#8b8a82;
--grid:#282826; --accent:#3987e5; --accent-soft:#3987e526;
}}
:root[data-theme="dark"]{
color-scheme:dark;
--surface-0:#121211; --surface-1:#1a1a19; --line:#33322f;
--text-primary:#ffffff; --text-secondary:#c3c2b7; --text-muted:#8b8a82;
--grid:#282826; --accent:#3987e5; --accent-soft:#3987e526;
}
*{box-sizing:border-box}
body{margin:0;background:var(--surface-0);color:var(--text-primary);
font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
-webkit-font-smoothing:antialiased}
.wrap{max-width:1000px;margin:0 auto;padding:32px 20px 64px}
h1{font-size:20px;font-weight:600;margin:0 0 2px;letter-spacing:-.01em}
.sub{color:var(--text-muted);font-size:12.5px;margin:0 0 24px}
.tiles{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:24px}
.tile{flex:1 1 200px;background:var(--surface-1);border:1px solid var(--line);
border-radius:10px;padding:14px 16px}
.tile .lab{font-size:11.5px;letter-spacing:.04em;text-transform:uppercase;
color:var(--text-muted);margin-bottom:6px}
.tile .num{font-size:30px;font-weight:600;letter-spacing:-.02em;
font-variant-numeric:tabular-nums;line-height:1.1}
.tile .foot{font-size:12px;color:var(--text-secondary);margin-top:4px}
.card{background:var(--surface-1);border:1px solid var(--line);border-radius:10px;
padding:16px 16px 8px;margin-bottom:16px}
.card h2{font-size:13.5px;font-weight:600;margin:0 0 1px}
.card .note{font-size:12px;color:var(--text-muted);margin:0 0 8px}
.plot{position:relative;width:100%}
.rowtop{display:flex;align-items:baseline;gap:10px}
.rowtop h2{margin:0}
.range{font-size:12px;color:var(--text-secondary);margin-left:auto;
font-variant-numeric:tabular-nums}
.btn{font:inherit;font-size:12px;padding:3px 10px;border-radius:6px;cursor:pointer;
background:var(--surface-0);color:var(--text-secondary);border:1px solid var(--line)}
.btn:hover:not(:disabled){color:var(--text-primary);border-color:var(--text-muted)}
.btn:disabled{opacity:.45;cursor:default}
svg{display:block;width:100%;overflow:visible}
.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin:10px 2px 6px}
.legend span{display:inline-flex;align-items:center;gap:6px;font-size:12px;
color:var(--text-secondary)}
.swatch{width:10px;height:10px;border-radius:2px;flex:none}
.tip{position:absolute;pointer-events:none;opacity:0;transition:opacity .08s;
background:var(--surface-1);border:1px solid var(--line);border-radius:7px;
padding:8px 10px;font-size:12px;box-shadow:0 6px 20px #0000001f;
min-width:120px;z-index:5;color:var(--text-primary)}
.tip b{font-weight:600}
.tip .r{display:flex;justify-content:space-between;gap:14px;
font-variant-numeric:tabular-nums}
.tip .r i{font-style:normal;color:var(--text-secondary);display:flex;
align-items:center;gap:6px}
details{background:var(--surface-1);border:1px solid var(--line);
border-radius:10px;padding:12px 16px}
summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--text-secondary)}
table{width:100%;border-collapse:collapse;margin-top:12px;font-size:12.5px}
th,td{text-align:right;padding:6px 8px;border-bottom:1px solid var(--grid);
font-variant-numeric:tabular-nums}
th:first-child,td:first-child{text-align:left;font-variant-numeric:normal}
th{color:var(--text-muted);font-weight:500;font-size:11.5px;
text-transform:uppercase;letter-spacing:.04em}
tr:last-child td{border-bottom:none}
.foot-note{color:var(--text-muted);font-size:12px;margin-top:20px;max-width:66ch}
</style></head><body>
<div class="wrap">
<h1>Claude allowance</h1>
<p class="sub" id="sub"></p>
<div class="tiles" id="tiles"></div>
<div class="card">
<div class="rowtop"><h2>Range</h2>
<span class="range" id="rangelab"></span>
<button class="btn" id="rangereset" disabled>Show all</button></div>
<p class="note">Drag along this rail to keep a stretch of time — or
slide the window by its middle, or pull either edge. Dragging across any
chart below zooms into it just the same. Double-click a chart, or press
Escape, to come back.</p>
<div class="plot" id="rail"></div>
</div>
<div id="cards"></div>
<div class="card">
<h2>Where it went</h2>
<p class="note" id="stacknote"></p>
<div class="plot" id="ps"><div class="tip" id="ts"></div></div>
<div class="legend" id="leg"></div>
</div>
<details>
<summary>The same numbers, as a table</summary>
<p class="note">The whole period, whatever the charts are showing: this is
the one place the projects outside the stack’s top five are still
counted one by one, and a zoomed total would lose them.</p>
<table id="tbl"></table>
</details>
<p class="foot-note">The curves are measured: they come from
<code>/api/oauth/usage</code>, the same place Claude Code’s own
<code>/usage</code> reads, and they cover the whole account —
including anything spent on claude.ai or another machine. A
<span aria-hidden="true">▸</span> marks the limit this week’s
work is actually charged against, and a
<span style="color:var(--critical)">red line</span> each new week of it
(Saturday, 18:00) — dashed before probing began, where the
turnover is carried back seven days at a time rather than reported. The stack below is inferred: local
transcripts priced at the published per-MTok rates and scaled by the
conversion the probes themselves fix, so a bar can only account for what
this computer did. The dollars beside them are those same rates left
unscaled — what these turns would have cost through the API. None
of it is billed: the subscription pays for them.</p>
</div>
<script>
const D = __DATA__;
const dark = () => document.documentElement.dataset.theme === 'dark' ||
(document.documentElement.dataset.theme !== 'light' &&
matchMedia('(prefers-color-scheme: dark)').matches);
const S = () => dark() ? D.seriesDark : D.seriesLight;
const css = n => getComputedStyle(document.body).getPropertyValue(n).trim();
const NS = 'http://www.w3.org/2000/svg';
const el = (n, a = {}) => { const e = document.createElementNS(NS, n);
for (const k in a) e.setAttribute(k, a[k]); return e; };
const fmt = t => new Date(t * 1000).toLocaleString(undefined,
{ weekday: 'short', hour: '2-digit', minute: '2-digit' });
const day = t => new Date(t * 1000).toLocaleDateString(undefined,
{ weekday: 'short', day: 'numeric', month: 'short' });
// Dollars at the published per-MTok rates. Nothing here is billed: the
// subscription covers these turns, and the figure is only what the same
// tokens would have come to through the API.
const usd = d => '$' + d.toLocaleString(undefined,
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
const pretty = p => p.replace(/^-home-laussy-?/, '') || 'home';
const M = { l: 42, r: 14, t: 10, b: 26 };
const H = 150, HS = 190;
// Four equal steps that land on a round number. 25% split four ways gives
// 6.25, and an axis labelled 6/13/19 reads like a mistake. A percentage axis
// also never climbs past 100.
function nice(raw, floor) {
raw = Math.max(raw, floor || 0);
if (!(raw > 0)) return { max: 1, step: 0.25, dp: 2 };
if (raw > 50) return { max: 100, step: 25, dp: 0 };
const mag = Math.pow(10, Math.floor(Math.log10(raw / 4)));
for (const m of [1, 2, 2.5, 4, 5, 7.5, 10]) {
const step = m * mag;
if (step * 4 < raw) continue;
let dp = 0;
while (dp < 5 && Math.abs(parseFloat(step.toFixed(dp)) - step) > 1e-9) dp++;
return { max: step * 4, step, dp };
}
return { max: raw, step: raw / 4, dp: 2 };
}
function axes(svg, w, h, sc, ticks) {
const g = el('g');
for (let i = 0; i <= 4; i++) {
const v = sc.step * i, y = h - M.b - (h - M.b - M.t) * i / 4;
g.appendChild(el('line', { x1: M.l, x2: w - M.r, y1: y, y2: y,
stroke: i ? css('--grid') : css('--line'), 'stroke-width': 1 }));
const tx = el('text', { x: M.l - 8, y: y + 4, 'text-anchor': 'end',
fill: css('--text-muted'), 'font-size': 11 });
tx.textContent = v.toFixed(sc.dp) + (i === 4 ? '%' : '');
g.appendChild(tx);
}
for (const t of ticks) {
const tx = el('text', { x: X(t, w), y: h - 8, 'text-anchor': 'middle',
fill: css('--text-muted'), 'font-size': 11 });
tx.textContent = tickLabel(t);
g.appendChild(tx);
}
svg.appendChild(g);
}
// The window on the time axis. Everything that draws asks V and never
// D.since/D.until, so one assignment moves every chart at once and they
// cannot drift out of step with each other or with the rail.
const FULL = { from: D.since, to: D.until };
let V = { from: D.since, to: D.until };
const MINSPAN = 300; // five minutes; below that there is nothing to see
const zoomed = () => V.from > FULL.from + 1 || V.to < FULL.to - 1;
// Set by brushable() and read by the tooltip handlers: a pointer that is
// drawing a selection is not a pointer asking what is under it.
let dragging = false;
const X = (t, w) => M.l + (w - M.l - M.r) * (t - V.from) / (V.to - V.from);
const invX = (px, w) => V.from + (px - M.l) * (V.to - V.from) / (w - M.l - M.r);
const Y = (v, h, ymax) => h - M.b - (h - M.b - M.t) * (ymax ? v / ymax : 0);
const inView = p => p[0] >= V.from && p[0] <= V.to;
// The tick spacing follows what is ON SCREEN, not what was asked for on the
// command line: zoom a seven-day plot down to an afternoon and a tick a day
// would leave none at all. Seven gaps or fewer, from a ladder of steps that
// are all round numbers of minutes, hours or days.
const STEPS = [300, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800, 604800];
function tickStep() {
const span = V.to - V.from;
for (const st of STEPS) if (span / st <= 7) return st;
return STEPS[STEPS.length - 1];
}
function tickList() {
const step = tickStep(), out = [];
// Ticks land on local midnight / o'clock, not on multiples of the epoch.
const tz = new Date().getTimezoneOffset() * 60;
let t = Math.ceil((V.from - tz) / step) * step + tz;
for (; t < V.to; t += step) out.push(t);
return out;
}
const tickLabel = t => {
if (tickStep() >= 86400) return day(t);
const d = new Date(t * 1000);
// A tick that lands on midnight says which day it is instead of the time:
// across two days the hourly labels would otherwise read 12:00 AM, 12:00
// PM, 12:00 AM, 12:00 PM and name neither of them.
if (!d.getHours() && !d.getMinutes()) return day(t);
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
};
// Clip the drawn content to the plot rectangle. Nothing needed it while the
// axis always held every point; zoomed in, a line runs off both sides and
// would otherwise be painted straight over the labels (the stylesheet gives
// every svg overflow:visible, for the tooltips).
let uid = 0;
function clipped(svg, w, h) {
const id = 'clip' + (++uid);
const cp = el('clipPath', { id });
cp.appendChild(el('rect', { x: M.l, y: M.t - 6,
width: w - M.l - M.r, height: h - M.t - M.b + 12 }));
svg.appendChild(cp);
const g = el('g', { 'clip-path': 'url(#' + id + ')' });
svg.appendChild(g);
return g;
}
// A red line at every turnover of the weekly allowance (Saturday, 18:00), so
// a week's climb can be read from its own start. Dashed where the turnover
// is carried back from the probes rather than reported by them.
function weekLines(G, x, y0, y1) {
for (const [t, measured] of D.weeks) {
const ln = el('line', { x1: x(t), x2: x(t), y1: y0, y2: y1,
stroke: css('--critical'), 'stroke-width': 1.5, opacity: .85,
'stroke-dasharray': measured ? 'none' : '4 3' });
const ti = el('title');
ti.textContent = 'new week \u00b7 ' + new Date(t * 1000).toLocaleString(undefined,
{ weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) +
(measured ? '' : ' (inferred)');
ln.appendChild(ti);
G.appendChild(ln);
}
}
// Drag across a chart to keep that stretch of it. A press that goes nowhere
// is not a selection -- it must travel six pixels before it counts -- so an
// ordinary click still leaves the hover readout alone.
function brushable(svg, w, h, tip) {
const sel = el('rect', { y: M.t, height: h - M.t - M.b, fill: css('--accent'),
opacity: 0, 'pointer-events': 'none' });
svg.appendChild(sel);
svg.style.cursor = 'crosshair';
const at = ev => { const r = svg.getBoundingClientRect();
return Math.min(Math.max((ev.clientX - r.left) * w / r.width, M.l), w - M.r); };
let x0 = null;
svg.addEventListener('pointerdown', ev => {
if (ev.button !== 0) return;
x0 = at(ev); dragging = true;
try { svg.setPointerCapture(ev.pointerId); } catch (e) {}
sel.setAttribute('x', x0); sel.setAttribute('width', 0);
sel.setAttribute('opacity', .16);
if (tip) tip.style.opacity = 0;
});
svg.addEventListener('pointermove', ev => {
if (x0 === null) return;
const x1 = at(ev);
sel.setAttribute('x', Math.min(x0, x1));
sel.setAttribute('width', Math.abs(x1 - x0));
});
const end = ev => {
if (x0 === null) return;
const x1 = at(ev), a = x0;
x0 = null; dragging = false;
sel.setAttribute('opacity', 0);
if (Math.abs(x1 - a) >= 6) {
applyView(invX(Math.min(a, x1), w), invX(Math.max(a, x1), w));
}
};
svg.addEventListener('pointerup', end);
svg.addEventListener('pointercancel', () => {
x0 = null; dragging = false; sel.setAttribute('opacity', 0); });
svg.addEventListener('dblclick', () => applyView(FULL.from, FULL.to));
}
function lineChart(host, tip, segs, ymaxWanted) {
host.querySelectorAll('svg').forEach(s => s.remove());
const w = host.clientWidth || 900, h = H;
// The y axis answers to the window too: zoomed into a quiet afternoon, a
// scale set by the week's peak would draw a flat line along the bottom.
const vals = segs.flat().filter(inView).map(p => p[1]);
const sc = nice(Math.max(0, ...vals) * 1.25, ymaxWanted), ymax = sc.max;
const svg = el('svg', { viewBox: `0 0 ${w} ${h}`, height: h });
axes(svg, w, h, sc, tickList());
const G = clipped(svg, w, h);
for (const seg of segs) {
if (seg.length === 1) {
G.appendChild(el('circle', { cx: X(seg[0][0], w), cy: Y(seg[0][1], h, ymax),
r: 3, fill: css('--accent') }));
continue;
}
const d = seg.map((p, i) => (i ? 'L' : 'M') + X(p[0], w) + ' ' + Y(p[1], h, ymax)).join(' ');
G.appendChild(el('path', { d: d + ` L${X(seg.at(-1)[0], w)} ${h - M.b} L${X(seg[0][0], w)} ${h - M.b} Z`,
fill: css('--accent-soft'), stroke: 'none' }));
G.appendChild(el('path', { d, fill: 'none', stroke: css('--accent'),
'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
}
weekLines(G, t => X(t, w), M.t, h - M.b);
const cross = el('line', { y1: M.t, y2: h - M.b, stroke: css('--text-muted'),
'stroke-width': 1, 'stroke-dasharray': '3 3', opacity: 0 });
const dot = el('circle', { r: 4.5, fill: css('--accent'),
stroke: css('--surface-1'), 'stroke-width': 2, opacity: 0 });
svg.appendChild(cross); svg.appendChild(dot);
const pts = segs.flat().filter(inView);
svg.addEventListener('pointermove', ev => {
if (dragging) return;
const r = svg.getBoundingClientRect();
const px = (ev.clientX - r.left) * w / r.width;
let best = null, bd = 1e9;
for (const p of pts) { const d = Math.abs(X(p[0], w) - px);
if (d < bd) { bd = d; best = p; } }
if (!best || bd > 40) { cross.setAttribute('opacity', 0);
dot.setAttribute('opacity', 0); tip.style.opacity = 0; return; }
const x = X(best[0], w), y = Y(best[1], h, ymax);
cross.setAttribute('x1', x); cross.setAttribute('x2', x);
cross.setAttribute('opacity', .6);
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
dot.setAttribute('opacity', 1);
tip.innerHTML = `<b>${best[1].toFixed(0)}%</b>` +
`<div class="r"><i>${fmt(best[0])}</i></div>`;
tip.style.opacity = 1;
const lx = x * r.width / w;
tip.style.left = Math.min(Math.max(lx - 60, 0), r.width - 140) + 'px';
tip.style.top = Math.max(0, y * r.height / h - 56) + 'px';
});
svg.addEventListener('pointerleave', () => { cross.setAttribute('opacity', 0);
dot.setAttribute('opacity', 0); tip.style.opacity = 0; });
brushable(svg, w, h, tip);
host.appendChild(svg);
}
function stackChart(host, tip) {
host.querySelectorAll('svg').forEach(s => s.remove());
const w = host.clientWidth || 900, h = HS, cols = S();
const sums = D.stack.map(r => r.slice(1).reduce((a, b) => a + b, 0));
const seen = D.stack.map((r, i) => inView(r) ? sums[i] : 0);
const sc = nice(Math.max(0, ...seen) * 1.2, 0.004), ymax = sc.max;
const svg = el('svg', { viewBox: `0 0 ${w} ${h}`, height: h });
const H0 = h - M.b - M.t;
// axes() is written for the 150px charts; the stack is taller, so its own
// grid is drawn here against the same ymax.
for (let i = 0; i <= 4; i++) {
const v = sc.step * i, y = h - M.b - H0 * i / 4;
svg.appendChild(el('line', { x1: M.l, x2: w - M.r, y1: y, y2: y,
stroke: i ? css('--grid') : css('--line'), 'stroke-width': 1 }));
const tx = el('text', { x: M.l - 8, y: y + 4, 'text-anchor': 'end',
fill: css('--text-muted'), 'font-size': 11 });
tx.textContent = v.toFixed(sc.dp) + (i === 4 ? '%' : '');
svg.appendChild(tx);
}
for (const t of tickList()) {
const tx = el('text', { x: X(t, w), y: h - 8, 'text-anchor': 'middle',
fill: css('--text-muted'), 'font-size': 11 });
tx.textContent = tickLabel(t);
svg.appendChild(tx);
}
const G = clipped(svg, w, h);
const span = (w - M.l - M.r) * D.bucket / (V.to - V.from);
const bw = Math.max(1.5, span - 2);
D.stack.forEach((row, ri) => {
const x = X(row[0], w);
let acc = 0;
row.slice(1).forEach((v, i) => {
if (v <= 0) return;
const y0 = h - M.b - H0 * acc / ymax;
const y1 = h - M.b - H0 * (acc + v) / ymax;
acc += v;
// A 2px surface gap keeps two segments from reading as one block.
const r = el('rect', { x: x, y: y1, width: bw,
height: Math.max(1, y0 - y1 - (acc < sums[ri] ? 2 : 0)),
fill: cols[i % cols.length], rx: 1.5 });
r.dataset.i = ri;
G.appendChild(r);
});
});
weekLines(G, t => X(t, w), M.t, h - M.b);
svg.addEventListener('pointermove', ev => {
if (dragging) return;
const r = svg.getBoundingClientRect();
const px = (ev.clientX - r.left) * w / r.width;
let bi = -1, bd = 1e9;
D.stack.forEach((row, i) => { if (!inView(row)) return;
const d = Math.abs(X(row[0], w) + bw / 2 - px);
if (d < bd) { bd = d; bi = i; } });
if (bi < 0 || bd > Math.max(20, span)) { tip.style.opacity = 0; return; }
const row = D.stack[bi];
let html = `<b>${fmt(row[0])}</b>`;
D.names.forEach((n, i) => { if (row[i + 1] > 0.0005)
html += `<div class="r"><i><span class="swatch" style="background:${cols[i % cols.length]}"></span>${pretty(n)}</i><span>${row[i + 1].toFixed(3)}%</span></div>`; });
html += `<div class="r" style="border-top:1px solid var(--line);margin-top:5px;padding-top:5px"><i>total</i><span>${sums[bi].toFixed(3)}%</span></div>`;
tip.innerHTML = html;
tip.style.opacity = 1;
const lx = X(row[0], w) * r.width / w;
tip.style.left = Math.min(Math.max(lx - 70, 0), r.width - 190) + 'px';
tip.style.top = '4px';
});
svg.addEventListener('pointerleave', () => tip.style.opacity = 0);
brushable(svg, w, h, tip);
host.appendChild(svg);
}
const hms = left => left > 86400
? Math.floor(left / 86400) + 'd ' + Math.floor(left % 86400 / 3600) + 'h'
: Math.floor(left / 3600) + 'h ' +
String(Math.floor(left % 3600 / 60)).padStart(2, '0') + 'm';
function head() {
document.getElementById('tiles').innerHTML = D.latest.map(l => {
const left = l.reset === null ? null : l.reset - Date.now() / 1000;
const c = l.percent < 50 ? '--good' : l.percent < 80 ? '--warning' : '--critical';
return `<div class="tile"><div class="lab">${l.label}${l.active ?
' <span title="the limit this week\u2019s work is charged against">\u25b8</span>' : ''}</div>
<div class="num" style="color:${css(c)}">${l.percent.toFixed(0)}%</div>
<div class="foot">${left === null ? 'no window open'
: `resets in ${hms(left)} · ${fmt(l.reset)}`}</div>${
l.cost === null || l.cost === undefined ? '' :
`<div class="foot">${usd(l.cost)} at list prices</div>`}</div>`;
}).join('');
document.getElementById('sub').textContent =
`${D.days} day${D.days === 1 ? '' : 's'} to ` +
new Date(D.generated * 1000).toLocaleString() +
` · ${D.probes} probe${D.probes === 1 ? '' : 's'} · tag __TAG__`;
document.getElementById('stacknote').textContent = D.calibrated
? `Per cent of the weekly allowance spent per ${D.bucket === 3600 ? 'hour' : '15 minutes'}, by project, priced from the local transcripts.`
: 'Not enough probes yet to fix the conversion between local token spend and per cent — the bars are unscaled.';
const cols = S();
document.getElementById('leg').innerHTML = D.names.map((n, i) =>
`<span><span class="swatch" style="background:${cols[i % cols.length]}"></span>${pretty(n)}</span>`).join('');
const tot = D.totals.reduce((a, b) => a + b[1], 0);
const totd = D.totals.reduce((a, b) => a + b[2], 0);
document.getElementById('tbl').innerHTML =
'<thead><tr><th>Project</th><th>% of weekly allowance</th>' +
'<th>at list prices</th><th>share</th></tr></thead><tbody>' +
D.totals.map(([p, v, d]) => `<tr><td>${pretty(p)}</td><td>${v.toFixed(3)}%</td>` +
`<td>${usd(d)}</td><td>${tot ? (100 * v / tot).toFixed(1) : '0.0'}%</td></tr>`).join('') +
`<tr><td><b>total</b></td><td><b>${tot.toFixed(3)}%</b></td>` +
`<td><b>${usd(totd)}</b></td><td></td></tr></tbody>`;
}
// --------------------------------------------------------------- the rail
// The whole period, once, with the kept stretch drawn over it as a window.
// It is the only chart that never zooms: it is what you steer by.
//
// It is built by rail() and moved by railWindow(). The two are separate
// because a drag on the window redraws the charts on every pointermove, and
// rebuilding the rail's own svg underneath the finger would drop the pointer
// capture and the drag would die after one frame.
const RM = { l: 10, r: 10 };
let railSvg = null, railWin = null, railHL = null, railHR = null;
const RH = 46;
function rail() {
const host = document.getElementById('rail');
host.querySelectorAll('svg').forEach(s => s.remove());
const w = host.clientWidth || 900;
const svg = el('svg', { viewBox: `0 0 ${w} ${RH}`, height: RH });
const fx = t => RM.l + (w - RM.l - RM.r) * (t - FULL.from) / (FULL.to - FULL.from);
const finv = x => FULL.from + (x - RM.l) * (FULL.to - FULL.from) / (w - RM.l - RM.r);
const top = 5, bot = RH - 5;
// Something to aim at: a faint map of where the busy stretches are, so a
// window can be put on one without hunting. The SPEND is what is drawn,
// not the utilisation curve: probing only started recently, so the curve
// is a sliver at the right-hand end and would leave most of the rail
// blank, while the transcripts go back as far as the plot does. Bars, at
// the resolution the stack already uses.
const sums = D.stack.map(r => r.slice(1).reduce((a, b) => a + b, 0));
const mx = Math.max(...sums, 1e-9);
const bwr = Math.max(1, (w - RM.l - RM.r) * D.bucket / (FULL.to - FULL.from) - 0.5);
D.stack.forEach((row, i) => {
if (sums[i] <= 0) return;
const hh = Math.max(1.5, (bot - top) * sums[i] / mx);
svg.appendChild(el('rect', { x: fx(row[0]), y: bot - hh, width: bwr,
height: hh, fill: css('--accent'), opacity: .5, rx: 1 }));
});
svg.appendChild(el('line', { x1: RM.l, x2: w - RM.r, y1: bot, y2: bot,
stroke: css('--line'), 'stroke-width': 1 }));
weekLines(svg, fx, top - 2, bot + 2);
// Everything outside the window is dimmed rather than the window filled,
// so the curve under the kept stretch stays its own colour.
const maskL = el('rect', { x: 0, y: 0, height: RH, fill: css('--surface-0'), opacity: .62 });
const maskR = el('rect', { x: 0, y: 0, height: RH, fill: css('--surface-0'), opacity: .62 });
svg.appendChild(maskL); svg.appendChild(maskR);
const win = el('rect', { y: top - 2, height: bot - top + 4, fill: 'none',
stroke: css('--accent'), 'stroke-width': 1.5, rx: 3 });
svg.appendChild(win);
const hl = el('rect', { y: top - 2, width: 5, height: bot - top + 4,
fill: css('--accent'), rx: 2.5 });
const hr = el('rect', { y: top - 2, width: 5, height: bot - top + 4,
fill: css('--accent'), rx: 2.5 });
svg.appendChild(hl); svg.appendChild(hr);
hl.style.cursor = hr.style.cursor = 'ew-resize';
win.style.cursor = 'grab';
svg.style.cursor = 'crosshair';
railSvg = { svg, w, fx, finv, maskL, maskR };
railWin = win; railHL = hl; railHR = hr;
// One pointerdown decides what the drag is: an edge if it started on one,
// a slide if it started inside a window there is room to slide, a fresh
// window anywhere else.
//
// That last clause matters more than it looks. Showing everything, the
// window IS the rail, so every press lands "inside" it -- and a slide with
// nowhere to slide to does nothing at all, which is precisely the state
// the page opens in and the first thing anyone tries. Unzoomed, an
// interior press draws a new window instead, and the rail does what the
// sentence above it says from the first drag.
let mode = null, anchor = 0, anchorX = 0, grabbed = null;
const at = ev => { const r = svg.getBoundingClientRect();
return (ev.clientX - r.left) * w / r.width; };
svg.addEventListener('pointerdown', ev => {
if (ev.button !== 0) return;
const x = at(ev), a = fx(V.from), b = fx(V.to);
if (zoomed() && Math.abs(x - a) <= 7) { mode = 'l'; }
else if (zoomed() && Math.abs(x - b) <= 7) { mode = 'r'; }
else if (zoomed() && x > a && x < b) { mode = 'm'; anchor = x;
grabbed = { ...V }; win.style.cursor = 'grabbing'; }
else { mode = 'new'; anchorX = x; anchor = finv(x); }
try { svg.setPointerCapture(ev.pointerId); } catch (e) {}
ev.preventDefault();
});
svg.addEventListener('pointermove', ev => {
if (!mode) return;
const x = at(ev), t = finv(x);
if (mode === 'l') applyView(t, V.to);
else if (mode === 'r') applyView(V.from, t);
// A new window only begins once the press has travelled far enough to be
// a drag; a stray click should not throw the page into its smallest view.
else if (mode === 'new') { if (Math.abs(x - anchorX) >= 4) applyView(anchor, t); }
else {
const span = grabbed.to - grabbed.from;
let a = grabbed.from + (t - finv(anchor));
a = Math.min(Math.max(a, FULL.from), FULL.to - span);
applyView(a, a + span);
}
});
const stop = () => { mode = null; win.style.cursor = 'grab'; };
svg.addEventListener('pointerup', stop);
svg.addEventListener('pointercancel', stop);
svg.addEventListener('dblclick', () => applyView(FULL.from, FULL.to));
host.appendChild(svg);
railWindow();
}
function railWindow() {
if (!railSvg) return;
const { w, fx, maskL, maskR } = railSvg;
const a = fx(V.from), b = fx(V.to);
railWin.setAttribute('x', a); railWin.setAttribute('width', Math.max(1, b - a));
railHL.setAttribute('x', a - 2.5); railHR.setAttribute('x', b - 2.5);
maskL.setAttribute('width', Math.max(0, a));
maskR.setAttribute('x', b); maskR.setAttribute('width', Math.max(0, w - b));
const one = t => new Date(t * 1000).toLocaleString(undefined,
{ day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const span = V.to - V.from;
document.getElementById('rangelab').textContent =
one(V.from) + ' — ' + one(V.to) + ' · ' +
(span >= 172800 ? (span / 86400).toFixed(1) + ' days'
: span >= 7200 ? (span / 3600).toFixed(1) + ' hours'
: Math.round(span / 60) + ' min');
document.getElementById('rangereset').disabled = !zoomed();
}
// Moving the window redraws every chart but NOT the rail -- see above.
function applyView(a, b) {
if (b < a) { const t = a; a = b; b = t; }
if (b - a < MINSPAN) { const c = (a + b) / 2; a = c - MINSPAN / 2; b = c + MINSPAN / 2; }
V = { from: Math.max(FULL.from, a), to: Math.min(FULL.to, b) };
if (V.to - V.from < MINSPAN) { // clamped against an end of the record
if (V.from <= FULL.from) V.to = Math.min(FULL.to, V.from + MINSPAN);
else V.from = Math.max(FULL.from, V.to - MINSPAN);
}
drawCharts();
railWindow();
}
function drawCharts() {
D.limits.forEach((l, i) => lineChart(document.getElementById('pl' + i),
document.getElementById('tl' + i), l.segs, 10));
stackChart(document.getElementById('ps'), document.getElementById('ts'));
}
function draw() {
head();
// One panel per limit in force — there is no fixed pair of them.
const host = document.getElementById('cards');
if (!host.children.length) host.innerHTML = D.limits.map((l, i) =>
`<div class="card"><h2>${l.label}${l.active ? ' \u25b8' : ''}</h2>
<p class="note">Per cent used, as the server reports it. The line breaks
at each reset and wherever the record has a hole.</p>
<div class="plot" id="pl${i}"><div class="tip" id="tl${i}"></div></div></div>`).join('');
drawCharts();
rail();
}
draw();
document.getElementById('rangereset').addEventListener('click',
() => applyView(FULL.from, FULL.to));
addEventListener('keydown', ev => {
if (ev.key === 'Escape' && zoomed()) applyView(FULL.from, FULL.to); });
addEventListener('resize', () => { clearTimeout(window._r);
window._r = setTimeout(draw, 120); });
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
</script></body></html>
"""
# ------------------------------------------------------------------- main
def main():
p = argparse.ArgumentParser(
prog="claudemeter",
description="How much of the Claude allowance is gone, and who spent it.")
p.add_argument("-v", "--version", action="version",
version=f"claudemeter {VERSION}")
p.add_argument("--probe", action="store_true",
help="ask the server once, right now, and record it")
p.add_argument("--daemon", action="store_true", help="run the poller")
p.add_argument("-s", "--sessions", action="store_true",
help="break the window down by session")
p.add_argument("-w", "--weekly", action="store_true",
help="with -s: the weekly window instead of the 5-hour one")
p.add_argument("--projects", action="store_true",
help="with -s: group by project instead of session")
p.add_argument("--models", action="store_true",
help="with -s: group by model instead of session")
p.add_argument("--top", type=int, default=15, help="rows to show (default 15)")
p.add_argument("--plot", nargs="?", const=True, default=False,
metavar="TAG",
help="write an HTML timeline and print its path; "
"refreshes the last plot if it is under 12h old, "
"otherwise takes a fresh tag; with a TAG, write "
"over the plot that tag already names; with `new`, "
"always take a fresh tag")
p.add_argument("-d", "--days", type=float, default=7,
help="days of history to plot (default 7)")
p.add_argument("-c", "--cost", action="store_true",
help="what it would have cost at the published API rates; "
"with --statusline, put the window's dollars on it")
p.add_argument("--statusline", action="store_true",
help="one line, for Claude Code's statusLine hook")
p.add_argument("--json", action="store_true", help="machine-readable dump")
p.add_argument("--offline", action="store_true",
help="do not ask the server; report the last record")
p.add_argument("--rescan", action="store_true",
help="rebuild the spend table from every transcript, "
"keeping the sessions whose transcript is gone")
p.add_argument("--reprice", action="store_true",
help="recompute the spend table's dollars in place, "
"after a price change; loses nothing")
p.add_argument("--interval", type=int, default=300,
help="daemon: seconds between probes while active")
p.add_argument("--max-interval", type=int, default=3600,
help="daemon: longest back-off when idle")
a = p.parse_args()
if a.reprice:
n, was, now = reprice()
print(f"{n} turns repriced. {money(was)} \u2192 {money(now)}")
return 0
if a.rescan:
n, orph, was, now = rebuild()
print(f"{n} turns priced from the transcripts, {orph} carried over "
f"from sessions whose\ntranscript has been pruned. "
f"{money(was)} \u2192 {money(now)}")
return 0
if a.daemon:
return daemon(a)
if a.probe:
rec, err = probe()
if err:
print(err, file=sys.stderr)
return 1
return status(a)
if a.statusline:
return statusline(a)
if a.cost:
return cost_report(a)
if a.json:
return as_json(a)
if a.plot is not False:
return plot(a)
if a.sessions:
return sessions(a)
return status(a)
if __name__ == "__main__":
sys.exit(main())
[Unit]
Description=Watch the Claude 5-hour and weekly allowance
After=network-online.target
[Service]
Type=simple
ExecStart=/home/laussy/bin/claudemeter --daemon
# The poller sleeps between probes and sizes its own interval: five minutes
# while there is Claude activity on this machine or the 5-hour window is past
# half, doubling to an hour when there is neither. So a working day costs a
# couple of hundred probes and an idle one a couple of dozen — each one a
# Haiku turn of about twenty tokens and 2 kB on the wire.
Restart=always
RestartSec=60
Nice=10
IOSchedulingClass=idle
StandardOutput=append:/home/laussy/.claudemeter/daemon.log
StandardError=append:/home/laussy/.claudemeter/daemon.log
[Install]
WantedBy=default.target