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

260 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.