-
Notifications
You must be signed in to change notification settings - Fork 3
Domains Quota
The quota domain turns provider subscription usage into a uniform, read-only
snapshot model and serves it to presentation surfaces without blocking the
render path. Four adapters read the accounts Clio can see: the Anthropic OAuth
credential Clio herself holds (anthropic-max), the Claude Code CLI's stored
login (claude-code), the Codex CLI's stored login (codex), and the
Antigravity (agy) CLI's token file (antigravity). A TTL cache sits between
adapters and callers so repeated looks do not spend repeated reads, and a set
of pure helpers in presentation.ts fold those snapshots into footer segments,
a welcome-banner line, and severity words.
The module's stated design constraint is lazy refresh: src/domains/quota/service.ts
opens with "Refresh stays lazy, as the decision log requires: no background
timer," and src/domains/quota/cache.ts repeats it, "Per the quota decision
log there is no standalone background timer." The only refresh triggers in
source are a caller invoking read() and the summary feed scheduling an
off-frame read after a surface's peek().
| Path | Role |
|---|---|
src/domains/quota/types.ts |
UsageSnapshot, UsageWindow, QuotaProvider contract, parseRetryAfterSeconds, formatPlan
|
src/domains/quota/registry.ts |
buildQuotaProviders() assembles the four adapters in display order |
src/domains/quota/service.ts |
createQuotaService() with peek(), read(), detected()
|
src/domains/quota/cache.ts |
createQuotaCache(), DEFAULT_QUOTA_CACHE_TTL_MS = 5 * 60_000
|
src/domains/quota/anthropic-usage.ts |
fetchAnthropicUsage() shared by the two Anthropic adapters |
src/domains/quota/anthropic-max-provider.ts |
createAnthropicMaxQuotaProvider() reads Clio's own auth storage |
src/domains/quota/claude-code-provider.ts |
createClaudeCodeQuotaProvider() reads Claude Code's token file |
src/domains/quota/codex-provider.ts |
createCodexQuotaProvider() reads Codex's auth.json
|
src/domains/quota/antigravity-provider.ts |
createAntigravityQuotaProvider() reads the agy token file |
src/domains/quota/presentation.ts |
severity, folding, footerQuotaSegment, quotaSummaryLine, localQuotaSnapshot
|
src/domains/quota/summary-feed.ts |
createQuotaSummaryFeed() synchronous render-path reader |
src/interactive/interactive-presentation.ts |
composes the feed into welcome banner, footer, and fleet dock |
The quota domain has no index module; consumers import its modules directly.
The domain's own contract, stated in src/domains/quota/types.ts, is that
adapters stay pure data plumbing: "no UI, no dispatch admission, no credential
writes."
UsageSnapshot (src/domains/quota/types.ts) carries one provider's facts at
one instant: providerId, displayName, a QuotaStatus
("ok" | "no_credentials" | "expired" | "error" | "loading"), a list of
UsageWindow, optional credits, plan, message, retryAfterSeconds, a
stale flag, and an ISO 8601 fetchedAt. Windows carry a stable key
("session" or "weekly" for the shared columns, weekly_scoped.<name> for
per-model Anthropic scopes, group.<n>.<key> and model.<name> for
Antigravity), a usedPct of 0–100, an ISO resetsAt, a compact short label,
an optional scope, a provider-supplied severity, and an active flag.
Timestamps are strings, not Date, so a snapshot stays cloneable across the
worker and transport boundaries.
QuotaProvider requires id, displayName, detect() (credential present,
no network), and fetch() (returns a snapshot and never throws, by contract).
buildQuotaProviders() (src/domains/quota/registry.ts) returns the adapters
in display order: anthropic-max, then claude-code, codex, antigravity,
each wrapped in a guard:
...(!isClioHomeRelocated() || process.env.CLAUDE_CONFIG_DIR?.trim() ? [createClaudeCodeQuotaProvider()] : []),isClioHomeRelocated() (src/core/xdg.ts:114) is true when any of the four
CLIO_CODER_{CONFIG,DATA,STATE,CACHE}_DIR roles differs from the platform
default. Under a relocated home the external adapters are omitted because their
default credential paths would point at the operator's real home; setting the
matching env var (CLAUDE_CONFIG_DIR, CODEX_HOME, ANTIGRAVITY_HOME) keeps
them. The anthropic-max adapter is never gated this way, because its
credential lives in Clio's own storage. The file also marks a reserved slot:
"The copilot adapter attaches here once its credential shape is confirmed."
Both Anthropic adapters call fetchAnthropicUsage(token, { fetch, timeoutMs })
(src/domains/quota/anthropic-usage.ts), a GET on
https://api.anthropic.com/api/oauth/usage with an anthropic-beta: oauth-2025-04-20 header. The endpoint has shipped two window shapes and can
send both at once; limits[] wins because only it carries severity,
is_active, and per-model scope, with five_hour/seven_day flat buckets as
fallback. HTTP 401/403 maps to "expired", 429 to "error" with
retryAfterSeconds parsed from the Retry-After header, anything else to
"error". The spend block becomes credits when enabled is true.
createAnthropicMaxQuotaProvider() (src/domains/quota/anthropic-max-provider.ts)
reads Clio's own stored OAuth record: openAuthStorage().get("anthropic")
(src/domains/providers/auth/storage.ts), which unlike resolveApiKey does
not renew an expiring credential as a side effect. A quota read must never
mutate an authentication record, the file says. If the token's expiresAtMs
is past, it returns "expired" with advice ("the next turn refreshes it" when
a refresh token exists), skipping the network entirely.
createClaudeCodeQuotaProvider() (src/domains/quota/claude-code-provider.ts)
reads Claude Code's own ~/.claude/.credentials.json (or
$CLAUDE_CONFIG_DIR/.credentials.json), parsing the claudeAiOauth record.
Its expiryMessage() distinguishes two cases from the record alone: an expired
access token with a live refresh token means "run the claude command once to
refresh it"; without a usable refresh token the advice is to sign in again. It
is the only Anthropic adapter that stamps a plan field, via
formatPlan(credentials.subscriptionType).
createCodexQuotaProvider() (src/domains/quota/codex-provider.ts) reads
$CODEX_HOME/auth.json or ~/.codex/auth.json for tokens.access_token and
tokens.account_id, then GETs https://chatgpt.com/backend-api/wham/usage
with User-Agent: codex-cli and, when present, a ChatGPT-Account-Id header.
rate_limit.primary_window maps to key session (label "5h") and
secondary_window to weekly, but windowKey() reclassifies by the
limit_window_seconds value: 86400 seconds or more means weekly. Credits come
from the credits block: unlimited: true renders "Unlimited", and a
has_credits balance renders the raw string. 401/403 is "expired".
createAntigravityQuotaProvider() (src/domains/quota/antigravity-provider.ts)
reads $ANTIGRAVITY_HOME/antigravity-oauth-token or
~/.gemini/antigravity-cli/antigravity-oauth-token. It is deliberately
narrower than a general OAuth client: there is no token refresh, because
"Refreshing would mean POSTing another product's refresh token to Google with
client credentials scraped out of the agy binary." A stored token within 60
seconds (EXPIRY_SKEW_MS) of expiry is treated as already expired, and the
message tells the operator to run agy once.
The fetch loops over three endpoints in order
(daily-cloudcode-pa.googleapis.com, its sandbox, then
cloudcode-pa.googleapis.com). Per endpoint, collect() first POSTs
v1internal:loadCodeAssist to discover the cloudaicompanionProject and
currentTier, then prefers v1internal:retrieveUserQuotaSummary when a
project exists and the summary yields windows. The fallback is
v1internal:fetchAvailableModels, which reports per-model quotas for names
starting with gemini, claude, gpt, image, or imagen. Summary groups
are preserved in order with Gemini groups sorted first; windows from the
second group onward get keys prefixed group.<index>.. A 401/403 on any
endpoint is remembered as sawAuthFailure and wins at the end ("Sign in with
Antigravity again"), while transport errors roll into the last message.
createQuotaCache() (src/domains/quota/cache.ts) keeps one entry per
provider (Map<string, { snapshot, storedAtMs }>) with a clock injected for
tests. Its methods:
-
read(providerId)returns the snapshot only inside the TTL. -
readLastGood(providerId)returns the snapshot regardless of age, copying it withstale: truewhen expired. -
write(snapshot)stores a new last-good value with the current clock. -
clear(providerId?)drops one entry or all. -
resolve(provider)is the refresh entry point: fresh cache hits return immediately; otherwiseprovider.fetch()runs, an"ok"result is written, and a failed result falls back to the cached entry markedstalewith the failure's message preferred. A failure with no prior entry passes through.
createQuotaService() (src/domains/quota/service.ts) owns the provider list
(default buildQuotaProviders()) and a cache
(default createQuotaCache() with the 5-minute TTL). Its three methods:
-
peek()servescache.readLastGood()for every provider, so a first paint never spends a read; expired entries come back markedstale. -
read()fans outcache.resolve(provider)to all providers concurrently withPromise.all"because they are independent accounts, and a slow one must not hold up the others." A thrown provider error is caught per provider and converted to an"error"snapshot, and the result filters outno_credentialsentries. -
detected()runs every provider'sdetect()concurrently and returns the ones holding usable credentials, without spending a usage read.
The service also appends the local-inference row. includeLocal defaults to
"any configured target whose runtime tier is local-native" (read once at
construction from readSettings().targets and getRuntimeRegistry()), and
localRuntimeLabel overrides the display name. The local row is built by
localQuotaSnapshot() (src/domains/quota/presentation.ts): provider id
"local", empty windows, credits: { display: "$0.00", usedPct: null },
plan: "Local", and the message "no subscription window consumed". Its
comment says the saved-quota figure is deliberately absent because proving it
needs per-target token attribution this slice does not collect.
The interactive session wires the pieces in
src/interactive/interactive-presentation.ts. It creates the feed once:
const quotaSummary = createQuotaSummaryFeed({
onUpdate: () => { footer?.refresh(); requestRender(); },
});The feed (src/domains/quota/summary-feed.ts) is the bridge that makes a
synchronous render path safe. peek() answers from the last reading
immediately; when that reading is missing or older than QUOTA_SUMMARY_TTL_MS
(5 minutes), it schedules refresh(), which returns synchronously while a
detached void Promise.resolve().then(...) chain runs service.read(). The
inFlight flag deduplicates concurrent refreshes, and onUpdate fires only
when JSON.stringify(nextSnapshots) !== JSON.stringify(snapshots), which is
how a repaint is requested. dispose() sets a flag that makes refresh and
onUpdate inert, called from the teardown path before other subscriptions go.
The welcome dashboard receives getQuotaSummary: () => quotaSummary.peek()
(the comment at interactive-presentation.ts:270 says "the banner reads a
string and never awaits a provider"), the fleet dock and footer receive
getQuotaSnapshots: () => quotaSummary.peekSnapshots().
sequenceDiagram
participant R as Welcome banner / footer
participant F as QuotaSummaryFeed
participant S as QuotaService
participant C as QuotaCache
participant P as QuotaProvider(s)
R->>F: peek()
alt no reading or read older than 5m
F-->>R: null or last line
F-)F: schedule refresh() (off frame)
F->>S: read()
loop per provider, concurrently
S->>C: resolve(provider)
alt cached entry fresh
C-->>S: cached snapshot
else expired or absent
C->>P: fetch()
alt ok
P-->>C: snapshot
C-->>S: snapshot (written as last good)
else failed and prior entry exists
P-->>C: failed snapshot
C-->>S: stale-marked last good
else failed and no prior entry
P-->>C: failed snapshot
C-->>S: failed snapshot
end
end
S-->>F: snapshots (no_credentials dropped, local appended)
F->>F: quotaSummaryLine() + change detection
F--)R: onUpdate() -> footer.refresh(), requestRender()
else reading still warm
F-->>R: cached line
end
severityForPct() (src/domains/quota/presentation.ts) classifies a window
at 60% caution, 80% warning, 95% critical, else normal. windowSeverity()
prefers the provider's own word when it is one of the four recognized
severities ("Anthropic sends its own severity and that is preferred");
snapshotSeverity() returns the worst window.
primaryWindow() picks the window closest to biting, by severity rank then by
higher usedPct. foldDuplicateAccounts() folds anthropic-max and
claude-code snapshots that describe the same account: both must be "ok",
carry the same number of windows with matching keys, scopes, and usedPct
within 0.5, and resetsAt equal or within 1 second of each other (the live
endpoint adds per-response milliseconds). When folded, the snapshot carrying a
plan label survives.
footerQuotaSegment() renders one compact segment per account: the session
window as "5h X% used", the weekly window as "wk X% used", plus the
primaryWindow when it is a scoped window neither of those (the binding limit),
or falls back to the primary window's short/label when the provider names
neither column. Accounts are joined with " · " and display names shortened to
their first word. quotaSummaryLine() appends the local row's credit display
("Local $0.00") after the paid segments, so free local inference is visible
rather than omitted.
The interactive layer colors and renders these strings: src/interactive/quota-view.ts
(renderQuotaAccounts, routeWeeklyQuota, workerQuotaLabel, quotaMeter)
is consumed by the footer status page (src/interactive/footer/pages.ts), the
usage overlay (src/interactive/usage-overlay.ts), and the dispatch board
(src/interactive/dispatch-board.ts).
-
No network on the render path.
QuotaSummaryFeed.peek()and the welcome dashboard'sgetQuotaSummary()supplier are synchronous; the feed defers the actual read to a detached promise, documented insrc/domains/quota/summary-feed.tsas keeping "the decision log's lazy refresh rule: a read happens because a surface was looked at, never on a timer." -
Provider failures degrade, never throw.
service.read()catches per provider and returns an"error"snapshot; the feed's.catch()leaves the previous line standing because reaching it means "something outside the adapters broke, and the banner should not start flickering because of it." -
Credential isolation. The registry gates external adapters on
isClioHomeRelocated()and the env-var overrides; each file-based adapter also returnsnullfrom its default path when relocated and the override is unset. -
Read-only credentials.
anthropic-maxusesAuthStorage.get()instead ofresolveApiKey()to avoid a refresh side effect; the three external adapters read files and never write them;antigravityexplicitly refuses to refresh tokens. - Failure precedence in Antigravity. Auth failures on any endpoint override transport errors across the endpoint loop, and the first endpoint whose windows are non-empty ends the loop.
-
Snapshot staleness is preserved.
cache.readLastGood()and theresolve()fallback both setstale: true, and surfaces render it ("STALE · last good reading" insrc/interactive/quota-view.ts).
-
New provider adapter. Implement
QuotaProvider(id,displayName,detect,fetch) with failure reported as snapshot status, then add the factory call inbuildQuotaProviders()(src/domains/quota/registry.ts) at the desired display position. The copilot comment marks the intended next slot. A relocated-home guard following the existing pattern keeps tests and relocated deployments isolated. -
New shared Anthropic surface.
fetchAnthropicUsage()already centralizes the endpoint and both window shapes; a new adapter on the same account supplies its own token and stamps its ownproviderId/displayName, as the two existing adapters do. -
New compact surface. Add a helper in
src/domains/quota/presentation.tsoperating onUsageSnapshot[]; keep it free of terminal and theme imports so the interactive layer decides color, as the module header requires. -
Fold rules. If a third credential can sit on an Anthropic account,
foldDuplicateAccounts()'s hardcoded{"anthropic-max", "claude-code"}set is the place to widen.
tests/contracts/quota-presentation.test.ts (run with
pnpm run test:file -- tests/contracts/quota-presentation.test.ts):
- "classifies severity by threshold and prefers the provider's own word":
asserts
severityForPct(60)= caution, 80 warning, 95 critical, and that a window withseverity: "normal"at 99% stays normal while an unrecognized word falls back to the threshold. - "reports a snapshot's worst window as its severity": a mixed 3%/88% snapshot is warning; an empty window list is normal.
- "picks the window closest to biting as the compact one": a weekly-only Codex snapshot returns the weekly window as primary.
- "folds two credentials that report the same account, keeping the labelled one":
identical anthropic-max/claude-code windows fold to one entry, the
claude-code snapshot with
plan: "Max"surviving. - "keeps genuinely different accounts apart": claude-code, codex, and antigravity snapshots stay three.
- "builds a compact footer segment, showing 5h only where a provider reports one":
asserts the exact string
"Claude 5h 6% used/wk 9% used · Codex wk 76% used · Antigravity 5h 0% used/wk 73% used", establishing that Codex's weekly-only account gets no 5h column. - "reads every provider concurrently and appends the free local row": with two
fake providers and
includeLocal: true, the firstread()spends one fetch per provider, drops theno_credentialsprovider, and appendslocal; a secondread()spends zero fetches for the good provider but one for the absent one, "since signing in must take effect." - "survives a provider that throws instead of returning a snapshot": the broken
provider yields an
"error"snapshot with the thrown message, anddetected()returns empty. - "peeks without spending a read":
peek()returns empty before any read and still spends zero fetches.
tests/contracts/quota-tui.test.ts:
- "quota feed shares one lazy read, refreshes detail-only changes, and stops on disposal":
a fake service with
ttlMs: 100and an injected clock; the firstpeek()returns null and schedules exactly one read; after the read,onUpdatefired once; advancing the clock 101ms and making all snapshotsstalefires a second read and a second update "even when the percentage summary is unchanged";dispose()then suppresses further reads. - "welcome subscriptions stay in the field list and wrap without dropping accounts or local cost":
renders
createWelcomeDashboardat widths 40–220 and asserts every account and "Local $0.00" survive, in field order Targets → Subscriptions → Fleet. - "footer keeps unassociated accounts out of compact rows and supplies all accounts to Status": the compact footer row contains none of the account names, while the expanded status page shows "Claude Code (Max) ... 5h 6% ... Weekly 9%", "Codex (Pro) ... Weekly 76%", and "Local AI ... $0.00".
- "footer producer follows selected runtime changes instead of picking the busiest account": the selected runtime "claude" shows "weekly 91% left" and no other account; switching to a local target removes quota rows entirely.
tests/contracts/quota-local-target.test.ts: with an isolated env
and empty provider list, configuring a target with runtime openrouter yields
no local row from peek()/read(), switching the same target to runtime
ollama yields exactly one local snapshot, and includeLocal: false forces
empty.
tests/contracts/quota-views.test.ts:
- "reset labels include a countdown, local wall time, timezone, and an honest elapsed state":
quotaResetLabelrenders "in 1h 0m" with wall clock and timezone, "in 2d 2h" for longer durations, and "Reset time passed · awaiting provider update" for a past instant. - "meters show consumed capacity consistently and remain bounded": 0% is all empty glyphs, 120% and -10% both clamp to full width.
- "equal percentages across providers never merge unrelated accounts": codex
vs antigravity stay two; claude-code vs anthropic-max fold to one even when
resetsAtdrifts by 70ms, but not when one side isnull. - "worker quota joins only known local credential owners and does not claim a per-worker share":
workerQuotaLabellinksantigravity-codeto the Antigravity account, says "unavailable for remote credentials" for a remote node, and "not linked" for runtimes without a local credential owner. - "weekly badges select the model's own group and never infer another account's quota":
routeWeeklyQuotamatches a gemini model to the Gemini group and claude/gpt models to the other group on Antigravity, returns null for unknown models, remote nodes, unrelated runtimes, expired accounts, and model-scope-only windows, and prefixes "STALE · " when the account is stale.
-
The two Anthropic adapters share one endpoint. New window shapes must be
handled in
anthropic-usage.tsfor both adapters to see them; thelimits[]-over-flat-buckets precedence is intentional because only the array carries severity, active, and scope. -
limits[]is preferred when non-empty even if flat buckets are also present, per the observed double-serving; adding a third shape needs the same precedence care. -
The 1-second
resetsAttolerance infoldDuplicateAccounts()exists because the live endpoint adds per-response milliseconds; tightening it re-splits the same account into two budgets, and the 0.5%usedPcttolerance covers the same drift. -
Antigravity has no refresh on purpose. Adding one means POSTing a
refresh token to Google with credentials scraped from the
agybinary, which the file comments reject; the 60-second expiry skew is the only softening. - The Antigravity endpoint list is an ordered fallback, and auth failure on a later endpoint is recorded even if earlier endpoints succeeded silently; reordering changes which error wins.
-
includeLocalis resolved once atcreateQuotaService()construction, from live settings; a target added at runtime is not picked up until a new service exists (the summary feed keeps one service for the session). -
service.read()dropsno_credentialsbutservice.peek()does not:peek()returns every last-good snapshot as-is (status included), so a raw consumer can seeno_credentialsentries, while a freshread()never does. The feed's string line is unaffected either way, becausefooterQuotaSegment()skips snapshots whose status is not"ok". -
The cache is in-memory and per-service-instance; the file says persisting
across sessions is a later concern. There is no eviction besides
clear(). -
The local row's
$0.00is a display constant, not a measurement; do not add cost accounting expectations tolocalQuotaSnapshotwithout the per-target attribution the comment says is missing.
Source and generation metadata
title: "Domains quota"
summary: "How Clio reads Anthropic, Codex, and Antigravity subscription usage into shared snapshots, caches them with a 5-minute TTL instead of a background timer, and feeds the footer, welcome banner, and usage overlay."
sources:
- "src/domains/quota/registry.ts"
- "src/domains/quota/service.ts"
- "src/domains/quota/cache.ts"
- "src/domains/quota/types.ts"
- "src/domains/quota/presentation.ts"
- "src/domains/quota/summary-feed.ts"
- "src/domains/quota/anthropic-max-provider.ts"
- "src/domains/quota/claude-code-provider.ts"
- "src/domains/quota/codex-provider.ts"
- "src/domains/quota/antigravity-provider.ts"
- "src/domains/quota/anthropic-usage.ts"
- "src/interactive/interactive-presentation.ts"
symbols:
- "createQuotaService"
- "createQuotaCache"
- "createQuotaSummaryFeed"
- "buildQuotaProviders"
- "fetchAnthropicUsage"
- "footerQuotaSegment"
- "quotaSummaryLine"
- "localQuotaSnapshot"
- "foldDuplicateAccounts"
tests:
- "tests/contracts/quota-presentation.test.ts"
- "tests/contracts/quota-tui.test.ts"
- "tests/contracts/quota-local-target.test.ts"
- "tests/contracts/quota-views.test.ts"
invariants:
- "A usage read is spent only because a surface looked at a snapshot; there is no background timer."
- "A provider failure never throws out of the service; it is reported as a snapshot status that surfaces render."
- "Snapshots with status no_credentials are dropped from read() results, so absent accounts never render."
- "A fresh snapshot inside the TTL suppresses further reads, and a failed read falls back to the last good snapshot marked stale."
- "With a relocated Clio home, the claude-code, codex, and antigravity adapters are excluded unless CLAUDE_CONFIG_DIR, CODEX_HOME, or ANTIGRAVITY_HOME is set."
validate:
- "pnpm run test:file -- tests/contracts/quota-presentation.test.ts"Clio Coder · Repository · Website · Documentation
Wiki v0.1 · Developing implementation reference · Source snapshot: 657dce13d. Authored architecture documents define the product contracts.
- Clio Coder GUI Client
- apps / clio-coder-gui
- Apps clio coder gui server
- Apps clio coder gui tests
- apps
- Architecture
- Command-line surfaces
- Core
- Domains agents
- Config Domain
- Context Domain
- Dispatch domain
- Domains evidence
- Domains extensions
- Domains gateway
- domains
- Domains interop
- Domains lifecycle
- Domains memory
- Middleware Domain
- Domains mux
- Domains observability
- Domains plugins
- Prompt Compiler
- Domains providers
- Domains quota
- Domains resources
- Domains safety
- Domains scheduling
- Domains session
- Vendored Tool Registry and Resolution
- Engine
- Engine acp
- Engine apis
- engine
- Entry point
- Interactive
- interactive
- Interactive overlays
- Interactive renderers
- clio-coder wiki
- Scripts
- Contract tests
- Tests extended
- tests
- Tools
- Tools data
- tools
- Tools verify
- Worker runtime