Files
ClaudeDo/docs/explore-notes/usage-monitoring.md
T
mika kuns 3003cfa561 feat(usage): open the TokenTracker dashboard from the usage monitor
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.
2026-08-24 16:51:00 +02:00

17 KiB
Raw Blame History

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.cs Stable 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 0100 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:

  1. GetEffectiveMaxParallelAsync reads AppSettings.MaxParallelExecutions and steps it down via UsageThrottle.EffectiveSlots against the current UsageState snapshot. A missing/failed snapshot fails open to the configured value. A stage change (not every tick) logs once.
  2. 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-git matters — without it TokenTracker runs git log inside every session's working directory. We never call tokentracker init: that would write hooks into the user's global ~/.claude/settings.json and switch on cloud sync. Install is an explicit npm i -g tokentracker-cli, never npx. Resolution goes through the shared ExecutableResolver because an npm CLI is a .cmd shim.
  • 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's lib/session-analytics.js). That hash set — built from task_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 no task_runs row and therefore counts as "other".
  • One wide fetch, local filtering: TokenTrackerService.WindowDays = 90. TokenTracker's buildSessionAnalytics walks 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 in TokenTrackerAggregator.
  • 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. RefreshAsync is 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 version field is compared against TokenTrackerExportParser.SupportedFormatVersion (11). A mismatch is treated as a failure — the state keeps the last good export and reports the version in LastError.
  • Fail-open everywhere: TokenTrackerState mirrors UsageState — a failed or unparseable fetch never overwrites a good export, it only sets LastError. 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) and serve exits instead of falling back, so we scan 76807689 with a dual-stack bind probe (an IPv4-only probe calls 7680 free and the spawn then dies); --no-open because the CLI would open the browser before the server answers, so we poll GET http://127.0.0.1:<p>/ (30 s cap) and let the UI open the URL; and the child is a cmd.exe shim, so shutdown kills the process tree or node keeps listening. The spawned server is reused while alive and dies with the worker (TokenTrackerClient is IDisposable, 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 the UsagePill control in both the footer and the Mission Control header. Loads via GetUsageSnapshotAsync, updates live off IWorkerClient.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 first GetModelUsage per 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 of 0 as off. Release fires the row's CommitCommand → read-modify-write via GetAppSettings + UpdateAppSettings, so only the dragged bucket's three fields change. Plan-dependent weekly_scoped gauges are read-only.
  • Legend = numeric editor. Under each adjustable bar sit three legend rows whose colour swatches match the markers (soft TextDimBrush, hard StatusReviewBrush, gate StatusErrorBrush), each with a NumericUpDown. NumericUpDown has no commit command, so the box's Tag (soft/hard/gate) plus two code-behind handlers (LostFocus, Enter) call the row's CommitSoft/CommitHard/CommitGate command. Those run the typed value through the same UsageThresholdDrag.Apply clamp as a drag, so a box can't invert the order and only the edited stage moves. ⚠️ The KeepLastNumber converter is mandatory on those bindings — see the NumericUpDown null gotcha in src/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 in UsageSnapshotDto.Limits — deliberately dynamic, because the seven_day_opus / seven_day_sonnet-style buckets the raw API returns are plan-dependent and come back null on 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 RefreshAnalytics button 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, no init, no cloud sync) plus an Install TokenTracker button — 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 through ErrorReported into the shell's footer log strip, not a private banner.

Hub surface

  • GetUsageSnapshot() -> UsageSnapshotDto — percentages/limits/FetchedAtUtc are null and IsStale=true when no snapshot has landed yet. IsStale also 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. Sessions replaced the old assistant-message count — the export has no message granularity.
  • GetTaskUsage(from, to) — unchanged source: top consumers from task_runs joined to task/list, grouped per task. Null token columns count as 0, never drop the row. Model comes from that task's most recent run. Sorted by total tokens descending, capped at 100. Cost/retries/ productive/one-shot are an enrichment via the session_hash join — a missing export just leaves those columns empty, it never triggers a fetch (GetModelUsage is what does).
  • GetTokenTrackerStatus() — probe + state, as TokenTrackerStatusDto(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 returns TokenTrackerDashboardDto(Ok, Url, Error); the UI opens the browser (ShellOpen.Url, which refuses anything that isn't http/https). Never throws — a failure also lands in WorkerLog.
  • InstallTokenTracker()npm i -g tokentracker-cli, streaming npm's output line by line into WorkerLog (npm writes progress to stderr; forwarding it is what keeps a multi-minute global install from looking hung), then force-re-probes.
  • UsageUpdated event carries the same UsageSnapshotDto.

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.