@cad0p/pi-usage0.11.6
Read-only usage/quota for the AI subscriptions Pi already owns (Claude, Codex, multi-account OpenCode Go, Antigravity): /usage in Pi and the Omarchy Agents panel. Fork of @j3pf/piquota.
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-usage
One source of truth for quota: the credentials Pi already owns.
[!NOTE]
@cad0p/pi-usageis a fork of J3pf/piQuota (@j3pf/piquota). It adds:
- OpenCode Go from the API key:
GET https://opencode.ai/zen/go/v1/usagereturns the rolling (5h), weekly and monthly windows for the key Pi already stores, so no browser session is needed.- Numbered accounts:
auth.jsonentries such asopencode-go-2…opencode-go-14(the pi-fallback-provider sibling convention) are read as extra accounts of their base provider. The accounts of one provider are queried in parallel (8 at a time,PI_QUOTA_ACCOUNT_CONCURRENCYfrom 1 to 32), so 14 OpenCode Go keys cost about one round trip instead of fourteen. Every request carriesUser-Agent: pi-usage/<version> (+https://github.com/cad0p/pi-usage)unless a provider needs its own client string: OpenCode Go asks clients not to use a generic HTTP-library name, and Node's default isnode.- One Omarchy tab per provider: a multi-account provider shows each account's binding window (
#2 · Monthly) in a single tab, and summarises failed accounts.- Tokens by day and by model: the tokens Pi's own session files recorded are folded per provider and shown in
/usage, in--json(localStats) and in the Omarchy records, at a bounded scan cost./usageon demand: the Pi command is/usageonly, and nothing is pinned above the editor or in the footer unless you ask for it. A multi-account provider gets one line per account in the panel.- No dead Claude store:
autopasses over an expired Claude Code token when Pi's ownanthropiclogin is fresh, and says so in the warnings.Everything below still applies; the CLI is still called
piquota.
pi-quota is a standalone project. It does not import, invoke or modify
shuvquota, and it never writes to a credential file.
[!TIP] Working with AI Agents? See docs/ARCHITECTURE.md for architectural boundaries, test runner commands, and hard invariants for AI coding agents, and docs/verification.md for the verification checklist every change follows.
| Surface | Command |
|---|---|
| Terminal | piquota · piquota --json · piquota --status · piquota --compact |
| Pi TUI | /usage |
External / rp-host / automation |
piquota --json (structured JSON feed with primaryWindowId and countdowns) |
| Omarchy Bar Panel | piquota omarchy (or systemd user timer via --omarchy-timer) |
[!NOTE] Moshi Integration Removed in v0.11.0: Previous versions included an integration with the Moshi mobile app and
moshi-hook. Starting with v0.11.0, Moshi-specific commands and approval mirroring are removed in favor of direct local integrations such aspiquota --jsonforrp-host/ Rp-app andpiquota omarchyfor Omarchy.
Hard guarantees
pi-usage opens
~/.pi/agent/auth.jsonwithflag: "r"and never writes it. CLI and systemd-timer runs are strictly read-only. In the Pi extension only,getProviderAuthmay ask Pi to renew Anthropic/Codex OAuth under Pi's own cross-process auth lock; pi-usage never writes auth storage itself. Check the auth.json checksum around live runs.No token, refresh token, API key, session cookie or
sessionKeyis printed, logged, or serialized to a file. Tokens are sent only to their provider; the extension holds resolved access tokens in memory for the request. Antigravity's renewed access token stays in a process-local cache until shortly before expiry. E-mail identities are redacted in every artifact.Antigravity may be renewed in memory because Google does not rotate its refresh token. The CLI and timer never refresh Claude/Codex; inside Pi, the extension calls Pi's
getProviderAuth, which returns fresh OAuth directly or lets Pi renew and persist it under Pi's auth-store lock. pi-usage itself never writes auth.json. Every surface reports the token the run used: an Antigravity renewal clears the stale stored-expiry claim; only a failed renewal leaves it.The Claude Code store (
~/.claude/.credentials.json) is opened read-only too, and its refresh token is never even read into memory, so nothing here can rotate a token the installedclaudeowns.~/.claude.jsonis read only for the account e-mail and display name; its project history is never touched.Browser cookie databases are copied to a private temp dir and opened read-only. This is only used for the OpenCode Go fallback, when the key usage endpoint is gone and no session cookie is configured.
/usagenever injects quota into the LLM context: the extension only callsui.setStatus,ui.setWidgetandui.notify.Pi's session files (
~/.pi/agent/sessions/**/*.jsonl) are opened read-only, to count tokens per day and per model. The scan cache keys each file by its path, size and byte offset and stores open days; the durable history under~/.pi/agent/pi-usage/stores frozen days. Both hold only aggregate counts and model names — no prompt text or tool output reaches a cache, a record or the report.Nothing is written outside these paths:
Path What ~/.cache/pi-quota/usage.jsonthe report cache, with each family's own fetch time ~/.cache/pi-quota/local-sessions.jsonper-session-file offsets and open token days ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-usage/totals.json,tokens-YYYY-MM.jsonfrozen token history, plus .corrupt-<ts>backups for corrupt files~/.cache/pi-quota/refresh-state.jsoneach family's own refresh clock ~/.cache/pi-quota/last-published.json,last-good.json,backoff.jsonsticky snapshots and throttle state ~/.config/pi-quota/opencode-cookieonly after piquota auth opencode, mode600~/.pi/agent/extensions/*.tsonly by install.sh
Status, verified against the live APIs
| Provider | Source | Endpoint | Result |
|---|---|---|---|
| Claude | Claude Code CLI store, else anthropic.access |
GET api.anthropic.com/api/oauth/usage |
✅ 5h + weekly + plan |
| Codex | openai-codex.access + accountId |
GET chatgpt.com/backend-api/wham/usage |
✅ 5h + weekly + plan |
| Antigravity | google-antigravity.access (or legacy antigravity) + projectId |
POST cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary |
✅ Gemini + Claude/GPT buckets, with in-memory refresh |
| OpenCode Go | opencode-go.key, numbered siblings included |
GET opencode.ai/zen/go/v1/usage |
✅ 5h + weekly + monthly per account; the session cookie and SSR dashboard are a base-account fallback |
| GitHub Actions (opt-in) | Authenticated gh CLI |
gh api /organizations/<org>/settings/billing/usage/summary?product=Actions |
✅ org-wide monthly Linux minutes, plan-derived allowance |
Four findings worth recording, each of which cost a wrong hypothesis:
- Antigravity needs the CLI
User-Agent. WithoutUser-Agent: antigravity/cli/...the backend answers403 You do not have a valid license of this productfor a healthy token. - Pi's
opencode-goentry is an OpenCode Zen API key, and that key reads its own Go usage.GET /zen/go/v1/usageanswers with the rolling (5h), weekly and monthly windows for the key's workspace;401means the key was revoked and403 Workspace access deniedmeans the key is valid but its workspace has no Go plan. Only a404(endpoint gone) or an entry without a key falls back to the session cookie and the authenticated SSR dashboard, and only for the base account, because one browser session belongs to one workspace. primaryWindowIdidentifies the most urgent window. The CLI computes the shortest, most pressing window (e.g. 5h session before weekly) so downstream UI consumers likerp-hostand the Pi TUI render priority pills without reimplementing window precedence.- Both Claude OAuth stores answer the same endpoint. Pi's own
anthropicentry and the Claude Code CLI store return identical windows, which is what makes the second source a drop-in replacement rather than a separate feature.
Claude has two sources
There are two ways to be signed in to Claude, and a user may have either or both:
| Source | Store | Why it exists |
|---|---|---|
| Claude Code CLI | ~/.claude/.credentials.json (honours CLAUDE_CONFIG_DIR); on macOS, when that file is absent, the Claude Code-credentials login Keychain item (read-only, via security find-generic-password; macOS may ask once to allow access) |
What npm:pi-claude-code-provider drives. A subscription with usage credits disabled answers 400 on Pi's own entry for every request, and this is the source that still works. |
| Pi's own entry | ~/.pi/agent/auth.json → anthropic |
/login anthropic in Pi, without the plugin installed. |
For piquota CLI and timer runs, the Claude Code CLI wins when both are
present and its token is still valid. The choice is a preference, not a fallback:
only the selected source is tried, so a broken preferred source is reported rather
than silently masked by the other one. An expired Claude Code token next to a fresh
Pi login is passed over in auto mode with a warning. Override CLI selection with
PI_QUOTA_CLAUDE_SOURCE=pi or =claude-code; --explain prints what was used.
Inside Pi, the extension asks Pi's getProviderAuth("anthropic") for OAuth first.
A fresh token is returned as-is; when it is near expiry, Pi renews and persists it
under its auth-store lock, then the extension uses that returned token in memory.
If Pi has no OAuth login, the configured source preference applies.
The Claude Code store is opened read-only, and its refresh token is deliberately
never carried into memory: Anthropic rotates refresh tokens, and a rotation
performed behind the CLI's back would sign the installed claude out. Refresh, if
any, stays the CLI's business.
GitHub Actions credential source
GitHub Actions does not use a Pi-store credential. Quota requests are delegated to GitHub CLI, which owns authentication; piQuota never reads the token value.
| Source | Credential | Why it exists |
|---|---|---|
| GitHub CLI | Its authenticated session, or GH_TOKEN / GITHUB_TOKEN passed to gh |
Reads organization billing usage without putting the token in piQuota's report or cache. |
Enable the family with PI_QUOTA_GITHUB_ACTIONS=1 or select it explicitly with
piquota github-actions (piquota gh and piquota actions are aliases). Use
gh auth login if the CLI is not authenticated; organization billing access and
the read:org, repo, and workflow scopes may be required.
| Variable | Default | Effect |
|---|---|---|
PI_QUOTA_GITHUB_ACTIONS |
off | Enables the family for default runs; truthy values are 1, true, yes, and on. |
PI_QUOTA_GITHUB_ORG |
KoralisSoft |
Organization queried for its plan and usage. |
PI_QUOTA_GITHUB_ACTIONS_MINUTES |
plan-derived | Positive integer monthly allowance override; unsupported plans require this setting. |
PI_QUOTA_GITHUB_ACTIONS_REPOS |
empty | Optional comma-separated repo names used only to filter attribution; quota remains org-wide. |
Prerequisites & Installation
- Node.js >= 22.19.0 (native
node:sqliteand globalfetchsupport; zero npm dependencies). - Pi Coding Agent (
pi). - Gentle AI (
gentle-pi). - Platform support: Linux, WSL2, macOS. See docs/OS-COMPATIBILITY.md for OS-specific details.
1-Line Quick Install
curl -fsSL https://raw.githubusercontent.com/cad0p/pi-usage/main/install.sh | bash
Install the Pi extension from npm
pi install npm:@cad0p/pi-usage
Loads both Pi extensions straight from the package. The quota panel resolves
the CLI from inside the package, so nothing else is required to see quotas in
the TUI.
When you also run install.sh for the terminal CLI, pass
--no-extension so the two copies cannot drift apart.
| Capability | pi install npm: |
install.sh |
|---|---|---|
TUI panel, sidebar and /usage |
yes | yes |
| Omarchy bar records | yes, written on every refresh | yes |
piquota on your PATH for terminal use |
no | yes, symlinked |
| Omarchy sync while Pi is closed | no | yes, with --omarchy-timer |
If you live inside Pi and use Omarchy, the extension publishes each
provider's record in-process as soon as that family finishes, then prunes stale
records once the complete refresh settles. Choose install.sh when you want the
CLI in a terminal, or background sync while Pi is not running.
Manual Install / Development
git clone https://github.com/cad0p/pi-usage.git
cd pi-usage
./install.sh # --link for development, --uninstall to remove
./install.sh --no-extension # CLI only; keep the Pi extension installed as a package
./install.sh --omarchy-plugin # also link the AI Usage bar plugin (see Omarchy integration)
No sudo. It validates Node, Pi and Gentle AI, reports which Claude source it found,
copies the project to ~/.local/share/pi-quota, symlinks ~/.local/bin/piquota,
installs the Pi TUI extension (and its gentle-pi rail slot) unless --no-extension
is given,
and removes the previous generation's shuvquota shim so upstream /usr/bin/shuvquota
is reachable again.
Commands
piquota # boxed panel: ring, bar, % left, "reset in 3h 12m"
piquota claude codex # only these families
piquota github-actions # GitHub Actions minutes (explicit opt-in; aliases: gh, actions)
piquota --json # normalized report (for rp-host, scripts, and automations)
piquota --compact # one line per provider
piquota --status # single line with rings, for status bars
piquota --explain # which stores and fields are read (names only)
piquota --clear-cache # delete the cache and exit
piquota --no-color # plain output
piquota --ttl 300 # cache TTL in seconds (default 60)
piquota --timeout 5000 # per-request timeout in ms (default 15000)
piquota --no-cache # ignore the cache entirely
piquota --force # ignore a still-fresh cache entry
piquota --no-refresh # never refresh Antigravity's token in memory
piquota --version # the version this binary reports
piquota auth status # every credential source, including opencode.ai
piquota auth opencode # open the login in Firefox and capture the session
piquota auth opencode --paste # read the cookie from stdin instead
piquota auth opencode --no-browser
piquota auth opencode --wait 300
piquota omarchy # write pi-*.json records for Omarchy's Agents panel
piquota stats # durable token history: last frozen day, families, totals
piquota stats --json --month 2026-11
# read-only history feed: months, one month's days
# per family/slot/model, all-time totals, notes
piquota stats --rebuild-totals # recompute totals.json from the frozen month files
Colours: green above 50% remaining, yellow 20–50%, red below 20%.
Which window the ring shows: always the shortest one (5h/session first,
then daily, weekly, monthly), because that is the limit you hit first. A weekly
window at 31% left is more consumed, but a 5h window at 97% is what answers "can
I keep working right now". Ties inside the same rank go to the tightest window,
so Antigravity's two 5h groups resolve to whichever is more consumed. The rule
lives in selectPrimaryWindow() (src/model.js), the CLI stamps
primaryWindowId into the report, and the Pi extension reads that field, so the
terminal and the TUI cannot disagree. The full panel still lists every window,
so nothing is hidden.
Inside Pi:
| Command | Effect |
|---|---|
/usage |
open the fullscreen quota overlay: every provider, window, account and token row, folded by section, refreshed in the background; tab opens the month history; esc closes it |
/usage box |
pin the five-row box above the editor |
/usage line |
pin the compact line above the editor, in its own row |
/usage panel |
full panel above the editor, pinned across prompts |
/usage hide |
hide the panel/line and stop background refresh until a surface is on again |
/usage status / nostatus |
enable/disable the footer status |
/usage refresh |
refresh only |
/usage json |
where the cached report lives |
The Pi surface
Nothing is pinned by default: /usage opens a fullscreen overlay that takes
the keyboard, so it claims no row above the editor and leaves the footer alone.
It is the same ctx.ui.custom overlay pattern pi's /context uses: it fills the
terminal, scrolls, folds and closes on Esc (or q), leaving nothing behind.
Turn a persistent surface on with /usage box, /usage line, /usage panel or
/usage status; only then does the extension keep it fresh with a background
refresh (and a gentle refresh after each agent turn). /usage hide gives the row
back. /usage panel still pins the same panel flat above the editor.
The overlay paints on the same keystroke: it renders the report the extension
already holds, or the on-disk report cache (~/.cache/pi-quota/usage.json) when
memory is empty. That cache is keyed by the Claude source the numbers in it came
from, so a report fetched with Pi's own OAuth is stored under Pi's key and a
reader that asked for Claude Code is not served it. It never waits for network work before the first frame. Opening
/usage refreshes only families older than their own clock (60 seconds by
default; Claude's 300-second throttle-safe interval is preserved); r forces
all enabled families. Families run independently and repaint as they finish. A
failed family is a line in the view — one line per family, kept until that family
succeeds again, so an out-of-order success cannot hide another one's failure —
never a thrown error. The local session
scan runs in a Node worker thread, not on the TUI event loop, and a shutdown or
/reload stops it mid-scan: it releases its history lock and writes nothing.
Every provider is a section:
the header is the brand-coloured name line, the body lists every window with its
used-fraction bar, reset countdown and the token rows for today, the last seven
days, the prompts and the heaviest models. All sections start expanded; the fold
state and the page you left live for the pi session, so reopening /usage
resumes them, and no file is written.
| Key | Action |
|---|---|
↑/↓, j/k |
move the header cursor; a section taller than the viewport scrolls line by line first |
enter / space |
fold or unfold the section under the cursor |
→/l, ←/h |
expand, collapse |
PgUp/PgDn, g/Home, G/End |
page, first section, last section |
| mouse wheel | scroll |
r |
refresh in place (on History: the month and the live report) |
tab, 1 / 2 |
switch between Now and History |
esc / q |
close |
Every key works in a terminal that sends the legacy escape sequences and in one that negotiates the kitty keyboard protocol (Ghostty among them): the decoder reads both encodings, plus kitty press, repeat and release events, so a press moves once and its release never moves again. A repeat is a press — a held arrow keeps moving, exactly as it does in tmux, where the OS repeat is the same bytes as the first press.
The legend at the bottom lists every key the current view answers, and wraps
into a second or third row on a narrow pane instead of dropping pairs — as few
rows as fit, with the pairs balanced across them. The frame
fits from about 60 columns up: a line wider than the terminal wraps at its
· separators with a hanging indent instead of being cut, so a row never
loses the datum it ends with, and a resize rebuilds it. The wheel is handled by the component itself, like
pi-context-view: pi routes fullscreen wheel events to the focused component but
has no scrolling API for a custom screen, and this extension has no runtime
dependencies to wrap the body in a ScrollView.
tab (or 2) opens History, the second page: the durable token history for
one month, with the same foldable sections. A provider section lists the month's
days, the prompt count for that month and for all time, the token split by
model, and — for a multi-account provider — one row per account with its month
and all-time totals (every account the family has in the month or all-time, so
one busy key does not hide the others). ←/→ (or
[/]) walk the browsable months — those with a file, plus the live month,
which is where History opens even before its first freeze — and tab (or 1)
comes back to Now. The all-time
figure is totals.json (frozen through its watermark) plus, when it is merged,
the same open day the month counts, so today is never missing from it. A rollup
that cannot cover the month on screen — a freeze whose totals write failed, a
month file restored behind totals.json — is still printed, dimmed, with
~ all-time lags this month; run piquota stats --rebuild-totals above the rows:
the figure is the only answer to "how much has this burned ever", so it is
explained rather than dropped. A family's open day
(today) is merged from the live report only when the report was generated today
and the frozen month has no row for that family's day — per family, so one family
frozen early never hides another's live row — and a day is counted exactly once.
A report generated before midnight, or the older report cache painted on the first
frame, is skipped: its todayTotalTokens belong to another day, and merging them
under today would count a frozen yesterday twice. The tail then names the report's
day and says today is not merged. A missing or quarantined month is a note instead
of rows, a failed history read is redacted and shown with a retry hint, and a slow
read never moves the page off the month the last keypress asked for.
The extension reads the same historyJson payload in-process; the CLI exposes
it as piquota stats --json [--month YYYY-MM]: the month list, one month's days
per family/slot/model, the all-time rollup and notes. The read path never takes
the writer lock and never writes, so it is safe while a scan is freezing a day. Everything it reports is frozen: lastFrozenDay is its
watermark, and neither piquota stats nor its JSON moves an open day into a
total. The History page merges the live day itself, for the month and for the
all-time figures alike, and prints the same "comes from the live report" note
under both.
A provider with several accounts is shown as one header (OP-Go · 7 of 14 accounts ok) plus one line per account with every window and its reset, e.g.
#2 ● session 2% (10m) · week 10% (6d) · month 90% (19d), or the reason it failed
(key rejected, no Go plan). The base entry in auth.json (opencode-go) has
no number, so its label column is blank; only real sibling slots (opencode-go-2
…) are numbered #2 … #14. The leading glyph is the fullest window's band. The
overlay and the pinned panel are rendered as components, so Pi's ten-line cap on
plain widgets never truncates them.
/usage box pins the five-row box above the editor:
╭─ Quota ──────────────────╮
│ Claude: ○ 0% R:3h 12m │
│ Codex: ○ 8% R:2h 40m │
│ Agy: ○ 4% R:41m │
│ Agy C/G: ? n/a R:unknown │
│ OP-Go: ○ 4% R:1h 05m │
╰───────────────────────────╯
When gentle-pi's fullscreen sidebar is active, the same card is painted in its right rail instead, under Status and TODO, and the above-editor box stays quiet so the numbers are not shown twice. See the rail slot.
- each name is painted with its own brand colour — Claude
#D97757, Codex#10A37F, Antigravity#4285F4, OpenCode#007AFF; - the numbers are used, not remaining, and the semaphore is both colour and
shape (
○plenty left →◔→◕→●nearly spent), so it still reads on a colourblind or mono terminal; - only active providers are shown: if you only have Claude and Codex configured in Pi, only Claude and Codex appear in the line and on Moshi (no empty warning icons for providers you don't use);
NO_COLOR/TERM=dumbdrops the colours and keeps the glyphs;- the window shown is the shortest one (5h first), and
/usageexpands every window with a used-fraction bar and the reset countdown.
Pi's theme only exposes a fixed palette, so brand colours are emitted as
truecolor ANSI. Widget string arrays are wrapped in Text components by Pi,
which are ANSI-aware, so the codes are measured correctly and theme.fg() keeps
working in the same strings.
Why the footer is off by default
gentle-pi replaces the footer with its own shell bar (setFooter) and that bar
drops segments from the end when the line overflows:
while (segments.length > 1 && visibleWidth(left) > width) segments.pop();
Extension statuses render last, so a footer status is the first thing discarded —
which is why a long status line appears and then vanishes once the bar fills with
cost, branch and usage data. The quota line therefore lives in its own row, where
nothing competes for it. /usage status still enables the footer variant for
anyone who wants it.
The gentle-pi rail slot
gentle-pi paints its right rail from a hardcoded allowlist, so a part that another extension registers is silently never drawn:
const sections = ["footer", "agents", "todo"].map((key) => { /* ... */ });
piQuota registers its card under quota, which that list does not include. There is
no injection point, so the card cannot appear in the rail on its own. The fix is one
anchored edit that appends the part:
const sections = ["footer", "agents", "todo", "quota"].map((key) => { /* ... */ });
piquota gentle-pi owns that edit:
piquota gentle-pi status— report whether the rail renders the quota part;piquota gentle-pi apply— append the part (idempotent);piquota gentle-pi revert— remove the part and restore the original bytes.
Because a gentle-pi update replaces the package, the patch would otherwise vanish.
The extension therefore validates the slot on every session start and re-applies
it when it is gone, reporting the repair instead of doing it silently. install.sh
applies it at install time and reverts it on --uninstall.
Safety properties, all covered by tests/rail-patch.test.mjs:
- exactly one array literal is rewritten; every other byte of the file is untouched;
- an unrecognized shape is refused, never guessed at, because a wrong rewrite of another extension's layout would break the whole rail;
- the write is atomic, the pre-patch revision is kept as a one-time
.pi-quota-backupnext to the file, and the result is re-read after writing with a restore from that backup when it does not read back as patched; PI_QUOTA_GENTLE_PI_DIRpoints the lookup elsewhere, which is what keeps the test suite away from a real installation.
If gentle-pi is absent, or the slot cannot be applied, nothing breaks: the card stays in the box above the editor. The rail only exists in fullscreen at 140 columns or wider, so the suppression of that box is conditional on gentle-pi reporting its rail as active — in regular mode or a narrow terminal the box is the only surface.
JSON feed & rp-host integration (piquota --json)
piquota --json provides a structured, machine-readable feed of all configured
provider quotas. It is designed for consumption by local status bars, dynamic
islands, background pollers, and client applications such as rp-host (from the
Rp suite) without external network daemons or closed-source intermediaries:
- Predictable exit status: exits
0if at least one configured provider answered successfully, or1if all providers failed or are unconfigured. primaryWindowId: the CLI pre-calculates the most urgent quota window (e.g., short 5h session window over weekly or monthly allowances) so consumer pills or widgets do not have to implement provider-specific priority hierarchies.- Percentages and Countdown:
usedPercentandremainingPercentreport percentages (0..100);resetsInSecprovides the exact integer seconds until reset, immune to local clock skew. expiresInMinandtokenRenewedInMemory: the freshness of the credential the run actually used. Antigravity's token is renewed in memory, so its result carriestokenRenewedInMemory: trueandexpiresInMin: nullinstead of Pi's stale stored countdown; a renewal that failed keeps the stored value, which is what the panel'stoken EXPIREDbadge and the warning line describe. The field is only present when a renewal happened.notConfiguredvsok: unconfigured providers reportnotConfigured: true, allowing consumers to cleanly omit them without displaying false-positive error indicators.localStats: the tokens Pi's own session files recorded, per family, shaped exactly like the fields Omarchy's Agents panel reads (today, the last seven local days, all-time per-model buckets) plus abyAccountbreakdown for numbered siblings.localStatsMetasays how the scan went.
{
"engine": "pi-quota",
"schemaVersion": 1,
"readOnly": true,
"generatedAt": "2026-10-15T05:35:21.210Z",
"sources": ["/home/user/.pi/agent/auth.json"],
"warnings": [],
"providers": [
{
"family": "codex",
"label": "Codex (Pi)",
"account": "user@example.com",
"plan": "plus",
"source": "/home/user/.pi/agent/auth.json",
"sourceKind": "pi",
"expiresInMin": 12650,
"ok": true,
"notConfigured": false,
"error": null,
"updatedAt": "2026-10-15T05:35:21.210Z",
"primaryWindowId": "5h",
"windows": [
{
"id": "5h",
"label": "5h window",
"usedPercent": 14.5,
"remainingPercent": 85.5,
"resetsAt": "2026-10-15T10:35:24.000Z",
"resetsInSec": 18003,
"windowSeconds": 18000,
"note": null
}
]
}
]
}
The token part of the same report is abridged here (recentDays always carries
seven entries, oldest first, today last):
{
"localStats": {
"codex": {
"hasLocalStats": true,
"hasPromptStats": true,
"todayTotalTokens": 14172,
"todayPrompts": 3,
"todaySessions": 1,
"todayTokensByModel": { "gpt-5.1-codex": 14172 },
"weekTokensByModel": { "gpt-5.1-codex": 118422 },
"recentDays": [{ "date": "2026-10-27", "messageCount": 0 }],
"totalPrompts": 1200,
"totalSessions": 42,
"activeDays": 11,
"activeDates": ["2026-10-27"],
"modelUsage": { "gpt-5.1-codex": { "inputTokens": 4, "outputTokens": 425, "cacheReadInputTokens": 6838, "cacheCreationInputTokens": 6905 } },
"byAccount": { "": { "todayTokens": 14172, "weekTokens": 118422, "todayPrompts": 3, "totalPrompts": 1200, "totalTokens": 48812210 } }
}
},
"localStatsMeta": { "files": 604, "scanned": 1, "reused": 603, "bytesRead": 3042, "durationMs": 9, "partial": false, "budgetMs": 12000, "frozenDays": 0, "locked": false }
}
Each provider refreshes on its own clock
Anthropic's usage endpoint answers 429 when polled every minute; Codex,
Antigravity and OpenCode Go tolerate 60 s intervals. To prevent cards from looking
stale without triggering rate limits:
| Family | Clock |
|---|---|
| Claude | 300 s (--claude-ttl) |
| Codex, Antigravity, OpenCode Go | 60 s (--fetch-ttl) |
src/refresh.js keeps a timestamp per family, requests only what is past its own
clock, and merges the result back into the canonical report. src/sticky.js
keeps the last healthy snapshot during transient upstream 429 throttles so the
user never loses visibility. Permanent failures (expired sign-in, missing
credential) are never masked.
Omarchy integration
Omarchy 4.0.4's Agents bar panel draws every *.json record in
${XDG_STATE_HOME:-~/.local/state}/omarchy/agents/usage/, whoever wrote it.
piquota omarchy (opt-in, one shot) writes one record per provider piQuota can
read, so Claude, Codex, Antigravity and OpenCode Go all get a tab:
piquota omarchy # write pi-claude.json, pi-codex.json, ... once
./install.sh --omarchy-timer # also run it every 5 minutes (systemd user timer)
- Records are named
pi-<family>.json, so they never overwrite Omarchy's ownclaude.json/codex.json. They hold plan names, used percentages, window labels, reset times, and — when the token scan has run — aggregate token counts per local day and per model (hasLocalStats,recentDays,modelUsage). No prompt text, session path, account or e-mail address is copied. - A provider with a failing sign-in shows a status and a help line instead of
limits. A provider that is not configured gets no record, and a stale
pi-*.jsonis removed on the next run. - A family with several accounts (numbered
<base>-<slot>entries inauth.json) still gets one record: every healthy account contributes only the window that constrains it, titled#<slot> · Session|Weekly|Monthly, and failed accounts add no rows but are summarised by reason, e.g.#8–#10 key rejected · #11–#14 no Go plan. The unnumbered base entry keeps a blankidand its limits are untitled; only real sibling slots carry#<slot>. The same record also carriesaccounts[{id, ok, status, limits}]with every window of every account (statusis a short reason such asKey rejectedfor a failed one), which Omarchy's built-in panel ignores and this package's own plugin draws. - Omarchy's native collector already tracks Claude with richer local statistics, so
piquota omarchyautomatically skipsclaudeand cleans up duplicatepi-claude.jsonfiles whenclaude.jsonexists in the usage directory (override withPI_QUOTA_OMARCHY_SKIP). PI_QUOTA_OMARCHY_DIRoverrides the output directory../install.sh --uninstallremoves the timer and thepi-*.jsonrecords.
The AI Usage bar plugin
The package also ships its own Omarchy bar widget, cad0p.pi-usage ("AI
Usage", in omarchy/cad0p.pi-usage/). It is derived
from Omarchy's built-in Agents plugin (MIT, by Omarchy) and reuses the shell's
own panel components, so it looks the same, but it has no tabs: every provider
is a collapsible section of one scrolling panel.
- Header row: the provider's mark, name and plan, the headline percentage
with its window and reset (
Weekly · 1d), a chip when something is wrong (Unavailable,7 down), and the headline meter while folded. The headline is the fullest window; for a multi-account provider it is the healthy account with the most room left./usagedoes not collapse a family like that: its panel lists one line per account, so this headline is the bar's own summary. - Expanded: the status card, LIMITS with the built-in meters and countdowns,
then for a multi-account record one row per account with every window
(
#2 session 2% 10m · week 10% 6d · month 90% 19d, failed accounts dimmed with their reason), then TOKENS BY DAY and TOKENS BY MODEL whenever the record carries local statistics, drawn exactly as the built-in draws them. - Which section is open: on first use, only the provider closest to a limit
(ties go to the earliest reset). Every expand or collapse you make sticks,
across popup opens and shell restarts, in
~/.local/state/omarchy/settings/cad0p.pi-usage.json. - Keys:
j/kor the arrows walk the section headers (a tall open section scrolls first), Enter or Space toggles,l/hexpand/collapse,rrefreshes, Tab moves to the neighbouring bar panel, Esc closes. The mouse wheel scrolls; a click on a header toggles it. - Records: every
*.jsonin the usage directory, Omarchy's own included. A provider is hidden byproviders.<id>.enabled = falsein the widget's settings, the same switch the built-in uses. The plugin does not support the built-in Agents widget's synced cross-device aggregation (syncMode,syncDir,syncFileName,syncDeviceId). Users who rely on synced aggregation should keep the built-in widget; this plugin is not a full replacement for it. - Refresh: the systemd timer and the Pi extension already publish the
pi-*.jsonrecords, so the plugin never schedulespiquota.r, a middle click oromarchy-shell cad0p.pi-usage refreshstartspiquota-omarchy.service(or runs the package'spiquota omarchywhen there is no unit). Omarchy's collectors (omarchy-agent-usage-update) run on therefreshIntervalSectimer and for a record'sretryAdvisedretry, and only while the built-in Agents widget is off the bar (omarchyCollectors:Auto,OnorOff, whereOffnever runs them), so keeping both never doubles the work.
Install it by linking it into Omarchy's plugin directory; the link points into
the pi package when there is one, so pi update updates the plugin too:
./install.sh --omarchy-plugin # symlink ~/.config/omarchy/plugins/cad0p.pi-usage (idempotent)
omarchy plugin enable cad0p.pi-usage # put it on the bar (right section)
omarchy bar put cad0p.pi-usage --after omarchy.agents # or: right next to the built-in Agents icon
omarchy plugin disable omarchy.agents # optional: drop the built-in widget
Without the installer, the link is
ln -s ~/.pi/agent/npm/node_modules/@cad0p/pi-usage/omarchy/cad0p.pi-usage ~/.config/omarchy/plugins/cad0p.pi-usage
followed by omarchy-shell shell rescanPlugins. To hide providers, pass the whole
object, as with the built-in:
omarchy bar set cad0p.pi-usage providers '{"fireworks":{"enabled":false}}' --json.
Remove it with omarchy plugin disable cad0p.pi-usage and
rm ~/.config/omarchy/plugins/cad0p.pi-usage (./install.sh --uninstall removes
the link too); omarchy plugin enable omarchy.agents brings the built-in back.
IPC: omarchy-shell cad0p.pi-usage <open|close|toggle|refresh>, plus
expand <id> / collapse <id> (e.g. expand pi-opencode-go).
tests/omarchy-harness/render.sh [out-dir] [--light] renders the panel offscreen
to PNGs against fixture records, with Omarchy's real components and the current
theme, next to the built-in panel for comparison. It never touches the running
shell.
Token stats from Pi's own sessions
Pi writes every assistant message's usage block into
~/.pi/agent/sessions/**.jsonl (plus ~/.omp/agent/sessions when the omp fork
is installed; PI_CODING_AGENT_DIR relocates the Pi agent directory). piQuota
folds those messages into tokens per local day and per model, per provider, and
shows them next to the quota:
| Where | What |
|---|---|
piquota --json |
localStats.<family> — the Omarchy-shaped counters plus byAccount |
/usage panel |
a tokens row per provider: today, 7 days, prompts, then the top 3 models of the week |
piquota omarchy |
the optional fields Omarchy's Agents panel draws as "Tokens by day" and "Tokens by model" |
piquota stats |
the durable history: last frozen day, families, prompts, session-days |
/usage → tab |
History: per provider, the month's days and models, account totals for a multi-account family, and the all-time rollup |
piquota stats --json |
the same history as a read-only JSON feed (--month YYYY-MM selects a month) |
What counts: one assistant message with a non-zero token count is one prompt,
and its tokens are input + output + cacheRead + cacheWrite — the same sum the
Omarchy panel renders. Messages are bucketed by local date (the panel calls
the last row "Today"). Only providers piQuota can bill are counted:
anthropic, openai-codex, antigravity, opencode-go and their numbered
siblings, so opencode-go-7 is account #7. Providers it does not manage
(deepinfra, xai, ...) are ignored, and so is the custom claude-bridge
provider: its tokens are not a Pi subscription.
Two stores, two lifetimes. Open days (normally just today) live in the disposable scan cache. Once a local day is over it is frozen into a durable history that no rescan, no cache wipe and no session deletion can rewrite. The history is Pi data, so it lives next to Pi's agent directory:
| Path | What |
|---|---|
${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-usage/totals.json |
all-time rollup per family and account (models, prompts, session-days, active dates) plus lastFrozenDay |
${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-usage/tokens-YYYY-MM.json |
the frozen days of one local month, keyed day → family → slot → model |
~/.cache/pi-quota/local-sessions.json |
per-session-file size, mtime, byte offset and copy fingerprint, open days, and pending late deltas for unreadable months |
~/.piis often a git repository. Addagent/pi-usage/to that repo's.gitignoreif you do not want your token counts committed.
Freezing: every run moves each day before today that is not frozen yet into its
month file and into totals.json, then drops it from the per-file cache. The
month file is written first and the totals second; the lastFrozenDay watermark
makes a restart re-freeze a half-written day idempotently (the month write is a
SET per day) instead of adding it twice. A late write to an already-frozen day —
a session that appends just past midnight — increments both stores directly; if
its month is temporarily unreadable, the delta stays in the cache until a later
run can write it. Such a held delta lives only in the disposable cache: if the
cache is lost before the month becomes writable, that delta is dropped, because
the bytes of a frozen day are never read again. A crash between the month and totals writes can double that
one small delta. A budget-truncated scan never freezes: history is only written
from complete runs.
Consequences, all covered by tests: history survives session deletion and cache
loss. A lost or corrupt totals.json is moved aside as
totals.json.corrupt-<ts> and rebuilt from the month files, which are the
authoritative record of frozen days — not from the session files, because a
session deleted since then would otherwise shrink history. Frozen days are never
recomputed, so a replaced session file cannot rewrite them either.
Damaged month files are handled without rebuilding frozen days from sessions. A
month that cannot be read (permissions, I/O) is left alone: the run warns, keeps
its open days in the cache, and does not freeze them until the month is readable.
Other history proceeds normally, and this alone does not force a full session
rescan. A rollup rebuild never writes totals.json from an incomplete month set.
A corrupt or foreign-version month is moved aside by the lock owner (never by a
read-only run) to .corrupt-<ts>. Its warning names the file and directs you to copy the
.corrupt-<ts> backup back to the original month path if recoverable, or to delete
the backup to abandon those counts. Until one of the two happens, freezing stops at
that month for every later day too (the frozen watermark only moves forward), so
later days stay open in the cache and each run repeats the warning; once the backup
is restored or deleted, the held days freeze on the next run. Frozen days are never rebuilt from sessions: their
daily rows are missing (shown as 0) in recentDays, while totals.json retains
the all-time counts, so the two views may disagree. piquota stats --rebuild-totals
refuses while a quarantined month has not been restored. A lost month file
loses that month's daily detail; normal runs keep the all-time totals, and a
rebuild can recover counts from every remaining month file even if sessions are
gone. Rebuild refuses when there is no month file at all while totals.json
still has data, because replacing that rollup with an empty one would turn a
missing directory into permanently lost history. A normal run reads only
totals.json plus the month files covering the last seven days (current month,
and the previous one early in a month), so years of history never slow a run: on
this machine 45 frozen days cost ~28 KB in total — totals.json 6.9 KB and two
month files of 5.4 KB and 16 KB. A day inside that window is read from its month
file once frozen, so late writes appear in "tokens by day" without waiting for
the week to roll over.
Cost: the first run streams each session file once — 1.56 GB across 608 files in 3.0 s here; later runs read only the bytes appended since the previous scan (59 ms of scanning for the whole store, ~20 KB read), and every byte is parsed once in normal operation. The CLI's own wall time is dominated by the provider calls either way.
What forces a file to be read from its first byte (a rescan, in which frozen
days are skipped and only open days are rebuilt): the cache missing or corrupt,
the file replaced or now shorter, a path the cache has never seen (a copy, a
move, a restored backup — its old days were already frozen under its previous
path), or the same size with a new mtime (an in-place rewrite of the same
length). An append only reads the new bytes. The one rewrite this cannot see is
one that restores both the size and the mtime, which no stat-based check can
detect. The cache is atomic and 0600.
Two writers are routine here (the systemd timer and /usage), so a run takes an
exclusive lock — local-sessions.json.lock, with a 2-minute stale timeout —
before it reads the history. The file holds an owner token, and a live run
refreshes its mtime as it walks the store, so a slow scan is never mistaken for a
dead one. A run that cannot take the lock reports the numbers already on disk and
writes nothing; a run that is taken over mid-scan notices on its next refresh and
also writes nothing (it never deletes its successor's lock).
Duplicate sessions are handled two ways. A byte-identical copy of a session file (a copy, a restored backup, a sync tool) is recognised by a (size, first 32 KB, last 32 KB) fingerprint and contributes nothing; anything appended to it later is counted normally, and if the original is deleted the copy is adopted and read on the next run. A fingerprint can collide — two same-size sessions that differ only in the middle — so a candidate is verified by streaming both files before it is called a copy; that full comparison runs only on the rare duplicate. A fork that copies a prefix and then diverges has its own size and tail, so it is counted as a session of its own, duplicated prefix included: on this machine (611 files, 186,443 assistant messages) only 11 message ids appeared in more than one file (0.006%), none of them on today, so message-id de-duplication would cost megabytes to correct a handful of tokens and is not implemented.
The scan has a wall-clock budget (12 s, or PI_QUOTA_LOCAL_STATS_BUDGET_MS) so
the first run cannot push /usage past its 30 s exec timeout; when it runs out
the report carries localStatsMeta.partial, the panel marks the numbers
scan partial, and nothing is frozen that run. PI_QUOTA_LOCAL_STATS=0 turns
the scan off.
Session counts are session-days, not distinct session files: a day is frozen
as aggregate rows, without the list of files that fed it, so a session that runs
past midnight counts once on each day it appears in. todaySessions is exact
(those files are still open); totalSessions is the sum of the per-day counts.
Layout
src/auth/pi-auth.js read-only auth.json reader, WSL + Linux, deduplicated
src/auth/claude-code-auth.js read-only Claude Code store; never carries its refresh token
src/auth/jwt.js Codex JWT payload reader (claim keys contain dots)
src/browser/cookies.js read-only Firefox/Chromium cookie access via a temp copy
src/browser/history.js workspace ids recovered from a copied places.sqlite
src/opencode/session.js cookie + workspace resolution, dashboard fetch
src/opencode/dashboard.js three-strategy parser for the Go plan page
src/providers/*.js one file per provider; each degrades instead of throwing
src/providers/github-actions.js plan-derived org Actions minutes via authenticated gh CLI
src/providers/antigravity-oauth.js in-memory refresh with Google's public client
src/providers/backoff.js per-family throttle state
src/sticky.js last published / last good snapshots and throttle recovery
src/engine.js collectQuota() -> one normalized report
src/local/pi-sessions.js token counts from Pi's session files, incremental scan
src/local/history.js frozen local-day history: month files + all-time rollup
src/model.js window normalization, percent and reset parsing
src/render/{theme,panel}.js colors, thresholds, rings, bars, boxed panel
src/cache.js report cache at ~/.cache/pi-quota/usage.json
src/overlay-keys.js raw key decoding for /usage: legacy and kitty keyboard encodings
src/omarchy/record.js pure mapping from the report to Omarchy usage records
src/omarchy/publish.js atomic writer for pi-*.json, and stale-record cleanup
src/refresh.js per-family refresh clocks, and the merge back into one report
src/cli/args.js argument parsing, and the flag/positional split
src/cli/commands/stats.js `piquota stats` and `--rebuild-totals`
src/exec.js the one place that spawns gh CLI binary
src/http.js fetch wrapper: timeouts, JSON, redaction
contrib/systemd/ user service and timer behind `install.sh --omarchy-timer`
omarchy/cad0p.pi-usage/ the AI Usage Omarchy bar plugin (QML; UsageLogic.js is its pure logic)
tests/omarchy-harness/ offscreen renderer for the plugin, fixtures included
bin/piquota.js the only CLI
extensions/quota-panel.ts Pi TUI line and /usage
Troubleshooting
| Symptom | Cause and fix |
|---|---|
no <family> credential in the Pi store |
Not logged in to Pi for that provider. /login <provider> in Pi. |
no claude credential in the Pi store or from the Claude Code CLI |
Neither Claude source is configured. /login anthropic in Pi, or install Claude Code and run claude once. |
Claude Code CLI has no readable credentials; looked for … |
The store is missing, empty or corrupt. piquota --explain prints every path that was tried. |
Claude Code token expired or rejected; run \claude` once` |
Claude Code's own access token lapsed. Run any claude command; piQuota never refreshes it. |
Claude reports plan pro but the window looks wrong |
The store is chosen by preference, not by success. PI_QUOTA_CLAUDE_SOURCE=pi piquota forces the other source; the cache is keyed by that setting, so the switch refetches. |
rate limited (HTTP 429); retry in Ns |
The vendor throttled the usage endpoint. Wait, or raise --ttl. |
Antigravity token rejected |
Refresh failed; /login antigravity in Pi. --no-refresh disables the attempt. |
no "auth" cookie for opencode.ai in N readable store(s) |
Run piquota auth opencode, or paste the cookie. |
only encrypted Chromium stores found |
Log in with Firefox; Chrome on Windows uses DPAPI. |
unknown flag: --x |
The flag is a typo or was removed. piquota --help lists every flag. |
unknown argument |
piquota --help lists every flag. |
Tests
node --test tests/*.test.mjs # 455 tests, fake tokens only, no network
Modules covered: auth.json parsing and de-duplication, the Claude Code store
(including that the refresh token never leaves it and that reading leaves the file
byte-identical), Claude source precedence, the five providers (including GitHub
Actions plan resolution, opt-in gating and degradation, the two Antigravity
failures and the OpenCode degradation), the dashboard parser's three
strategies, the Firefox cookie reader against a synthetic SQLite database, the
Antigravity refresh (in-memory only), sticky and last-good snapshots resilience,
the Omarchy record mapping and atomic writer, the Omarchy plugin's logic,
manifest and marks, the local token scan (family and slot mapping, incremental
append, shrink, deletion, local-day bucketing, cache corruption), the durable
history (month partitioning, freezing at midnight, late appends to frozen days,
cache loss, a crash between the month and totals writes, corrupt-history
quarantine, rebuild-totals), the renderers, the Pi extension contract, the
per-family refresh clocks, argument parsing, and the structure of these
documents themselves (tables, fences, links, anchors).
Acknowledgments & Prior Art
shuvquota by @shuv1337: Huge credit to
shuvquotafor pioneering multi-provider terminal quota monitoring across AI services. Its approach to rate-limit endpoints, dashboard parsing patterns for OpenCode Go, and terminal quota concepts served as inspiration for this project.piQuotawas built as a standalone, strictly read-only implementation designed specifically around Pi Coding Agent credentials (~/.pi/agent/auth.json), Gentle AI orchestration, and zero-dependency JSON and desktop integrations.