Skip to content

Latest commit

 

History

History
362 lines (310 loc) · 19.4 KB

File metadata and controls

362 lines (310 loc) · 19.4 KB

Userscript

A Tampermonkey/Violentmonkey userscript that reads quota numbers from the claude.ai dashboard and POSTs them to the local trayapp as authoritative snapshots.

Why a userscript and not an extension

  • Zero install friction. One .user.js file, drag into Tampermonkey, done.
  • Zero distribution overhead. No Chrome Web Store review, no signing.
  • Easy to inspect. Plain JS the user can read and modify.

A full extension would add background-script lifecycle, manifest v3 hassles, and storage permissions — none needed for this use case.

Behavior

The script is @match-injected on every claude.ai/* page but no-ops unless the URL indicates the user is viewing the usage settings tab, per isUsageRoute (userscript/lib/route.js). Two forms are accepted, because Anthropic has shipped both:

  • pathname exactly /settings/usage — the original full-page settings route.
  • hash route #settings/usage over any pathname — the modal dialog that replaced it around June 2026, e.g. https://claude.ai/new#settings/usage, where location.pathname is just /new.

Accepting either keeps the predicate meaning "the user intends to view usage," which is what lets a missing usage bar be reported as a genuine parse error rather than ignored on every unrelated page. On such a route:

  1. Wait for at least one usage-bar node to render (MutationObserver, with a sane timeout). A usage bar is matched by USAGE_BAR_SELECTOR (userscript/lib/bars.js), which accepts both markup generations Anthropic has shipped:
    • [role="progressbar"][aria-label="Usage"] — through early July 2026.
    • [role="meter"][aria-valuenow] — the design-system Meter component (data-cds="Meter") introduced July 2026. It carries no aria-label; its accessible name comes from aria-labelledby pointing at the row label. The "Usage credits" section renders its own role="meter" bar, which matches the selector but is discarded by the section-heading anchoring below.
  2. Anchor on section headings, not row labels. For each usage bar, find the most recent preceding <h2> or <h3> in document order (Anthropic has used both in different revisions). Section names are matched as a prefix against a list of known variants — a heading whose text starts with any of Your usage limits / Plan usage limits (the session section, both forms observed in the wild) or Weekly limits qualifies, even if a plan-tier badge has been concatenated onto the end (e.g. "Plan usage limitsMax (20x)", "Your usage limitsTeam"). Only two sections are kept:
    • session section → first bar in this section is "Current session" (% of the rolling 5-hour window).
    • Weekly limits → the first bar in this section that is not the Fable sub-row is the aggregate "All models" (% of the weekly limit). The one exception to heading-only anchoring is the Fable sub-row, which is claimed by row label (userscript/lib/rows.js, isFableRowLabel) because it sits under the same Weekly limits heading as the aggregate and nothing structural distinguishes them. Its accessible name comes from aria-labelledby pointing at the row label; matching is a case-folded prefix against accepted variants, the same hedge used for section headings. Note the bar's own data-variant / bg-* classes are not usable as an identity signal: they encode a threshold (accentwarning as a bar nears its limit), so the Fable bar's orange styling is a fact about its percentage, not about which row it is. If the label match fails, the fable field simply goes absent — the aggregate is still selected positionally, so the session and weekly lines cannot be collateral damage. Other sub-rows under "Weekly limits" (Sonnet only, Claude Design, future additions), the "Additional features" section (routines), and the extra-usage section are all ignored. Anchoring on section titles is more durable than matching row labels — Anthropic edits row text often, section headings less so but they do change (the session section was renamed from Plan usage limits to Your usage limits between April and May 2026). Carrying multiple accepted variants and matching as a prefix hedges against future cosmetic reshuffles.
  3. Read aria-valuenow (0–100) directly. We do not text-scrape the "X% used" label.
  4. Parse the page's "Last updated: N minutes ago" indicator into a staleness delta. The percent values and the "Resets in …" hint are accurate as of that timestamp, not as of Date.now(). observed_at is back-dated by the delta; the session-reset timestamp uses the back-dated time as its base (baseMs + Δ rather than now + Δ). When the indicator can't be found the snapshot is treated as current.
  5. Parse the row's reset hint into a UTC ISO timestamp:
    • "Resets in 3 hr 33 min" / "Resets in 19 min" → observedAt + Δ.
    • "Resets Thu 11:00 PM" → next future occurrence of that weekday at that local time, converted to UTC. Absolute clock-time hints are unaffected by page staleness. These land in session_window_ends / weekly_window_ends so the server can anchor the windows on Anthropic's actual reset boundary. Without a parseable hint the server declines to mint and the dashboard renders a [now, now+7d] hypothetical projection.
  6. POST to http://localhost:27812/snapshot.

Trigger sources, in priority order:

  • Persistent MutationObserver on document.body, filtered to aria-valuenow attribute changes. Fires within milliseconds of claude.ai's own poll updating the DOM, even on backgrounded tabs.
  • 60-second setInterval backstop. Catches the cases where the observer is torn down by an SPA re-render, or the tab is throttled below the observer's delivery cadence. The shorter interval (vs. the previous 5 minutes) is now affordable because freshness-driven dedup, below, prevents the backstop from generating duplicate rows on a stable plateau.
  • Initial sample on script start.

Freshness-driven dedup

Every trigger runs the extracted observation through a pure decision function (userscript/lib/dedup.js, shouldSend) that only emits a POST when at least one meaningful-change signal has fired since the last successful send. Because every trigger goes through the same gate, the backstop is free to fire aggressively without producing duplicate rows.

The five meaningful-change signals are:

  1. Session percent (aria-valuenow) changed. The bar visibly moved.
  2. Fable weekly percent changed. Its cap is visibly tighter than the session window's (77% used against the aggregate's 44% on the same page), so it gains whole points while the session bar is still rounding to the same integer — signal 1 cannot stand in for it. The weekly aggregate needs no equivalent signal: its denominator is larger than the session's, so it cannot advance without the session percent advancing first. Absent-ness is normalized (a missing key and an explicit null compare equal), or a page with no Fable row read against a pre-Fable state record would fire on every trigger and defeat the dedup entirely.
  3. Verbatim "Resets in …" text changed. Even when the percent is unchanged the row text ticks down; this is how we capture pure time advancement inside an active window.
  4. Limbo text appeared or disappeared. The row text matched "Starts when a message is sent" on one side and not the other.
  5. In limbo only, findLastUpdatedAgeMs returned a value strictly smaller than the most recently observed one — i.e. claude.ai's own poll fetched a fresh page. The reference is a rolling in-memory counter updated on every DOM read (whether or not we sent), not the last-sent age, because the last-sent age pins to its floor of 0 once a send lands while the page shows "just now" and would self-trap the trigger forever after. Null on either side is "no information" and must not fire.

Why "Last updated" is excluded as a generic trigger. The "Last updated: N minutes ago" indicator advances on pure wall-clock time even when nothing on the page has changed. Treating its tick as a meaningful change would defeat the entire dedup. It is consulted only inside limbo as the decrease signal — when its value drops, that's a positive sign of a fresh fetch landing, which is the only liveness evidence available while the visible numbers and the row text are frozen.

Persistent state

The dedup and continuity rules need to compare the current observation to the last successfully-sent one, including across tab reloads and SPA route changes. State is persisted in localStorage under the versioned key claude-usage-snapshot.state.v1; see "Pure-JS helpers and the test harness" below for the version-key rationale and cold-start fallback.

The record is written only after a successful POST, so an aborted send does not advance the anchor. The record carries the timestamp of the send, the percent, the verbatim reset text, the parsed windowEndsMs, the Fable weekly percent (always written, null when the row is absent, so the dedup comparison has a stable reference on both sides), and the observed session_active and weekly_active (each persisted only when the script positively detected limbo on that row). The "Last updated" age is not persisted — it lives only in a rolling in-memory counter for the limbo decrease trigger, because anchoring it to the last-sent state self-traps once the trigger ages-down to zero.

continuous_with_prev flag on every POST

Every snapshot body carries a boolean continuous_with_prev decided by userscript/lib/continuity.js (decideContinuity). It is true when the new observation can be linearly chained off the previous one and false when downstream consumers should treat it as the start of a fresh segment. The flag is false (start) when ANY of:

  1. No persisted state exists (cold start).
  2. Wall-clock gap from the previous send exceeds 15 minutes.
  3. Session percent decreased (indicates a window reset or a fresh page state).
  4. session_window_ends jumped by more than 1 hour (different window or a corrupt prior reading). Only the session boundary is checked here; the weekly boundary moves so rarely that drift is not a useful signal.

Otherwise it is true. Note session_active flipping is not a separate start signal — the four rules above already cover every transition that matters. The constants are named (WALL_CLOCK_GAP_MS, WINDOW_ENDS_JUMP_MS) so they are tunable; the values listed are the current defaults.

The server uses the flag for write-time plateau compaction (see docs/data-model.md) and the dashboard uses it as one of two inputs when deciding where to break the burn-down polyline (see docs/overview.md).

Note the scope limit: observation.percent here is the session percent, so the flag speaks only to session continuity. A weekly reset typically lands while the session bar sits at 0, making rule 3 a no-op (0 < 0 is false) and leaving the flag true across the discontinuity. Rather than add a second per-quota flag — which would need a schema change and could never repair snapshots already on disk — the renderer independently breaks on a strict decrease in whichever percentage it is plotting. Do not "fix" this by widening decideContinuity to consider the weekly or Fable percent: one flag cannot describe continuity for three curves at once, and a false value here also suppresses write-time plateau compaction.

Visibility-API spoof

The userscript header sets @run-at document-start so we can install a visibility-API spoof (userscript/lib/visibility.js, installVisibilitySpoof) before any other script reads the API. document.hidden is forced to false, document.visibilityState is forced to "visible" (and the webkit* mirrors), and a capture-phase listener on the document calls stopImmediatePropagation() on every visibilitychange / webkitvisibilitychange event so application handlers never fire.

The spoof targets a specific failure mode: when the OS screensaver kicks in or the window is minimized, claude.ai's poll loop pauses itself based on the visibility signal, the page's "Last updated" stops moving, and the userscript correctly suppresses sends (dedup) — leaving an honest but undesired gap in the chart. With the spoof installed, claude.ai's app stays unaware that the OS considers the tab hidden.

Limit: this only addresses pauses claude.ai performs itself on the JS visibility signal. Browser-level throttling of timers, requestAnimationFrame, and task scheduling sits below the JS API and cannot be reached from a userscript. If claude.ai's polling cadence is limited by that, the spoof will not be enough; the next escalation is a silent-audio loop to keep Chromium's per-tab throttling state in "foreground."

Why GM.xmlHttpRequest, not fetch()

A plain fetch() from https://claude.ai to http://localhost:27812 will fail in modern browsers for three independent reasons:

  • Mixed content blocking: HTTPS pages cannot make HTTP requests in page context. (Chrome treats localhost as a secure context for top-level navigation, but not as an exception to mixed-content rules for subresource requests in all cases.)
  • CORS preflight: a cross-origin POST with Content-Type: application/json triggers a preflight that the trayapp would have to answer specifically.
  • Private Network Access (Chrome): requests from a public origin to a private network destination require an additional preflight with Access-Control-Request-Private-Network: true.

Tampermonkey/Violentmonkey provide GM.xmlHttpRequest (and the older GM_xmlhttpRequest) which run in the extension's privileged context and bypass all three. The userscript must use this and declare its allowed destinations:

// ==UserScript==
// @match        https://claude.ai/*
// @grant        GM.xmlHttpRequest
// @connect      localhost
// @connect      127.0.0.1
// ==/UserScript==

Only this approach is guaranteed to work across Chrome, Firefox, and Edge.

Endpoint

POST http://localhost:27812/snapshot
Content-Type: application/json

{
  "observed_at": "2026-04-25T17:32:14Z",
  "source": "userscript",
  "session_used": 6.0,
  "session_window_ends": "2026-04-25T19:02:11Z",
  "weekly_used": 23.0,
  "weekly_window_ends": "2026-04-30T06:00:00Z",
  "fable_weekly_used": 77.0,
  "continuous_with_prev": true
}

session_used and weekly_used are 0–100 percentages, both nullable: when only one row is parseable the other field is omitted and the trayapp records what was found. fable_weekly_used is the "Fable" weekly sub-row, omitted whenever the row is absent or unmatched; it has no *_window_ends companion because the page reports the same reset hint for it as for the weekly aggregate. The Fable row alone is never sufficient to consider the page parsed — it is an optional sub-row, so treating it as sufficient would suppress the parse-error report that fires when the rows we actually depend on go missing. *_window_ends are RFC3339 timestamps derived from each row's "Resets …" hint; they're omitted when the hint is in a format the parser doesn't recognize (e.g. "Resets May 1" when the boundary is far enough out that Anthropic switches to a date). The server requires a future weekly_window_ends to mint a weekly window; in its absence (older script, parser miss, or limbo) the engine declines to mint and the dashboard renders a [now, now+7d] projection until a parseable hint arrives.

session_active and weekly_active are optional booleans the script emits only when it positively detects "no active window" limbo on the corresponding row (the row's "Resets …" hint is replaced by "Starts when a message is sent"). In that case the body includes "session_active": false and/or "weekly_active": false. The script never emits the true form; absence of the key means "unknown." Both fields are independent — the page commonly shows one row in limbo while the other is normal — and they share a single isLimboLabel helper that scans each row's ancestors for the literal phrase. See docs/data-sources.md and docs/no-active-session.md for how the server uses the tri-state signal.

CORS

Because the userscript uses GM.xmlHttpRequest (which bypasses CORS), the trayapp does not need to set CORS headers for the userscript path. CORS becomes relevant only if the dashboard itself is ever loaded from an origin other than the trayapp's own server, which is not currently planned.

Failure handling

The userscript must:

  • Not crash the page if the trayapp is unreachable. GM.xmlHttpRequest failures are swallowed with a console warning.
  • Not double-post on stable plateaus. The freshness-driven dedup (above) handles this on the client side; the server's write-time slide (docs/data-model.md) handles any duplicates that slip through.
  • Tolerate DOM changes. If the expected nodes are missing for >N seconds, post a parse_error payload to the local server (separate endpoint) so the trayapp can surface "userscript broke, please update" in the tray UI. The payload is a structured fingerprint (heading texts, progressbar/meter counts, pathname) — not raw page HTML — so conversation content and account names never leave the browser.

Distribution

A single file userscript/claude-usage-snapshot.user.js in the repo. The user installs once via their userscript manager. Auto-update can be configured via @updateURL and @downloadURL headers pointing at the file's GitHub raw URL — optional, off by default.

Pure-JS helpers and the test harness

Pure-JS helpers (parsing, row matching, persistent state, dedup, continuity, visibility spoof) live as CommonJS modules under userscript/lib/ so node --test can require() them directly. The userscript itself is a single Tampermonkey-loaded IIFE with no build step, so each helper's function bodies are also inlined into claude-usage-snapshot.user.js alongside the existing utilities. The lib copy is the source of truth; the inlined copy is what runs on claude.ai. A header comment in the inlined block points at the lib file so the duplication is discoverable; edit both together. test/inline-drift.test.js enforces this for the row-matching helper: it extracts the inlined copy from the userscript source and asserts it agrees with lib/rows.js on every input the lib tests cover, so a one-sided edit fails the suite instead of shipping. This keeps the test harness simple and the userscript install footprint a single file. If the helper count grows enough that the duplication becomes painful, a small concat step (Make target that prepends lib bodies into the user.js) is a fine future move.

Persistent state lives under localStorage key claude-usage-snapshot.state.v1. The version suffix is intentional: a future schema change is handled by picking a new key, at which point the old key is naturally treated as absent (cold start). loadState() returns null on any read failure — corrupt JSON, missing fields, storage exceptions, or a value under a different version — so the dispatch path never throws on a poisoned record.

Limitations (deliberate)

  • Only fires when the user has the page open. This is not a bug; it's the design.
  • Cannot read anything not visible in the DOM. If Anthropic moves quota detail to a separate page or behind a click, the script must be updated.
  • Does not authenticate. The trayapp trusts any localhost POST; the threat model assumes the host is the trust boundary.