The modal deliberately shows only a slice of the analytics; this hands off to TokenTracker's own local dashboard for the rest. The worker starts `tokentracker serve` on demand and returns the URL, the UI opens the browser. Three things the spawn has to get right: port 7680 is not free on Windows (Delivery Optimization holds [::]:7680) and serve does not fall back, so we scan 7680-7689 with a dual-stack bind probe; --no-open because the CLI would open the browser before the server answers; and the child is a cmd.exe shim, so shutdown kills the process tree. --no-sync keeps our no-cloud-sync rule.
17 KiB
Usage monitoring, gate & throttle
Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit
bf56cd8c(2026-08-24). Drift check:git log --oneline bf56cd8c..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.csStable structure only (no line numbers). See docs/explore-notes/README.md.
Covers src/ClaudeDo.Worker/Usage/, the queue's throttle/gate integration, per-run token
accounting, and the UI surfaces (usage pill + usage monitor modal).
Two independent data sources, on purpose. Limits/gate/throttle come from the OAuth poll below; the analytics (per-model and per-task breakdown, cost) come from the external TokenTracker CLI. Neither one can break the other.
Data source (limits, gate, throttle)
GET https://api.anthropic.com/api/oauth/usage — an undocumented Anthropic endpoint,
authenticated with the Bearer access token Claude Code itself keeps fresh at
~/.claude/.credentials.json. ClaudeDo reads that token, never refreshes it, never logs it.
Because the endpoint is undocumented and can change without notice, every consumer fails open. That is the single most important invariant here.
Components (Usage/)
| Type | Role |
|---|---|
UsageModels |
UsageBucket / UsageLimitRow / UsageSnapshot. UsageBucket.Utilization is already a 0–100 percent — compare directly against thresholds, don't rescale. |
ClaudeOAuthUsageClient |
Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing limits → empty list. |
UsageState |
Threadsafe singleton. A failed poll never overwrites the last good snapshot — it only sets LastError. |
UsageMonitorService |
BackgroundService; polls on usage_poll_interval_seconds (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts HubBroadcaster.UsageUpdated after every tick, success or failure. |
UsageSnapshotBuilder |
Builds the Hub-facing UsageSnapshotDto from UsageState + IUsageGate + AppSettings. The one shared place for stale/threshold/gate logic — WorkerHub.GetUsageSnapshot and UsageMonitorService must not diverge. |
UsageGate |
Hard pause decision → UsageGateDecision(IsBlocked, Reason). |
UsageThrottle |
Pure static staging of parallelism ahead of the gate. |
TranscriptUsageReader |
Reads one session's cumulative totals for per-run accounting. No aggregation — the date/model/scope roll-up moved to TokenTracker/. |
TokenTracker/ |
The external analytics backend: cost + per-model/per-task breakdown. Own section below. |
Interfaces in Usage/Interfaces/: IUsageClient, ITranscriptUsageReader, IUsageGate;
plus Usage/TokenTracker/Interfaces/ITokenTrackerClient.
The gate (hard pause)
Thresholds: AppSettings.UsageGateFiveHourPct / UsageGateSevenDayPct (defaults 80/90).
Blocked once five_hour >= UsageGateFiveHourPct or seven_day >= UsageGateSevenDayPct
(>=, not >). Threshold 0 = that bucket never gates.
What it pauses: only the queue's slot-fill loop — new queued tasks don't start.
Unaffected: already-running runs, RunNow, ContinueTask, interactive ConPTY sessions,
planning sessions, daily prep (all bypass the queue).
Fail-open: no snapshot yet, a failed last poll, or an app-settings read error all resolve to not-blocked.
There is no persistent pause state. Recovery is just the queue's 30 s backstop timer
(queue_backstop_interval_ms) re-evaluating the gate on its own once usage drops back
under the threshold. A blocked↔free transition is logged and broadcast (WorkerLog, Warn
on block / Info on resume) exactly once per change, not every tick.
The throttle (staged parallelism)
UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct, sevenDayThresholds) — pure static, no state. UsageThresholds(SoftPct, HardPct, GatePct) is the
per-bucket triple (same file).
Thresholds are per bucket (usage_throttle_five_hour_{soft,hard}_pct /
usage_throttle_seven_day_{soft,hard}_pct, defaults 50/65 each) because the 5h and 7d windows fill
at very different rates. Each bucket is staged independently and the strictest bucket wins —
not "whichever is more utilized", so a bucket that is lower but tightly configured can be the one
that throttles:
| Utilization (per bucket) | That bucket's slots |
|---|---|
| below soft | full configured max_parallel_executions |
>= softPct |
capped at 2 |
>= hardPct |
capped at 1 |
>= gatePct |
0 — same hard block as UsageGate |
A threshold of 0 disables that stage for that bucket, and a bucket with no reading (null) never
throttles. The 0 return is deliberately kept in sync with UsageGate's hard block because both
read the same gate thresholds — change one, change both.
Only new slot fills are affected; a run already occupying a slot when the stage tightens runs to completion. Same fail-open policy: no snapshot means no throttling.
The effective stage (configured vs. effective slots + the decisive bucket, ThrottleBucket
= "five_hour" / "seven_day") rides along on UsageSnapshotDto purely for UI display.
It does not change what the gate gates on.
Queue integration (Queue/QueueService)
Per loop tick:
GetEffectiveMaxParallelAsyncreadsAppSettings.MaxParallelExecutionsand steps it down viaUsageThrottle.EffectiveSlotsagainst the currentUsageStatesnapshot. A missing/failed snapshot fails open to the configured value. A stage change (not every tick) logs once.- Separately,
IUsageGate.EvaluateAsync— if blocked, the slot-fill loop is skipped entirely for that tick.
Per-run token accounting
task_runs stores four raw token fields: tokens_in / tokens_out /
cache_read_tokens / cache_write_tokens.
These are not read from the stream-json result event's usage.input_tokens — that is
only the uncached remainder of a single API call and undercounts the real prompt size by
orders of magnitude once caching kicks in.
Instead TaskRunner.ApplyUsageAsync calls
ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId) — the session transcript's
cumulative raw totals across every assistant message, located by {sessionId}.jsonl — and
stores the delta against prior task_runs rows sharing the same session_id, so a
--resume'd run doesn't double-count turns already billed to an earlier run.
A missing/unreadable transcript leaves all four fields null; it never fails the run.
ReadSessionTotalsAsync stays — it is the per-run source of truth and is deliberately not
routed through TokenTracker, whose export has no per-run granularity (it aggregates a whole
session, which a --resume'd task spreads over several runs).
TranscriptUsageReader details
Locates a single {sessionId}.jsonl under ~/.claude/projects/**, sums the raw usage fields of
every assistant message, deduped by requestId (falling back to message.id), with a per-file
length+mtime cache. <synthetic>-model lines are skipped — they are not real API calls.
There is no history-wide ReadAsync any more; WorkerConfig is still a constructor parameter
purely so the DI registration and call sites stayed untouched.
Analytics via TokenTracker
The per-model / per-task / cost view comes from TokenTracker, an external MIT-licensed CLI
(tokentracker-cli, npm) that reads the same local Claude transcripts. Everything lives in
Usage/TokenTracker/; the pure parts (SessionHash, TokenTrackerArgs,
TokenTrackerExportParser, TokenTrackerAggregator) have no I/O and are unit-tested, only
TokenTrackerClient (processes) and TokenTrackerService (cache coordination) touch the world.
- Invocation:
tokentracker sessions --from <d> --to <d> --no-git --format json(TokenTrackerArgs).--no-gitmatters — without it TokenTracker runsgit loginside every session's working directory. We never calltokentracker init: that would write hooks into the user's global~/.claude/settings.jsonand switch on cloud sync. Install is an explicitnpm i -g tokentracker-cli, nevernpx. Resolution goes through the sharedExecutableResolverbecause an npm CLI is a.cmdshim. - The join: the export never carries a raw session id, only
session_hash=sha256("claude\0" + sessionId)hex, first 24 chars (SessionHash.ForClaudeSession, mirroring TokenTracker'slib/session-analytics.js). That hash set — built fromtask_runs.session_id— is also what decides the ClaudeDo vs. other scope split. Note this is narrower than the old cwd-based split: an interactive or planning session has notask_runsrow and therefore counts as "other". - One wide fetch, local filtering:
TokenTrackerService.WindowDays= 90. TokenTracker'sbuildSessionAnalyticswalks the whole history regardless of--from/--to(v0.88.4 returns the same rows either way), so narrowing the request buys nothing while making every range switch pay again. Range switching is then pure in-memory filtering inTokenTrackerAggregator. - Freshness:
EnsureFreshAsync(maxAge). No export at all → the caller awaits the fetch; an export older than 15 minutes → served immediately while a refresh runs behind the caller's back.RefreshAsyncis single-flight (a second caller waits for the in-flight run instead of spawning another process). The probe (CLI present? Node ≥ 20?) is cached until an install forces it. - Version check: the export's
versionfield is compared againstTokenTrackerExportParser.SupportedFormatVersion(11). A mismatch is treated as a failure — the state keeps the last good export and reports the version inLastError. - Fail-open everywhere:
TokenTrackerStatemirrorsUsageState— a failed or unparseable fetch never overwrites a good export, it only setsLastError. CLI missing → empty analytics plus a hint card in the UI. Nothing here can throw into the queue. - Local dashboard:
tokentracker serve --port <p> --no-open --no-sync, started by the worker on demand (ITokenTrackerClient.StartDashboardAsync) for the "Open TokenTracker dashboard" button. Three things this path has to get right: TokenTracker's default port 7680 is not free on Windows (Delivery Optimization holds[::]:7680) andserveexits instead of falling back, so we scan 7680–7689 with a dual-stack bind probe (an IPv4-only probe calls 7680 free and the spawn then dies);--no-openbecause the CLI would open the browser before the server answers, so we pollGET http://127.0.0.1:<p>/(30 s cap) and let the UI open the URL; and the child is acmd.exeshim, so shutdown kills the process tree or node keeps listening. The spawned server is reused while alive and dies with the worker (TokenTrackerClientisIDisposable, registered as a singleton) — a worker crash leaks it until the port is scanned past. - Limits, gate and throttle do not come from TokenTracker. They are the OAuth poll above. A broken or absent TokenTracker costs you the cost columns, nothing else.
UI surfaces
UsagePillViewModel— one shared instance backs theUsagePillcontrol in both the footer and the Mission Control header. Loads viaGetUsageSnapshotAsync, updates live offIWorkerClient.UsageUpdatedEvent. Dot state priority is mutually exclusive: blocked > stale > warn > normal.IsThrottled(effective slots below configured, and not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket. The pill's click handler (IslandsShellViewModel.OpenUsageMonitor) shows the window before loading (BeginLoad) — awaiting the load first made the pill feel like a dead click, because the firstGetModelUsageper worker process scans the whole transcript history.- Draggable stage markers — each of the two real gauges carries three markers (soft/hard/gate).
UsageGaugeBar(Views/Controls) draws them against its own width and does the pointer work; the math is a pure static,UsageThresholdDrag(in the modal VM's file), which keeps soft ≤ hard ≤ gate and treats a neighbour of0as off. Release fires the row'sCommitCommand→ read-modify-write viaGetAppSettings+UpdateAppSettings, so only the dragged bucket's three fields change. Plan-dependentweekly_scopedgauges are read-only. - Legend = numeric editor. Under each adjustable bar sit three legend rows whose colour swatches
match the markers (soft
TextDimBrush, hardStatusReviewBrush, gateStatusErrorBrush), each with aNumericUpDown.NumericUpDownhas no commit command, so the box'sTag(soft/hard/gate) plus two code-behind handlers (LostFocus, Enter) call the row'sCommitSoft/CommitHard/CommitGatecommand. Those run the typed value through the sameUsageThresholdDrag.Applyclamp as a drag, so a box can't invert the order and only the edited stage moves. ⚠️ TheKeepLastNumberconverter is mandatory on those bindings — see theNumericUpDownnull gotcha insrc/ClaudeDo.Ui/CLAUDE.md. Rows are updated in place on each snapshot (keyed by limit kind) so a poll landing mid-drag doesn't replace the bound instance. UsageMonitorModalViewModel— opened from the pill. Renders one gauge per row inUsageSnapshotDto.Limits— deliberately dynamic, because theseven_day_opus/seven_day_sonnet-style buckets the raw API returns are plan-dependent and come backnullon plans that don't have them; a fixed gauge layout would break. Also shows model usage (GetModelUsageAsync, ClaudeDo-vs-Other split per model) and top-task usage (GetTaskUsageAsync) over a 7d/30d preset or custom range — both with a cost column, the task table additionally with retries.- Analytics header vs. hint card — the modal shows exactly one of the two. With TokenTracker
present: total tokens + total cost, a freshness stamp, a manual
RefreshAnalyticsbutton and an Open TokenTracker dashboard button (spinner while the server boots; everything this modal deliberately doesn't show lives there). Without it: a hint card explaining what TokenTracker is (local only, noinit, no cloud sync) plus anInstall TokenTrackerbutton — which is hidden and replaced by a Node-version hint when Node < 20 or Node is missing, because the install would just fail. Install progress and every failure go throughErrorReportedinto the shell's footer log strip, not a private banner.
Hub surface
GetUsageSnapshot() -> UsageSnapshotDto— percentages/limits/FetchedAtUtcare null andIsStale=truewhen no snapshot has landed yet.IsStalealso trips on a failed last poll or a snapshot older than 3×usage_poll_interval_seconds.GetModelUsage(from, to)— filters + aggregates the cached TokenTracker export (TokenTrackerAggregator.ByModel). Returns an empty list rather than throwing when TokenTracker is unconfigured or no export landed.Sessionsreplaced the old assistant-message count — the export has no message granularity.GetTaskUsage(from, to)— unchanged source: top consumers fromtask_runsjoined to task/list, grouped per task. Null token columns count as 0, never drop the row.Modelcomes from that task's most recent run. Sorted by total tokens descending, capped at 100. Cost/retries/ productive/one-shot are an enrichment via thesession_hashjoin — a missing export just leaves those columns empty, it never triggers a fetch (GetModelUsageis what does).GetTokenTrackerStatus()— probe + state, asTokenTrackerStatusDto(Installed, Version, NodeOk, NodeVersion, LastFetchedUtc, LastError, FormatVersion, SessionCount). Never throws; an unconfigured worker comes back as not-installed with a reason.RefreshTokenTracker()— forces a fetch, then re-probes and returns the status.OpenTokenTrackerDashboard()— brings up the local dashboard server and returnsTokenTrackerDashboardDto(Ok, Url, Error); the UI opens the browser (ShellOpen.Url, which refuses anything that isn't http/https). Never throws — a failure also lands inWorkerLog.InstallTokenTracker()—npm i -g tokentracker-cli, streaming npm's output line by line intoWorkerLog(npm writes progress to stderr; forwarding it is what keeps a multi-minute global install from looking hung), then force-re-probes.UsageUpdatedevent carries the sameUsageSnapshotDto.
Settings columns
app_settings: usage_gate_five_hour_pct / usage_gate_seven_day_pct (80/90),
usage_throttle_five_hour_{soft,hard}_pct / usage_throttle_seven_day_{soft,hard}_pct (50/65 per
bucket). All six clamped 0..100 by AppSettingsRepository.UpdateAsync, which does not enforce
soft ≤ hard ≤ gate — the ordering is a UI-side drag constraint, and an out-of-order stored config
degrades instead of throwing. Worker config: usage_poll_interval_active_seconds /
usage_poll_interval_idle_seconds.
The gate percentages are editable in two places that both write the same app_settings row:
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
gauge-only — SettingsModalViewModel therefore carries them load→save verbatim so saving Settings
can't reset a dragged value.