omp packages

@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.

GitHub Actions Published 4 hours ago MIT ~726 downloads/mo

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.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

pi-usage

One source of truth for quota: the credentials Pi already owns.

[!NOTE] @cad0p/pi-usage is a fork of J3pf/piQuota (@j3pf/piquota). It adds:

  • OpenCode Go from the API key: GET https://opencode.ai/zen/go/v1/usage returns the rolling (5h), weekly and monthly windows for the key Pi already stores, so no browser session is needed.
  • Numbered accounts: auth.json entries such as opencode-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_CONCURRENCY from 1 to 32), so 14 OpenCode Go keys cost about one round trip instead of fourteen. Every request carries User-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 is node.
  • 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.
  • /usage on demand: the Pi command is /usage only, 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: auto passes over an expired Claude Code token when Pi's own anthropic login 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 as piquota --json for rp-host / Rp-app and piquota omarchy for Omarchy.

Hard guarantees

  • pi-usage opens ~/.pi/agent/auth.json with flag: "r" and never writes it. CLI and systemd-timer runs are strictly read-only. In the Pi extension only, getProviderAuth may 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 sessionKey is 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 installed claude owns. ~/.claude.json is 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.

  • /usage never injects quota into the LLM context: the extension only calls ui.setStatus, ui.setWidget and ui.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.json the report cache, with each family's own fetch time
    ~/.cache/pi-quota/local-sessions.json per-session-file offsets and open token days
    ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-usage/totals.json, tokens-YYYY-MM.json frozen token history, plus .corrupt-<ts> backups for corrupt files
    ~/.cache/pi-quota/refresh-state.json each family's own refresh clock
    ~/.cache/pi-quota/last-published.json, last-good.json, backoff.json sticky snapshots and throttle state
    ~/.config/pi-quota/opencode-cookie only after piquota auth opencode, mode 600
    ~/.pi/agent/extensions/*.ts only 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:

  1. Antigravity needs the CLI User-Agent. Without User-Agent: antigravity/cli/... the backend answers 403 You do not have a valid license of this product for a healthy token.
  2. Pi's opencode-go entry is an OpenCode Zen API key, and that key reads its own Go usage. GET /zen/go/v1/usage answers with the rolling (5h), weekly and monthly windows for the key's workspace; 401 means the key was revoked and 403 Workspace access denied means the key is valid but its workspace has no Go plan. Only a 404 (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.
  3. primaryWindowId identifies the most urgent window. The CLI computes the shortest, most pressing window (e.g. 5h session before weekly) so downstream UI consumers like rp-host and the Pi TUI render priority pills without reimplementing window precedence.
  4. Both Claude OAuth stores answer the same endpoint. Pi's own anthropic entry 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

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=dumb drops the colours and keeps the glyphs;
  • the window shown is the shortest one (5h first), and /usage expands 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-backup next 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_DIR points 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 0 if at least one configured provider answered successfully, or 1 if 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: usedPercent and remainingPercent report percentages (0..100); resetsInSec provides the exact integer seconds until reset, immune to local clock skew.
  • expiresInMin and tokenRenewedInMemory: the freshness of the credential the run actually used. Antigravity's token is renewed in memory, so its result carries tokenRenewedInMemory: true and expiresInMin: null instead of Pi's stale stored countdown; a renewal that failed keeps the stored value, which is what the panel's token EXPIRED badge and the warning line describe. The field is only present when a renewal happened.
  • notConfigured vs ok: unconfigured providers report notConfigured: 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 a byAccount breakdown for numbered siblings. localStatsMeta says 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 own claude.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-*.json is removed on the next run.
  • A family with several accounts (numbered <base>-<slot> entries in auth.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 blank id and its limits are untitled; only real sibling slots carry #<slot>. The same record also carries accounts[{id, ok, status, limits}] with every window of every account (status is a short reason such as Key rejected for 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 omarchy automatically skips claude and cleans up duplicate pi-claude.json files when claude.json exists in the usage directory (override with PI_QUOTA_OMARCHY_SKIP).
  • PI_QUOTA_OMARCHY_DIR overrides the output directory. ./install.sh --uninstall removes the timer and the pi-*.json records.

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. /usage does 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/k or the arrows walk the section headers (a tall open section scrolls first), Enter or Space toggles, l/h expand/collapse, r refreshes, Tab moves to the neighbouring bar panel, Esc closes. The mouse wheel scrolls; a click on a header toggles it.
  • Records: every *.json in the usage directory, Omarchy's own included. A provider is hidden by providers.<id>.enabled = false in 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-*.json records, so the plugin never schedules piquota. r, a middle click or omarchy-shell cad0p.pi-usage refresh starts piquota-omarchy.service (or runs the package's piquota omarchy when there is no unit). Omarchy's collectors (omarchy-agent-usage-update) run on the refreshIntervalSec timer and for a record's retryAdvised retry, and only while the built-in Agents widget is off the bar (omarchyCollectors: Auto, On or Off, where Off never 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

~/.pi is often a git repository. Add agent/pi-usage/ to that repo's .gitignore if 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 shuvquota for 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.

    piQuota was 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.