One prompt that builds a live local dashboard showing whether your Claude sessions are actually working — a file you bookmark, not a site you host. Level 2 below puts it in your menu bar, with a live line showing what each session is doing.
Ben — your path: you’ve now got Levels 1–4 in place plus your own customer work on top — so the last two are written to recognize that. Level 5 and Level 6 are upgrade prompts, not rebuilds: it reads your existing watch-status.py, status.json and dashboard first, then layers a Usage & Budget panel on top, preserving every custom card and filter you’ve added. The public, clone-and-go version lives at github.com/jeremyinthebay/claude-watcher — send that onward to other people, not the copy you install over your own setup.
The problem it solves. A long Cowork session can look "busy" in the chat while doing nothing, or look silent while a subagent grinds through a 15-minute build. Watching the chat tells you almost nothing. The trick: a working session constantly writes files; a stalled one doesn't. So instead of asking the session how it's doing (a session can be wrong about itself — and a hung one can't answer at all), a tiny script observes from outside: it checks the modification times of each session's on-disk files every minute and renders a dashboard. Green means files written in the last ~2½ minutes. Gray means nothing moving. The sessions never know they're being watched, and it costs zero tokens to run.
<meta refresh> tag, so the page reloads itself. No server, no fetch — this matters because browsers block fetch() on file:// pages, which is exactly why the data is inlined.Everything stays on your Mac — nothing is published, nothing leaves the machine. If the laptop sleeps, the watcher sleeps too, which is fine: so do the sessions.
Build me a local "Claude Watcher" — a dashboard that shows whether my Claude
sessions are actually working, with NO web server. It's a single HTML file I
bookmark, regenerated every minute by launchd. Everything stays local.
HOW SESSIONS LEAVE TRACKS (verify these paths on my machine before coding):
- Cowork sessions live under ~/Library/Application Support/Claude/
local-agent-mode-sessions/*/*/ — each session is a directory local_<uuid>/
with a sibling metadata file local_<uuid>.json containing sessionId, title,
model, isArchived, lastActivityAt.
- A WORKING session constantly writes files inside its directory; a stalled
one doesn't. Freshest file mtime = last real activity. Skip node_modules/
and outputs/ subdirs when scanning, and cap the walk (~8k files) for speed.
- Active subagents show up as recently-modified .jsonl files under the
session's .claude/projects/*/*/subagents/ — count ones touched <3 min ago.
- Claude Code sessions (if I use the CLI) leave transcripts at
~/.claude/projects/<project-dir>/<uuid>.jsonl — same mtime trick.
BUILD (three pieces):
1. ~/claude-watcher/watch-status.py — scans the paths above; for every
non-archived session with activity in the last 48h, compute age of newest
file and state: working (<150s) / quiet (<30min) / idle. Then write
~/claude-watcher/index.html as a COMPLETE self-contained page with the
data inlined (no fetch — file:// pages can't fetch) and
<meta http-equiv="refresh" content="30"> so it reloads itself. Dark theme,
one card per session: green/amber/gray dot, title, model, "Xs ago", the
session id in small mono, and an "N subagents" badge when active. Footer
shows generated-at time — if that goes stale, the generator itself died,
which is also worth knowing. Atomic write (tmp file + os.replace) so the
browser never catches a half-written page.
2. Also scan my GuideFlow repo and add a card: newest commit (hash, age,
subject — local git only, no network fetch) and mtimes of ROADMAP.md /
PROGRESS.md / HANDOFF.md, so I can see the last time a session actually
saved state.
3. ~/Library/LaunchAgents/com.guideflow.watch-status.plist — runs the script
every 60s (StartInterval 60, RunAtLoad true), stderr to a log file in
~/claude-watcher/. Load it with launchctl load.
VERIFY (don't tell me it works — show me):
- Run the script once, print the sessions it found with their states, and
confirm THIS session shows as "working" (it must — you're writing files
right now).
- Open file:///Users/<me>/claude-watcher/index.html via `open`, and paste the
launchctl list line proving the job is loaded.
- Wait ~90 seconds and confirm the generated-at stamp advanced on its own.
RULES: nothing public, no server, no network calls, no tokens spent at
runtime — the watcher is a shell script's view of the disk, not an AI. Don't
make sessions self-report status anywhere: a hung session can't report, and
that's precisely the moment the watcher exists for.
The dashboard answers “is it working?” when you look at it. The menu bar answers it without looking: a glyph at the top of the screen — green with a count when sessions are actually working, gray when idle, red when the watcher itself dies. And because every session logs each step to a transcript on disk, the watcher can also read the tail of that transcript and show the last tool call or words per session — “what is it doing right now,” observed from outside, still zero tokens.
Extend my local Claude Watcher (built from the prompt above — build that
first if it doesn't exist) with two upgrades: a macOS MENU BAR app via
SwiftBar, and a live "what is it actually doing" line per session. Still
100% local: no server, no network calls, no tokens spent at runtime.
UPGRADE 1 — the "doing" line (what is each session working on?)
Extend ~/claude-watcher/watch-status.py so that, in addition to the HTML
dashboard, it atomically writes ~/claude-watcher/status.json:
{ "generated_at": <unix>, "sessions": [ { "title", "model", "age_s",
"state", "subagents", "doing" } ] }
"doing" is a one-line best-effort of the session's latest action, extracted
from its transcript — sessions log every step as JSONL:
- Cowork: the session dir's newest .claude/projects/*/*.jsonl (files directly
in that dir — do NOT match the subagents/ subdirs).
- Claude Code CLI: ~/.claude/projects/<project>/<uuid>.jsonl, same format.
Read only the LAST ~400 KB of the file. Parse each line as JSON, keep only
type == "assistant" entries, and walk message.content blocks IN ORDER so the
final value is the most recent event:
- a "tool_use" block -> 'ToolName: <hint>' where hint is the first line of
input.command / .file_path / .path / .description / .subject / .pattern /
.url (first that exists);
- a "text" block -> the text itself.
Then collapse all whitespace, strip * ` | # characters, cap at 160 chars.
Compute it only for working/quiet sessions (skip idle — saves IO).
Show the line on the HTML dashboard too, small and muted, under each active
session's card.
UPGRADE 2 — the menu bar app (SwiftBar)
1. brew install --cask swiftbar (install Homebrew first if missing).
2. Write ~/claude-watcher/swiftbar/claude-watcher.60s.sh — the ".60s" suffix
IS the refresh cadence, SwiftBar parses it from the filename. The script
cats the LOCAL ~/claude-watcher/status.json into python3 for formatting
(no curl — everything is on this machine).
Menu bar glyph: "(green)N" when N sessions are working, "(yellow)" when
only quiet ones, "(white)" all idle, "(red) Claude?" if generated_at is
older than 5 minutes (the generator died — that is its own alert).
Dropdown: one line per session — state dot, title, age, model, subagent
count — with its "doing" line as an indented "--" submenu row (size=11),
then "Open dashboard | href=file:///Users/<me>/claude-watcher/index.html".
3. defaults write com.ameba.SwiftBar PluginDirectory "$HOME/claude-watcher/swiftbar"
then: open -a SwiftBar
THREE TRAPS (each one cost a real debugging round — do not rediscover them):
- The python code rides inside a single-quoted bash string. Use ONLY double
quotes in the python source, including dict keys. One stray s['key']
terminates the shell string and you get a NameError that looks impossible.
- macOS system python3 is too old for same-quote nesting inside f-strings.
Never index a dict inside an f-string — assign to a plain variable first,
then interpolate the variable.
- "|" is SwiftBar's parameter separator. Strip or replace it in every piece
of dynamic text (titles, doing lines, log lines) before printing.
VERIFY (show me, don't tell me):
- Run watch-status.py once; paste the sessions with their "doing" values and
confirm THIS session appears as working — its doing line should show the
very command you just ran. If a watcher can't see the session that is
building it, it can't see anything.
- Run the plugin script directly in the terminal; paste its full output.
- Confirm the icon is in the menu bar, then wait ~90s and confirm the
dropdown's data advanced without you touching anything.
PRIVACY RULE: "doing" lines contain shell commands and file paths. That is
fine in a local file — but never publish status.json anywhere public without
deciding, on purpose, that you are okay with that.
Level 2 answers “what is it doing?” Level 3 answers the follow-ups: who is doing it (role chips per subagent), how long and how expensive (runtimes and real token spend from each agent’s own usage records), what the last hour looked like (a tiny activity sparkline per session), and what the CLI is up to (claude-code runs join the same board). Plus the layout rules that keep all of that readable.
Upgrade my local Claude Watcher (Levels 1 + 2 above are prerequisites) into
the "mission control" version. Still 100% local, still zero tokens at
runtime, still observing from outside — nothing below asks a session to
report on itself.
UPGRADE 1 — make the layout survive real data (this is what fixes "messy")
Rebuild the dashboard HTML with these specific patterns:
- A summary strip of chips at the top: N working / N quiet / N idle, plus a
red "feed stale" chip if generated_at is older than 5 minutes.
- One card per session: status dot, title, model chip, active-subagent count
badge, and right-aligned meta: last activity + "up 4h" (from the metadata
createdAt) + the session id in small mono.
- EVERY dynamic line renders as ONE line with CSS ellipsis
(white-space:nowrap; overflow:hidden; text-overflow:ellipsis) and the full
text in a title= attribute for hover. Wrapping raw prompts is what made
the old layout unreadable.
- Finished subagents collapse into a <details> ("N finished in the last
30 min") instead of stacking under the live ones.
- Prettify tool names for display: strip the "mcp__" prefix, render "__" as
" · ". Tool calls get a wrench icon, prose gets a speech icon.
UPGRADE 2 — role chips per subagent
Subagent prompts start like "You are a Sonnet BUILDER on ..." — extract that
role into a small amber chip instead of showing the raw prompt:
match: ^(?:you are|you're) (?:an\s+|a\s+|the\s+)?(ROLE)(?: agent)? followed
by " on|for|in" or punctuation, case-insensitive, capped ~34 chars.
TRAP: order the article alternatives an|a|the and require trailing \s+ —
with plain (a|an|the)? the "a" alternative wins and "an Opus SPEC" extracts
as "n Opus SPEC".
UPGRADE 3 — per-subagent runtime and token spend
For each subagent transcript (cap the read at 8 MB — they're short-lived):
- start time = the first line's "timestamp" field; runtime = now − start.
- token spend = sum of message.usage.output_tokens over every
type=="assistant" line. Show it in the row ("51.5k tok") and in the menu
bar submenu. This is the "which agent is expensive" signal.
UPGRADE 4 — activity sparkline per session
From the last ~2 MB of the session's main transcript, bucket every line's
"timestamp" into 12 buckets of 5 minutes (the last hour) and render tiny
bars under the card — steady grind and burst-then-stall look completely
different, and you learn to read it instantly.
TRAP: don't slice the timestamp at a fixed width — fractional seconds make
fromisoformat fail SILENTLY and you get an all-zero sparkline that looks
plausible. Find the closing quote instead. (Cost a debugging round.)
UPGRADE 5 — Claude Code CLI runs on the same board
Also scan ~/.claude/projects/*/*.jsonl (exclude paths containing
"subagents"). Each file is one CLI run: project name from the directory
(split on "-Projects-"; fall back to a cleaned suffix, and if the result is
shorter than 3 chars call it "home"), state from mtime, "doing" via the same
last-action parser, model from the newest assistant line's message.model.
Give these cards a distinct "CLI" chip. Their subagents live in the SAME
layout (<project>/<session-uuid>/subagents/agent-*.jsonl) — run them through
the same per-subagent parser so CLI fan-outs get role/runtime/token rows too. If you use claude CLI at all — or
run any unattended loop with it — these are the sessions you most want
watched.
UPGRADE 6 — trust the app's own clock too
Session age = max(newest file mtime, metadata lastActivityAt). A capped
directory walk can miss the one hot file and report a working session as a
day old. Two independent signals, take the fresher.
UPGRADE 7 — filters and search ("only what I care about")
- The summary-strip chips are BUTTONS: clicking toggles that state's
visibility, choices persist in localStorage, and a chip dims when off.
- CLI runs get their own chip, and it is the SOLE gate for CLI cards — an
idle CLI run must still show when the CLI chip is on. (Users click "CLI"
to SEE CLI runs, not to intersect them with the state filters. We shipped
the intersect version first; it reads as "the chip says 2 but the page
shows nothing." Don't repeat that.)
- A live search box next to the chips, filtering on title, doing lines, and
subagent roles. Keep the input OUTSIDE the re-rendered strip or every
refresh steals its focus. Empty result renders "nothing matches — show
everything" with a one-click reset.
- Make the chip/search bar position:sticky with backdrop blur, add card
hover states, and you're done: the page reads like a product, not a log.
STANDING RULE — fail pretty: any auxiliary probe that breaks (a budget
check, a log tail) renders as one short warning WITH THE FIX COMMAND
("run: claude login"), never a raw error dump. A dashboard that yells
stack traces trains you to stop reading it.
VERIFY (show me, don't tell me):
- Run the generator; paste one session's JSON showing role, tok, runtime_s,
and a spark array with at least one nonzero bucket.
- THIS session must appear working, with the very command you just ran as
its doing line.
- Screenshot the dashboard in a real browser; confirm zero horizontal
overflow at 390px width; confirm the finished-subagents toggle opens.
- Menu bar: paste the plugin's terminal output showing role chips and token
counts on the submenu rows.
RULES unchanged: local only, no server, no network, no runtime tokens, and
never publish the output file anywhere public without deciding on purpose.
The watcher shows you what your agents are doing. This one makes there be more of them: it turns your Opus Cowork session into a driver that fans work out to up to four Sonnet subagents in parallel — decompose, brief, verify, never type — and then writes that operating mode into your project’s CLAUDE.md and boot docs so every future session starts that way without being told. The rules are compressed from a system that ships ~30 PRs a day this way; the model discipline in rule 3 is the difference between 2× throughput and 4× cost.
You are my Opus DRIVER session for GuideFlow. Adopt the multi-agent
operating mode below for THIS session, then memorialize it so every future
session boots with it. Two deliverables: (1) you working this way today,
(2) the docs that make it permanent.
THE OPERATING MODE — up to 4 subagents at a time
1. Your instinct on any execution task is "which subagent does this," not
"let me do it." You are the scarce, expensive resource: you decompose,
brief, judge, and verify. Subagents type.
2. Fan out up to FOUR subagents at once — but launch them ALL IN ONE
MESSAGE. Sequential launches serialize silently; one message with four
Agent calls is what actually runs them concurrently.
3. MODEL DISCIPLINE (the silent cost leak): subagents inherit YOUR model
unless told otherwise — an Opus driver that fans out without specifying
spawns four Opus agents. Pass model:"sonnet" on every subagent whose job
is to gather, search, build, or execute. Reserve Opus subagents for
genuine adversarial validation, and even then ask whether Sonnet plus a
control run would do.
4. DISJOINT OWNERSHIP, DECIDED UP FRONT: before spawning, name exactly which
files/functions/areas each subagent owns. Two subagents editing the same
function are not independent — merge them into one. Parallelize the slow
part (building, research); serialize the risky part (merging, deciding).
5. Each brief must be SELF-CONTAINED: subagents see none of our chat. Give
each one the goal, the paths, the constraints, and a machine-checkable
definition of done ("output X exists and passes Y" — never "improve Z").
6. Cap effort: if a subagent fails the same problem twice, pull the work
back to yourself or re-brief — don't let it grind. Four stuck agents burn
quota four times faster than one.
7. You verify results INDEPENDENTLY before calling anything done — rerun the
check, open the file, run the test. A subagent's "done" is a claim, not
a fact. (My Claude Watcher shows your subagents live — role, runtime,
token spend — so I will see both the parallelism and the waste.)
MEMORIALIZE IT (so I never have to paste this twice)
8. Write the mode into the project's persistent docs, whichever exist —
check before writing:
- CLAUDE.md at the GuideFlow repo root (create it if missing): add a
"## Multi-agent operating mode" section with rules 1–7, compressed.
- The session boot prompt doc (my Session Boot Prompts file, e.g.
BOOT.md / NEW-CHAT-BOOT.md): add one line telling every new session to
read that CLAUDE.md section and operate by it.
- If a memory directory exists, record it there too.
Keep each write short — a future session should absorb this in ten
seconds, not re-read an essay.
9. Show me the diff of every file you touched. Docs I haven't seen don't
count as written.
PROVE IT WORKS, NOW
10. Take my current top task, decompose it into 2–4 genuinely independent
workstreams, and run the fan-out for real: all subagents launched in one
message, model:"sonnet" on each, disjoint ownership stated. Then paste:
the decomposition, each subagent's one-line result, and your own
independent verification of the merged outcome.
11. If the current task does NOT decompose cleanly, say so and run it with
one subagent instead — forcing parallelism onto entangled work creates
merge conflicts, not speed. Knowing when NOT to fan out is part of the
mode.
Levels 1–4 tell you what your agents are doing. Level 5 tells you what they’re spending: token burn for today / this week / this month / all-time with cost and period-over-period deltas, a model-mix bar (are you actually staying on Sonnet?), and three real rate-limit gauges — Session, Weekly, Extra-usage — read from the same meter the CLI’s /usage screen uses. It’s written as an upgrade to a watcher that already exists (yours, with your custom work) rather than a rebuild, and it carries the two traps that cost me the most: the OAuth token endpoint that moved, and the fact that ccusage shows your desktop usage as zero until you point it at the right folders.
Upgrade my existing Claude Watcher with a "Usage & Budget" panel. READ THIS FIRST:
I already built Levels 1-4 and have my own custom work on top. Do NOT rebuild the
watcher. Open my current ~/claude-watcher/watch-status.py, my status.json, and my
dashboard HTML, learn their shapes, then ADD the budget layer on top — preserving
every card, chip, filter and customization I already have. Still 100% local; the
token/cost half spends zero tokens at runtime.
WHAT IT ADDS
- Four hero cards up top: TODAY / THIS WEEK / THIS MONTH / ALL TIME token totals,
each with cost and a period-over-period delta (today vs yesterday, week vs last
week, month vs last month).
- A 14-day trend bar chart and a model-mix bar (how much Sonnet vs Opus vs Haiku).
- Three rate-limit gauges — Session %, Weekly %, Extra-usage $ — the REAL account
meters, so you see how close you are to a limit, not just token volume.
DATA SOURCE 1 — ccusage (zero auth, nothing to expire)
Token/cost history comes from ccusage (npm i -g ccusage), which parses Claude's
local JSONL logs offline — no API token, so this half can never "expire." In
watch-status.py add a cached helper (cache ~10 min so your 60s generator doesn't
re-parse every tick) that runs: ccusage daily --since 20250101 --json and from the
daily rows computes today/yesterday, this-week/last-week (ISO week), this-month/
last-month, all-time, a 14-day sparkline, and a 30-day model mix. Put it all in
status.json under a "budget" key, wrapped in try/except so a ccusage hiccup never
blanks the rest of the feed.
TRAP — ccusage only sees the CLI by default, so DESKTOP usage reads as ZERO.
Out of the box ccusage scans ~/.claude/projects — Claude Code CLI only. Cowork/
desktop sessions keep their OWN nested .claude inside each session dir, so on a day
you only used the desktop app the "today" card shows 0 while the weekly gauge climbs.
Fix: build a comma-separated CLAUDE_CONFIG_DIR from ~/.claude PLUS every desktop
session's nested .claude — glob
~/Library/Application Support/Claude/local-agent-mode-sessions/*/*/local_*/.claude —
and pass it as an env var to ccusage. It dedupes by message id, so overlaps are safe.
Sort those dirs by mtime and cap the list (~2000) so the env string can't grow
without bound as sessions pile up.
TRAP — `ccusage daily` is trustworthy; `ccusage session` is not.
If you drill from "today cost $X" into "which session did it", note that
`session --since` selects which sessions APPEAR by activity date and then reports each
one's FULL LIFETIME cost. It overstated one day for me by 2.52x. Level 6 covers the
fix; for now, build the budget cards on `daily` only.
DATA SOURCE 2 — the real %-meters (a SELF-HEALING OAuth probe)
The Session/Weekly/Extra gauges come from the same endpoint the CLI's /usage screen
uses: GET https://api.anthropic.com/api/oauth/usage with your Claude Code OAuth token
(header anthropic-beta: oauth-2025-04-20 ). Write a small usage-probe.sh (zsh
wrapping python) that:
- reads the token from the login keychain:
security find-generic-password -s "Claude Code-credentials" -a <your-mac-username> -w
(detect the account from the item's own attributes — it's your macOS user);
- if the access token is expired, REFRESHES it with the stored refresh token and
writes the new credential back into the keychain IN PLACE. This is what makes it
self-healing — a read-only probe dies the moment the token expires and stays dead
until you run `claude login`; this one never does;
- writes ~/claude-watcher/meters.json: session_pct, session_resets, weekly_pct,
weekly_resets, any model-scoped weekly %, extra-usage $used/$cap, and a one-line
summary. Your generator reads meters.json into status.json's budget.meters.
TRAPS on the probe (each cost a debugging round):
- The token endpoint MOVED. console.anthropic.com/v1/oauth/token now 404s. The live
one is https://platform.claude.com/v1/oauth/token — I found it (plus client_id
9d1c250a-e61b-44d9-88ed-5944d1962f5e, the public Claude Code OAuth client) in the
strings of the Claude Code binary. POST {grant_type:"refresh_token", refresh_token,
client_id}.
- Cloudflare 1010-blocks a bare User-Agent. Python urllib's default UA gets an HTTP
403 "error code: 1010" before it reaches the origin — set ANY real User-Agent
header and it goes through.
- NEVER print or log the token. Refresh safely: a FAILED refresh request does not
consume the refresh token, so attempts are harmless; before writing, back up the
current credential to a file and validate the new JSON, then update the SAME
keychain service+account in place (security add-generic-password -U) so you never
create a duplicate item, and read it back to confirm before trusting it.
- Guard freshness checks: "value or DEFAULT" is a bug when the value can be 0 —
meters written this same second have age 0, and `age or BIG` treats 0 as missing.
Use explicit None checks.
KEEP IT FRESH (this is what stops "it worked, then broke overnight")
Add a launchd job — com.guideflow.usage-probe, StartInterval 1200 (every 20 min),
RunAtLoad — that runs usage-probe.sh. Access tokens last ~8h; polling every 20 min
keeps the token warm and meters.json current, so a token never silently expires
between runs again. VERIFY IT FROM THE LAUNCHD CONTEXT specifically — that's where
keychain access is the thing in doubt: delete meters.json, let the job tick, and
confirm it's rewritten with an empty error log.
THE DASHBOARD (layer on, don't replace)
Add a budget section ABOVE your existing session cards, in your current dark theme,
reusing your CSS variables:
- Four hero cards. Format tokens like "265.0M" (÷1e6, one decimal; "k" under a
million). Delta line: a colored triangle + "+113.5% vs 124.1M". SPEND SEMANTICS —
an increase is red/attention (▲), a decrease is green/good (▼), a null delta is a
muted dash; ALL TIME shows "all time" instead of a delta. Small cost chip ($) per
card.
- 14-day trend bars with today highlighted; a stacked model-mix bar + legend (Sonnet
in your gold/green, Opus in a warmer red so an Opus-heavy day reads hotter, cost
per model on hover); three gauges (Session / Weekly / Extra) colored amber at
>=60% and red at >=80%, each with its reset label.
- ADDITIVELY: keep your session list, filters, sparklines, role chips and any custom
cards exactly as they are. WRAP your existing render() instead of rewriting it, and
don't rename any element ids your current code reads. Feed your SwiftBar menu bar
the weekly % too (and a ⚠ when a limit is close) so it's glanceable without opening
the page.
ONE HONEST NOTE on the model mix once desktop is included: the bar will probably go
Opus-heavy, because the desktop app defaults to Opus while a well-run CLI/relay
executor runs Sonnet. That's the true total picture. If you also want the "is my
automation staying on Sonnet?" signal, keep THAT as a separate CLI-only readout —
it's your executor's own logs, not this blended view.
VERIFY (show me, don't tell me):
- Run the generator; paste status.json's budget block — four cards with nonzero
tokens (run something in the desktop app first so "today" isn't a real zero), a
meters object with session/weekly %, and a model_mix array.
- Prove the desktop-inclusion fix: show "today" is 0 with plain ccusage, then nonzero
once CLAUDE_CONFIG_DIR includes the session dirs.
- Run usage-probe.sh once and paste the summary line (session=..% weekly=..%); confirm
a second run takes the fast path (no re-refresh) and the keychain still has exactly
ONE credential item.
- Delete meters.json, let the launchd probe tick, confirm it reappears — that proves
it works from launchd, not just your shell.
- Screenshot the dashboard at 1440px and at 390px; confirm the hero cards wrap 2x2 on
phone and your existing panels below are untouched.
RULES unchanged: local only, no server, the burn cards spend zero tokens at runtime,
and never publish meters.json or status.json anywhere public without deciding on
purpose — meters.json reflects your account's real usage and limits.
Level 5 tells you how much you spent. This tells you which conversation spent it, and it alerts on the trajectory rather than the tombstone. The reason it exists: my own watchdog fired exactly once in a hot week — weekly=82%, over the line — and the week reset six hours later. One alert, about a budget that was about to be forgiven, and silence during the three days it was actually being spent. When it did fire, the only lever it could offer was “use it less,” because nothing could name what had burned it. This level fixes both halves, and carries the four traps that cost me the most: ccusage session reporting lifetime cost instead of the range you asked for (2.52x wrong), transcripts duplicated ~2.2x on disk, cache-writes billed at 2x rather than 1.25x (~5.6% low every single day), and a projection that screams on hour one unless you gate it.
Upgrade my Claude Watcher with per-conversation ATTRIBUTION and PACE alerting. READ
THIS FIRST: I already run Levels 1-5 with my own custom work on top. Do NOT rebuild
anything. Read my watch-status.py, status.json, dashboard HTML and usage-probe.sh
first, learn their shapes, then layer on. Still 100% local, still zero tokens at
runtime.
WHY THIS EXISTS — the alert that proved Level 5 wasn't enough
My watchdog fired exactly once in a hot week: "weekly=82%", over the 80% line. The
week reset six hours later. So the one alert I got warned me about a budget that was
about to be forgiven, and said nothing during the three days it was actually being
spent. An absolute threshold is a tombstone. And when it did fire, the only lever it
could offer was "use it less", because nothing could name WHAT had burned it.
PART A — ATTRIBUTION: put a human name on the spend
Cowork already writes full Claude-Code-format transcripts, one `usage` block per
assistant message, under:
~/Library/Application Support/Claude/local-agent-mode-sessions/
<ws>/<acct>/local_<sessionId>/.claude/projects/**/*.jsonl
And the conversation's human TITLE sits in a sidecar next to it:
<ws>/<acct>/local_<sessionId>.json -> {"title": "WiFi Odds takeover", "model": ...}
Join those two and your report goes from a UUID to "WiFi Odds takeover". That join is
the whole feature. Write usage-attribute.py that walks the transcripts, aggregates
tokens per (session, model, LOCAL date), prices them, and prints a ranked table plus
--json for the alerting path.
TRAP A1 — `ccusage session --since` does NOT filter what it reports.
It selects which sessions APPEAR (by activity date) and then prints each one's FULL
LIFETIME cost. I built on it and was wrong by 2.52x: all 16 of that day's sessions
returned byte-identical costs for --since <today> and --since <January>, and the
session total ($1,037) overstated the day total ($411). Verify this on your own data
before trusting any per-session number: run `session --since` twice with far-apart
dates and diff. If the rows are identical, aggregate per-day yourself from the
transcripts. `ccusage daily` is fine — it's `session` that misleads.
TRAP A2 — the transcripts are DUPLICATED, about 2.2x.
Cowork copies them into nested session dirs, so a naive sum inflates badly and the
factor is not stable day to day, so you cannot divide it out. Dedup on the assistant
message id (I checked whether requestId was also needed: id alone and (id,requestId)
gave the identical unique count). 7,044 raw rows collapsed to 3,187 real ones.
TRAP A3 — rank by COST, not tokens. Cache reads were 97% of my tokens and are the
cheapest tier by an order of magnitude, so a token ranking ranks by context size, not
spend.
TRAP A4 — if you price tokens yourself, the cache-WRITE multiplier is 2x, not 1.25x.
Anthropic bills two cache-write tiers, 5-minute at 1.25x and 1-hour at 2x, and the
desktop app leans on the 1h tier. Assuming a flat 1.25x read ~5.6% low on EVERY single
day — a bias you cannot see without a per-day check against a second source. Better:
don't hardcode a price table at all, it goes stale silently. Calibrate from ccusage's
own per-model daily breakdowns and FIT the cache-write coefficient:
cost ~= r*(input + 5*output + 0.1*cache_read) + s*cache_creation
Two unknowns, ~14 day-rows per model, solved by 2x2 normal equations — no numpy. Mine
fitted to clean list prices with s/r = 2.00 on every well-sampled model, which is what
confirmed the 1h-tier explanation. Then validate: re-price every day from YOUR
transcripts and compare to ccusage's daily totals. Calibrate on ccusage's token counts
and validate on your own, or the check is circular and passes no matter what.
TRAP A5 — scan BOTH roots or your validation is apples to oranges. ccusage's totals
cover ~/.claude AND the desktop session dirs. I scanned only the desktop ones and read
11% low, with the gap tracking exactly when my CLI relay was busy. They are disjoint,
so they add.
PART B — PACE: alert on the trajectory, not the tombstone
Add usage-pace.py that re-reads the probe (zero budget, meters only) and answers the
one question a threshold can't: AT THE CURRENT RATE, DO I CROSS 100% BEFORE THIS
WINDOW RESETS? That's self-normalizing — loud in a hot week, quiet in a cool one, with
no percentage to retune. Alert when the projected crossing lands inside the window.
Use two rate models, because they fail in opposite directions:
- average = used/elapsed. Stable, but blind to a late-week burst: five idle days
dilute Friday's spike below the line.
- recent = delta between your last two logged readings. Catches bursts, noisy over
short spans.
Project with whichever is WORSE and report which one drove it, so the alert names the
shape of the problem.
Three guards, all of which I needed:
- TRUST GATE: believe no projection until >=4% of the window has elapsed. At hour 1 of
168 a single session reads as 40x pace.
- HYSTERESIS: after alerting once for a window, stay silent unless the projected
crossing moves >=12h EARLIER. A twice-daily job must not nag about a known state.
- NOISE GATE: ignore a recent-rate sample under ~2h. The meters are integer percent, so
two readings 20 minutes apart turn a 16.5->17 rounding tick into a fake 3%/h burn.
Key your state file on the window's reset timestamp so a new week starts clean.
Phone-readable output beats precision: "weekly 17%, 3.0x pace, hits 100% Mon ~1pm,
week resets Sat" then the top 3 conversations. Never send a bare percentage with no
reset date — that's what made my 82% alert useless.
WIRE IT UP
Run attribution ONLY when something already fired (a pace alert or a threshold). On a
quiet run it's wasted work, and silence should stay the default. Add a top-burners
panel to the dashboard from the same --json, and put the projected crossing next to
the weekly gauge.
VERIFY (show me, don't tell me):
- Paste the ranked table for today. The conversation you KNOW was short must be near
the bottom — that's your dedup check. If a 10-minute session ranks top, it's wrong.
- Prove trap A1 on your own data: `session --since` for two far-apart dates, diffed.
- Run the per-day validation and paste the table: your priced days vs ccusage's, with
the worst material-day deviation. State your residual honestly rather than tuning
until it's zero; mine reads ~5% low and I don't fully know why, which I'd rather
write down than hide.
- Unit-test pace with SYNTHETIC meters, not live ones: an early-window spike (must stay
quiet), a steady cool week (quiet), a late-week burst (must alert via recent-rate),
two readings 20min apart (must not alert on rounding), and a meter that goes
backwards after a reset (must not produce a negative rate).
- Confirm --dry-run leaves the state file untouched, and that a real run writes it.
RULES unchanged: local only, zero tokens at runtime, never print or log the OAuth
token, and never publish the attribution output — conversation titles are as sensitive
as the numbers.
Green is proof of life, not proof of correctness — it means the session is writing files, so it isn't hung. Whether the work is right is still what your PRs and CI are for. Amber usually means the session is thinking, waiting on you, or a long tool call is mid-flight. Gray on a session you believe is running is the tell that it stalled — that's your cue to stop it and ask for a status report. And if the generated-at stamp itself goes stale, the watcher died, which is a different problem than the sessions dying — the stamp is the watcher watching itself.