@cad0p/pi-usage 0.11.3 โ†’ 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.11.4] - 2026-11-03
6
+
7
+ <!-- USER-EDITABLE SECTION START -->
8
+ Token history, a scrolling bar panel, and Antigravity from Pi's own login.
9
+
10
+ - **Token stats from Pi's sessions:** tokens by day and by model per provider and account, read from `~/.pi/agent/sessions` with no extra credential. Incremental (a warm run reads only appended bytes) and durable: finished days are frozen into `${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-usage/` (`totals.json` + `tokens-YYYY-MM.json`) and survive session deletion and cache loss. Shown in `/usage` and in the Omarchy records; `piquota stats` inspects the history.
11
+ - **AI Usage bar plugin (`cad0p.pi-usage`):** looks like Omarchy's Agents panel, but every provider is a collapsible section of one scrolling panel, multi-account providers list every window of every account, and tokens by day and by model are drawn as in the built-in. `install.sh --omarchy-plugin` links it from the pi package, so `pi update` updates it.
12
+ - **No more writes into other Omarchy plugins:** `piquota omarchy` stopped copying provider marks into every `~/.config/omarchy/plugins/*/assets`.
13
+ - **`/usage` glance:** bare `/usage` closes on the next prompt or a second `/usage` (`/usage panel` still pins), and each account line shows session, week and month.
14
+ - **Antigravity:** read from Pi's `google-antigravity` login (the legacy `antigravity` key still works).
15
+ - `docs/verification.md`: the checklist every change follows.
16
+ <!-- USER-EDITABLE SECTION END -->
17
+
18
+ ### ๐Ÿš€ Features
19
+
20
+ - *(local)* Token stats by day and by model from Pi's session files (closes #16)
21
+ - *(omarchy)* AI Usage bar plugin, every provider a collapsible section (closes #18)
22
+
23
+ ### ๐Ÿ› Bug Fixes
24
+
25
+ - *(extension)* /usage glance closes on the next prompt; every window per account (closes #9)
26
+ - *(auth)* Read Antigravity from Pi's google-antigravity login (closes #12)
27
+
28
+ ### ๐Ÿ“š Documentation
29
+
30
+ - Add the verification checklist every change follows (closes #14)
31
+
32
+
5
33
  ## [0.11.3] - 2026-11-02
6
34
 
7
35
  <!-- USER-EDITABLE SECTION START -->
package/README.md CHANGED
@@ -8,6 +8,7 @@ One source of truth for quota: **the credentials Pi already owns**.
8
8
  > * **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.
9
9
  > * **Numbered accounts**: `auth.json` entries such as `opencode-go-2` โ€ฆ `opencode-go-14` (the [pi-fallback-provider](https://github.com/cad0p/pi-fallback-provider) sibling convention) are read as extra accounts of their base provider.
10
10
  > * **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.
11
+ > * **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.
11
12
  > * **`/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.
12
13
  > * **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.
13
14
  >
@@ -17,7 +18,7 @@ One source of truth for quota: **the credentials Pi already owns**.
17
18
  shuvquota, and it never writes to a credential file.
18
19
 
19
20
  > [!TIP]
20
- > **Working with AI Agents?** See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for architectural boundaries, test runner commands, and hard invariants for AI coding agents.
21
+ > **Working with AI Agents?** See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for architectural boundaries, test runner commands, and hard invariants for AI coding agents, and [docs/verification.md](docs/verification.md) for the verification checklist every change follows.
21
22
 
22
23
  | Surface | Command |
23
24
  | --- | --- |
@@ -49,11 +50,19 @@ shuvquota, and it never writes to a credential file.
49
50
  endpoint is gone and no session cookie is configured.
50
51
  * `/usage` never injects quota into the LLM context: the extension only calls
51
52
  `ui.setStatus`, `ui.setWidget` and `ui.notify`.
53
+ * Pi's session files (`~/.pi/agent/sessions/**/*.jsonl`) are opened read-only, to
54
+ count tokens per day and per model. The scan cache keys each file by its path,
55
+ size and byte offset and stores open days; the durable history under
56
+ `~/.pi/agent/pi-usage/` stores frozen days. Both hold only aggregate counts and
57
+ model names โ€” no prompt text or tool output reaches a cache, a record or the
58
+ report.
52
59
  * Nothing is written outside these paths:
53
60
 
54
61
  | Path | What |
55
62
  | --- | --- |
56
63
  | `~/.cache/pi-quota/usage.json` | the report cache |
64
+ | `~/.cache/pi-quota/local-sessions.json` | per-session-file offsets and open token days |
65
+ | `${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-usage/totals.json`, `tokens-YYYY-MM.json` | frozen token history, plus `.corrupt-<ts>` backups for corrupt files |
57
66
  | `~/.cache/pi-quota/refresh-state.json` | each family's own refresh clock |
58
67
  | `~/.cache/pi-quota/last-published.json`, `last-good.json`, `backoff.json` | sticky snapshots and throttle state |
59
68
  | `~/.config/pi-quota/opencode-cookie` | only after `piquota auth opencode`, mode `600` |
@@ -65,7 +74,7 @@ shuvquota, and it never writes to a credential file.
65
74
  | --- | --- | --- | --- |
66
75
  | Claude | Claude Code CLI store, else `anthropic.access` | `GET api.anthropic.com/api/oauth/usage` | โœ… 5h + weekly + plan |
67
76
  | Codex | `openai-codex.access` + `accountId` | `GET chatgpt.com/backend-api/wham/usage` | โœ… 5h + weekly + plan |
68
- | Antigravity | `antigravity.access` + `projectId` | `POST cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` | โœ… Gemini + Claude/GPT buckets, **with in-memory refresh** |
77
+ | Antigravity | `google-antigravity.access` (or legacy `antigravity`) + `projectId` | `POST cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` | โœ… Gemini + Claude/GPT buckets, **with in-memory refresh** |
69
78
  | 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 |
70
79
  | 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 |
71
80
 
@@ -177,6 +186,7 @@ git clone https://github.com/cad0p/pi-usage.git
177
186
  cd pi-usage
178
187
  ./install.sh # --link for development, --uninstall to remove
179
188
  ./install.sh --no-extension # CLI only; keep the Pi extension installed as a package
189
+ ./install.sh --omarchy-plugin # also link the AI Usage bar plugin (see Omarchy integration)
180
190
  ```
181
191
 
182
192
  No sudo. It validates Node, Pi and Gentle AI, reports which Claude source it found,
@@ -212,6 +222,8 @@ piquota auth opencode --no-browser
212
222
  piquota auth opencode --wait 300
213
223
 
214
224
  piquota omarchy # write pi-*.json records for Omarchy's Agents panel
225
+ piquota stats # durable token history: last frozen day, families, totals
226
+ piquota stats --rebuild-totals # recompute totals.json from the frozen month files
215
227
  ```
216
228
 
217
229
  Colours: green above 50% remaining, yellow 20โ€“50%, red below 20%.
@@ -230,10 +242,10 @@ Inside Pi:
230
242
 
231
243
  | Command | Effect |
232
244
  | --- | --- |
233
- | `/usage` | one on-demand refresh, show the full panel, notify a one-line summary |
245
+ | `/usage` | one on-demand refresh, show the full panel until the next prompt (or a second `/usage`), notify a one-line summary |
234
246
  | `/usage box` | pin the five-row box above the editor |
235
247
  | `/usage line` | pin the compact line above the editor, in its own row |
236
- | `/usage panel` | full boxed panel above the editor |
248
+ | `/usage panel` | full panel above the editor, pinned across prompts |
237
249
  | `/usage hide` | hide the panel/line and stop background refresh until a surface is on again |
238
250
  | `/usage status` / `nostatus` | enable/disable the footer status |
239
251
  | `/usage refresh` | refresh only |
@@ -248,8 +260,9 @@ it fresh with a background refresh (and a gentle refresh after each agent turn).
248
260
  `/usage hide` gives the row back.
249
261
 
250
262
  A provider with several accounts is shown as one header (`OP-Go ยท 7 of 14 accounts
251
- ok`) plus one line per account: its fullest window, or the reason it failed (`key
252
- rejected`, `no Go plan`). The panel is rendered as a component, so Pi's ten-line
263
+ ok`) plus one line per account with every window and its reset, e.g.
264
+ `#2 โ— session 2% (10m) ยท week 10% (6d) ยท month 90% (19d)`, or the reason it failed
265
+ (`key rejected`, `no Go plan`). The leading glyph is the fullest window's band. The panel is rendered as a component, so Pi's ten-line
253
266
  cap on plain widgets never truncates it, and the summary names the account with the
254
267
  most room: `OP-Go: 7/14 ok ยท lowest 24% (#7)`.
255
268
 
@@ -363,6 +376,10 @@ Rp suite) without external network daemons or closed-source intermediaries:
363
376
  * **`notConfigured` vs `ok`**: unconfigured providers report `notConfigured: true`,
364
377
  allowing consumers to cleanly omit them without displaying false-positive
365
378
  error indicators.
379
+ * **`localStats`**: the tokens Pi's own session files recorded, per family,
380
+ shaped exactly like the fields Omarchy's Agents panel reads (today, the last
381
+ seven local days, all-time per-model buckets) plus a `byAccount` breakdown for
382
+ numbered siblings. `localStatsMeta` says how the scan went.
366
383
 
367
384
  ```json
368
385
  {
@@ -403,6 +420,33 @@ Rp suite) without external network daemons or closed-source intermediaries:
403
420
  }
404
421
  ```
405
422
 
423
+ The token part of the same report is abridged here (`recentDays` always carries
424
+ seven entries, oldest first, today last):
425
+
426
+ ```json
427
+ {
428
+ "localStats": {
429
+ "codex": {
430
+ "hasLocalStats": true,
431
+ "hasPromptStats": true,
432
+ "todayTotalTokens": 14172,
433
+ "todayPrompts": 3,
434
+ "todaySessions": 1,
435
+ "todayTokensByModel": { "gpt-5.1-codex": 14172 },
436
+ "weekTokensByModel": { "gpt-5.1-codex": 118422 },
437
+ "recentDays": [{ "date": "2026-10-27", "messageCount": 0 }],
438
+ "totalPrompts": 1200,
439
+ "totalSessions": 42,
440
+ "activeDays": 11,
441
+ "activeDates": ["2026-10-27"],
442
+ "modelUsage": { "gpt-5.1-codex": { "inputTokens": 4, "outputTokens": 425, "cacheReadInputTokens": 6838, "cacheCreationInputTokens": 6905 } },
443
+ "byAccount": { "#1": { "todayTokens": 14172, "weekTokens": 118422, "todayPrompts": 3, "totalPrompts": 1200, "totalTokens": 48812210 } }
444
+ }
445
+ },
446
+ "localStatsMeta": { "files": 604, "scanned": 1, "reused": 603, "bytesRead": 3042, "durationMs": 9, "partial": false, "budgetMs": 12000, "frozenDays": 0, "locked": false }
447
+ }
448
+ ```
449
+
406
450
  ### Each provider refreshes on its own clock
407
451
 
408
452
  Anthropic's usage endpoint answers `429` when polled every minute; Codex,
@@ -434,7 +478,9 @@ piquota omarchy # write pi-claude.json, pi-codex.json, ... once
434
478
 
435
479
  - Records are named `pi-<family>.json`, so they never overwrite Omarchy's own
436
480
  `claude.json` / `codex.json`. They hold plan names, used percentages, window
437
- labels and reset times only; no tokens, accounts or e-mail addresses.
481
+ labels, reset times, and โ€” when the token scan has run โ€” aggregate token counts
482
+ per local day and per model (`hasLocalStats`, `recentDays`, `modelUsage`).
483
+ No prompt text, session path, account or e-mail address is copied.
438
484
  - A provider with a failing sign-in shows a status and a help line instead of
439
485
  limits. A provider that is not configured gets no record, and a stale
440
486
  `pi-*.json` is removed on the next run.
@@ -442,13 +488,219 @@ piquota omarchy # write pi-claude.json, pi-codex.json, ... once
442
488
  `auth.json`) still gets **one** record: every healthy account contributes only
443
489
  the window that constrains it, titled `#<slot> ยท Session|Weekly|Monthly`, and
444
490
  failed accounts add no rows but are summarised by reason, e.g.
445
- `#8โ€“#10 key rejected ยท #11โ€“#14 no Go plan`.
491
+ `#8โ€“#10 key rejected ยท #11โ€“#14 no Go plan`. The same record also carries
492
+ `accounts[{id, ok, status, limits}]` with every window of every account
493
+ (`status` is a short reason such as `Key rejected` for a failed one), which
494
+ Omarchy's built-in panel ignores and this package's own plugin draws.
446
495
  - Omarchy's native collector already tracks Claude with richer local statistics, so
447
496
  `piquota omarchy` automatically skips `claude` and cleans up duplicate `pi-claude.json`
448
497
  files when `claude.json` exists in the usage directory (override with `PI_QUOTA_OMARCHY_SKIP`).
449
498
  - `PI_QUOTA_OMARCHY_DIR` overrides the output directory. `./install.sh --uninstall`
450
499
  removes the timer and the `pi-*.json` records.
451
500
 
501
+ ### The AI Usage bar plugin
502
+
503
+ The package also ships its own Omarchy bar widget, `cad0p.pi-usage` ("AI
504
+ Usage", in [`omarchy/cad0p.pi-usage/`](omarchy/cad0p.pi-usage/)). It is derived
505
+ from Omarchy's built-in Agents plugin (MIT, by Omarchy) and reuses the shell's
506
+ own panel components, so it looks the same, but it has no tabs: every provider
507
+ is a collapsible section of one scrolling panel.
508
+
509
+ - **Header row**: the provider's mark, name and plan, the headline percentage
510
+ with its window and reset (`Weekly ยท 1d`), a chip when something is wrong
511
+ (`Unavailable`, `7 down`), and the headline meter while folded. The headline is
512
+ the fullest window; for a multi-account provider it is the healthy account
513
+ with the most room left, the `lowest` figure `/usage` prints.
514
+ - **Expanded**: the status card, LIMITS with the built-in meters and countdowns,
515
+ then for a multi-account record one row per account with every window
516
+ (`#2 session 2% 10m ยท week 10% 6d ยท month 90% 19d`, failed accounts dimmed
517
+ with their reason), then TOKENS BY DAY and TOKENS BY MODEL whenever the record
518
+ carries local statistics, drawn exactly as the built-in draws them.
519
+ - **Which section is open**: on first use, only the provider closest to a limit
520
+ (ties go to the earliest reset). Every expand or collapse you make sticks,
521
+ across popup opens and shell restarts, in
522
+ `~/.local/state/omarchy/settings/cad0p.pi-usage.json`.
523
+ - **Keys**: `j`/`k` or the arrows walk the section headers (a tall open section
524
+ scrolls first), Enter or Space toggles, `l`/`h` expand/collapse, `r` refreshes,
525
+ Tab moves to the neighbouring bar panel, Esc closes. The mouse wheel scrolls;
526
+ a click on a header toggles it.
527
+ - **Records**: every `*.json` in the usage directory, Omarchy's own included.
528
+ A provider is hidden by `providers.<id>.enabled = false` in the widget's
529
+ settings, the same switch the built-in uses. The plugin does **not** support
530
+ the built-in Agents widget's synced cross-device aggregation
531
+ (`syncMode`, `syncDir`, `syncFileName`, `syncDeviceId`). Users who rely on
532
+ synced aggregation should keep the built-in widget; this plugin is not a full
533
+ replacement for it.
534
+ - **Refresh**: the systemd timer and the Pi extension already publish the
535
+ `pi-*.json` records, so the plugin never schedules `piquota`. `r`, a middle
536
+ click or `omarchy-shell cad0p.pi-usage refresh` starts
537
+ `piquota-omarchy.service` (or runs the package's `piquota omarchy` when there is
538
+ no unit). Omarchy's collectors (`omarchy-agent-usage-update`) run on the
539
+ `refreshIntervalSec` timer and for a record's `retryAdvised` retry, and only
540
+ while the built-in Agents widget is off the bar (`omarchyCollectors`: `Auto`,
541
+ `On` or `Off`, where `Off` never runs them), so keeping both never doubles the
542
+ work.
543
+
544
+ Install it by linking it into Omarchy's plugin directory; the link points into
545
+ the pi package when there is one, so `pi update` updates the plugin too:
546
+
547
+ ```bash
548
+ ./install.sh --omarchy-plugin # symlink ~/.config/omarchy/plugins/cad0p.pi-usage (idempotent)
549
+ omarchy plugin enable cad0p.pi-usage # put it on the bar (right section)
550
+ omarchy bar put cad0p.pi-usage --after omarchy.agents # or: right next to the built-in Agents icon
551
+ omarchy plugin disable omarchy.agents # optional: drop the built-in widget
552
+ ```
553
+
554
+ Without the installer, the link is
555
+ `ln -s ~/.pi/agent/npm/node_modules/@cad0p/pi-usage/omarchy/cad0p.pi-usage ~/.config/omarchy/plugins/cad0p.pi-usage`
556
+ followed by `omarchy-shell shell rescanPlugins`. To hide providers, pass the whole
557
+ object, as with the built-in:
558
+ `omarchy bar set cad0p.pi-usage providers '{"fireworks":{"enabled":false}}' --json`.
559
+ Remove it with `omarchy plugin disable cad0p.pi-usage` and
560
+ `rm ~/.config/omarchy/plugins/cad0p.pi-usage` (`./install.sh --uninstall` removes
561
+ the link too); `omarchy plugin enable omarchy.agents` brings the built-in back.
562
+
563
+ IPC: `omarchy-shell cad0p.pi-usage <open|close|toggle|refresh>`, plus
564
+ `expand <id>` / `collapse <id>` (e.g. `expand pi-opencode-go`).
565
+
566
+ `tests/omarchy-harness/render.sh [out-dir] [--light]` renders the panel offscreen
567
+ to PNGs against fixture records, with Omarchy's real components and the current
568
+ theme, next to the built-in panel for comparison. It never touches the running
569
+ shell.
570
+
571
+ ## Token stats from Pi's own sessions
572
+
573
+ Pi writes every assistant message's `usage` block into
574
+ `~/.pi/agent/sessions/**.jsonl` (plus `~/.omp/agent/sessions` when the omp fork
575
+ is installed; `PI_CODING_AGENT_DIR` relocates the Pi agent directory). piQuota
576
+ folds those messages into tokens per local day and per model, per provider, and
577
+ shows them next to the quota:
578
+
579
+ | Where | What |
580
+ | --- | --- |
581
+ | `piquota --json` | `localStats.<family>` โ€” the Omarchy-shaped counters plus `byAccount` |
582
+ | `/usage` panel | a `tokens` row per provider: today, 7 days, prompts, then the top 3 models of the week |
583
+ | `piquota omarchy` | the optional fields Omarchy's Agents panel draws as "Tokens by day" and "Tokens by model" |
584
+ | `piquota stats` | the durable history: last frozen day, families, prompts, session-days |
585
+
586
+ What counts: one assistant message with a non-zero token count is one prompt,
587
+ and its tokens are `input + output + cacheRead + cacheWrite` โ€” the same sum the
588
+ Omarchy panel renders. Messages are bucketed by **local** date (the panel calls
589
+ the last row "Today"). Only providers piQuota can bill are counted:
590
+ `anthropic`, `openai-codex`, `antigravity`, `opencode-go` and their numbered
591
+ siblings, so `opencode-go-7` is account `#7`. Providers it does not manage
592
+ (`deepinfra`, `xai`, ...) are ignored, and so is the custom `claude-bridge`
593
+ provider: its tokens are not a Pi subscription.
594
+
595
+ **Two stores, two lifetimes.** Open days (normally just today) live in the
596
+ disposable scan cache. Once a local day is over it is frozen into a durable
597
+ history that no rescan, no cache wipe and no session deletion can rewrite. The
598
+ history is Pi data, so it lives next to Pi's agent directory:
599
+
600
+ | Path | What |
601
+ | --- | --- |
602
+ | `${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` |
603
+ | `${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-usage/tokens-YYYY-MM.json` | the frozen days of one local month, keyed day โ†’ family โ†’ slot โ†’ model |
604
+ | `~/.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 |
605
+
606
+ > `~/.pi` is often a git repository. Add `agent/pi-usage/` to that repo's
607
+ > `.gitignore` if you do not want your token counts committed.
608
+
609
+ Freezing: every run moves each day before today that is not frozen yet into its
610
+ month file and into `totals.json`, then drops it from the per-file cache. The
611
+ month file is written first and the totals second; the `lastFrozenDay` watermark
612
+ makes a restart re-freeze a half-written day idempotently (the month write is a
613
+ SET per day) instead of adding it twice. A late write to an already-frozen day โ€”
614
+ a session that appends just past midnight โ€” increments both stores directly; if
615
+ its month is temporarily unreadable, the delta stays in the cache until a later
616
+ run can write it. Such a held delta lives only in the disposable cache: if the
617
+ cache is lost before the month becomes writable, that delta is dropped, because
618
+ the bytes of a frozen day are never read again. A crash between the month and totals writes can double that
619
+ one small delta. A budget-truncated scan never freezes: history is only written
620
+ from complete runs.
621
+
622
+ Consequences, all covered by tests: history survives session deletion and cache
623
+ loss. A lost or corrupt `totals.json` is moved aside as
624
+ `totals.json.corrupt-<ts>` and **rebuilt from the month files**, which are the
625
+ authoritative record of frozen days โ€” not from the session files, because a
626
+ session deleted since then would otherwise shrink history. Frozen days are never
627
+ recomputed, so a replaced session file cannot rewrite them either.
628
+
629
+ Damaged month files are handled without rebuilding frozen days from sessions. A
630
+ month that cannot be *read* (permissions, I/O) is left alone: the run warns, keeps
631
+ its open days in the cache, and does not freeze them until the month is readable.
632
+ Other history proceeds normally, and this alone does not force a full session
633
+ rescan. A rollup rebuild never writes `totals.json` from an incomplete month set.
634
+
635
+ A *corrupt* or foreign-version month is moved aside by the lock owner (never by a
636
+ read-only run) to `.corrupt-<ts>`. Its warning names the file and directs you to copy the
637
+ `.corrupt-<ts>` backup back to the original month path if recoverable, or to delete
638
+ the backup to abandon those counts. Until one of the two happens, freezing stops at
639
+ that month for *every* later day too (the frozen watermark only moves forward), so
640
+ later days stay open in the cache and each run repeats the warning; once the backup
641
+ is restored or deleted, the held days freeze on the next run. Frozen days are never rebuilt from sessions: their
642
+ daily rows are missing (shown as 0) in `recentDays`, while `totals.json` retains
643
+ the all-time counts, so the two views may disagree. `piquota stats --rebuild-totals`
644
+ refuses while a quarantined month has not been restored. A lost *month* file
645
+ loses that month's daily detail; normal runs keep the all-time totals, and a
646
+ rebuild can recover counts from every remaining month file even if sessions are
647
+ gone. Rebuild refuses when there is no month file at all while `totals.json`
648
+ still has data, because replacing that rollup with an empty one would turn a
649
+ missing directory into permanently lost history. A normal run reads only
650
+ `totals.json` plus the month files covering the last seven days (current month,
651
+ and the previous one early in a month), so years of history never slow a run: on
652
+ this machine 45 frozen days cost ~28 KB in total โ€” `totals.json` 6.9 KB and two
653
+ month files of 5.4 KB and 16 KB. A day inside that window is read from its month
654
+ file once frozen, so late writes appear in "tokens by day" without waiting for
655
+ the week to roll over.
656
+
657
+ Cost: the first run streams each session file once โ€” 1.56 GB across 608 files
658
+ in 3.0 s here; later runs read only the bytes appended since the previous scan
659
+ (59 ms of scanning for the whole store, ~20 KB read), and every byte is parsed
660
+ once in normal operation. The CLI's own wall time is dominated by the provider
661
+ calls either way.
662
+
663
+ What forces a file to be read from its first byte (a *rescan*, in which frozen
664
+ days are skipped and only open days are rebuilt): the cache missing or corrupt,
665
+ the file replaced or now shorter, a path the cache has never seen (a copy, a
666
+ move, a restored backup โ€” its old days were already frozen under its previous
667
+ path), or the same size with a new mtime (an in-place rewrite of the same
668
+ length). An append only reads the new bytes. The one rewrite this cannot see is
669
+ one that restores both the size *and* the mtime, which no stat-based check can
670
+ detect. The cache is atomic and `0600`.
671
+
672
+ Two writers are routine here (the systemd timer and `/usage`), so a run takes an
673
+ exclusive lock โ€” `local-sessions.json.lock`, with a 2-minute stale timeout โ€”
674
+ before it reads the history. The file holds an owner token, and a live run
675
+ refreshes its mtime as it walks the store, so a slow scan is never mistaken for a
676
+ dead one. A run that cannot take the lock reports the numbers already on disk and
677
+ writes nothing; a run that is taken over mid-scan notices on its next refresh and
678
+ also writes nothing (it never deletes its successor's lock).
679
+
680
+ Duplicate *sessions* are handled two ways. A byte-identical copy of a session
681
+ file (a copy, a restored backup, a sync tool) is recognised by a (size, first
682
+ 32 KB, last 32 KB) fingerprint and contributes nothing; anything appended to it
683
+ later is counted normally, and if the original is deleted the copy is adopted and
684
+ read on the next run. A fingerprint can collide โ€” two same-size sessions that
685
+ differ only in the middle โ€” so a candidate is verified by streaming both files
686
+ before it is called a copy; that full comparison runs only on the rare duplicate.
687
+ A *fork* that copies a prefix and then diverges has its own size and tail, so it
688
+ is counted as a session of its own, duplicated prefix included: on this machine
689
+ (611 files, 186,443 assistant messages) only 11 message ids appeared in more than
690
+ one file (0.006%), none of them on today, so message-id de-duplication would cost
691
+ megabytes to correct a handful of tokens and is not implemented.
692
+
693
+ The scan has a wall-clock budget (12 s, or `PI_QUOTA_LOCAL_STATS_BUDGET_MS`) so
694
+ the first run cannot push `/usage` past its 30 s exec timeout; when it runs out
695
+ the report carries `localStatsMeta.partial`, the panel marks the numbers
696
+ `scan partial`, and nothing is frozen that run. `PI_QUOTA_LOCAL_STATS=0` turns
697
+ the scan off.
698
+
699
+ Session counts are **session-days**, not distinct session files: a day is frozen
700
+ as aggregate rows, without the list of files that fed it, so a session that runs
701
+ past midnight counts once on each day it appears in. `todaySessions` is exact
702
+ (those files are still open); `totalSessions` is the sum of the per-day counts.
703
+
452
704
  ## Layout
453
705
 
454
706
  ```
@@ -465,6 +717,8 @@ src/providers/antigravity-oauth.js in-memory refresh with Google's public clien
465
717
  src/providers/backoff.js per-family throttle state
466
718
  src/sticky.js last published / last good snapshots and throttle recovery
467
719
  src/engine.js collectQuota() -> one normalized report
720
+ src/local/pi-sessions.js token counts from Pi's session files, incremental scan
721
+ src/local/history.js frozen local-day history: month files + all-time rollup
468
722
  src/model.js window normalization, percent and reset parsing
469
723
  src/render/{theme,panel}.js colors, thresholds, rings, bars, boxed panel
470
724
  src/cache.js report cache at ~/.cache/pi-quota/usage.json
@@ -472,9 +726,12 @@ src/omarchy/record.js pure mapping from the report to Omarchy usage rec
472
726
  src/omarchy/publish.js atomic writer for pi-*.json, and stale-record cleanup
473
727
  src/refresh.js per-family refresh clocks, and the merge back into one report
474
728
  src/cli/args.js argument parsing, and the flag/positional split
729
+ src/cli/commands/stats.js `piquota stats` and `--rebuild-totals`
475
730
  src/exec.js the one place that spawns gh CLI binary
476
731
  src/http.js fetch wrapper: timeouts, JSON, redaction
477
732
  contrib/systemd/ user service and timer behind `install.sh --omarchy-timer`
733
+ omarchy/cad0p.pi-usage/ the AI Usage Omarchy bar plugin (QML; UsageLogic.js is its pure logic)
734
+ tests/omarchy-harness/ offscreen renderer for the plugin, fixtures included
478
735
  bin/piquota.js the only CLI
479
736
  extensions/quota-panel.ts Pi TUI line and /usage
480
737
  ```
@@ -498,7 +755,7 @@ extensions/quota-panel.ts Pi TUI line and /usage
498
755
  ## Tests
499
756
 
500
757
  ```bash
501
- node --test tests/*.test.mjs # 265 tests, fake tokens only, no network
758
+ node --test tests/*.test.mjs # 354 tests, fake tokens only, no network
502
759
  ```
503
760
 
504
761
  Modules covered: `auth.json` parsing and de-duplication, the Claude Code store
@@ -508,9 +765,14 @@ Actions plan resolution, opt-in gating and degradation, the two Antigravity
508
765
  failures and the OpenCode degradation), the dashboard parser's three
509
766
  strategies, the Firefox cookie reader against a synthetic SQLite database, the
510
767
  Antigravity refresh (in-memory only), sticky and last-good snapshots resilience,
511
- the Omarchy record mapping and atomic writer, the renderers, the Pi
512
- extension contract, the per-family refresh clocks, argument parsing, and the
513
- structure of these documents themselves (tables, fences, links, anchors).
768
+ the Omarchy record mapping and atomic writer, the Omarchy plugin's logic,
769
+ manifest and marks, the local token scan (family and slot mapping, incremental
770
+ append, shrink, deletion, local-day bucketing, cache corruption), the durable
771
+ history (month partitioning, freezing at midnight, late appends to frozen days,
772
+ cache loss, a crash between the month and totals writes, corrupt-history
773
+ quarantine, rebuild-totals), the renderers, the Pi extension contract, the
774
+ per-family refresh clocks, argument parsing, and the structure of these
775
+ documents themselves (tables, fences, links, anchors).
514
776
 
515
777
  ## Acknowledgments & Prior Art
516
778
 
package/bin/piquota.js CHANGED
@@ -19,6 +19,7 @@ import { parseArgs } from "../src/cli/args.js";
19
19
  import { runAuthCommand } from "../src/cli/commands/auth.js";
20
20
  import { runGentlePiCommand } from "../src/cli/commands/gentle-pi.js";
21
21
  import { runOmarchyCommand } from "../src/cli/commands/omarchy.js";
22
+ import { runStatsCommand } from "../src/cli/commands/stats.js";
22
23
  import { explainLines, resolveFamilies } from "../src/cli/explain.js";
23
24
  import { err, out } from "../src/cli/output.js";
24
25
  import { collectQuota, enabledFamilies, normalizeClaudeSourceMode } from "../src/engine.js";
@@ -35,6 +36,20 @@ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.u
35
36
  */
36
37
  const CACHE_KEY = `claude-source:${normalizeClaudeSourceMode(process.env.PI_QUOTA_CLAUDE_SOURCE)}`;
37
38
 
39
+ /**
40
+ * Local token statistics from Pi's session files are on for the CLI: the Omarchy
41
+ * records and /usage both show them. `PI_QUOTA_LOCAL_STATS=0` turns the scan
42
+ * off (e.g. on a machine where the session store is huge and network quota is
43
+ * all that matters), and the budget keeps a first full scan from delaying a
44
+ * report past the 30 s the /usage extension allows.
45
+ */
46
+ const LOCAL_STATS_BUDGET_MS = Number(process.env.PI_QUOTA_LOCAL_STATS_BUDGET_MS);
47
+ const LOCAL_STATS = process.env.PI_QUOTA_LOCAL_STATS === "0"
48
+ ? false
49
+ : LOCAL_STATS_BUDGET_MS > 0
50
+ ? { budgetMs: LOCAL_STATS_BUDGET_MS }
51
+ : {};
52
+
38
53
  const HELP = `piquota ${VERSION} โ€” read-only quota from Pi's provider credentials
39
54
 
40
55
  Usage:
@@ -42,6 +57,7 @@ Usage:
42
57
  piquota auth <opencode|status> [flags]
43
58
  piquota gentle-pi <status|apply|revert>
44
59
  piquota omarchy
60
+ piquota stats [--rebuild-totals]
45
61
 
46
62
  Quota:
47
63
  --json Emit the normalized report as JSON (for rp-host and automations)
@@ -56,6 +72,7 @@ Quota:
56
72
  --no-refresh Never refresh Antigravity's token in memory
57
73
  --explain Show which stores/fields are read (names only)
58
74
  --clear-cache Delete the local cache and exit
75
+ --rebuild-totals With \`stats\`: rebuild the token-history totals from its month files
59
76
  --version Print the version and exit
60
77
 
61
78
  Auth (OpenCode Go session, the only credential Pi does not store):
@@ -66,18 +83,31 @@ Auth (OpenCode Go session, the only credential Pi does not store):
66
83
  piquota auth opencode --wait <s> how long to wait for the login (default 180)
67
84
  piquota auth status report every credential source
68
85
 
86
+ Stats:
87
+ piquota stats summarise the durable token history
88
+ (dir: \${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-usage)
89
+ piquota stats --rebuild-totals recompute totals.json from the frozen month files
90
+
69
91
  Omarchy:
70
92
  piquota omarchy write pi-<family>.json records for Omarchy's Agents bar panel
71
93
  (dir: $PI_QUOTA_OMARCHY_DIR or $XDG_STATE_HOME/omarchy/agents/usage)
72
94
  run it on a timer: ./install.sh --omarchy-timer
73
95
 
96
+ Environment:
97
+ PI_QUOTA_LOCAL_STATS=0 skip the token scan of Pi's session files
98
+ PI_QUOTA_LOCAL_STATS_BUDGET_MS wall-clock budget for that scan (default 12000)
99
+
74
100
  Guarantees:
75
101
  * ~/.pi/agent/auth.json is opened read-only. Never written, synced or refreshed.
102
+ * Pi's session files are opened read-only too; the token scan keeps only
103
+ aggregate counts per day and model, never prompt text or session paths.
76
104
  * Antigravity's access token may be refreshed in memory; it is never persisted.
77
105
  * Browser cookie databases are copied and opened read-only; values are never logged.
78
- * Only percentages, window labels, reset times and plan names leave this machine.
106
+ * Only percentages, window labels, reset times, plan names and aggregate token
107
+ counts leave this machine.
79
108
  * \`omarchy\` (opt-in) writes only pi-*.json records, atomically, into Omarchy's usage dir;
80
- records hold plan names, percentages, window labels and reset times, never accounts.
109
+ records hold plan names, percentages, window labels, reset times and aggregate
110
+ token counts, never prompt text or accounts.
81
111
  * \`gentle-pi\` is the one file this tool edits outside its own state: it appends
82
112
  piQuota's part to gentle-pi's rail allowlist so the card can be painted there.
83
113
  The edit is one array literal, it is reversible with \`piquota gentle-pi revert\`,
@@ -122,10 +152,24 @@ async function main() {
122
152
  return runGentlePiCommand(sub);
123
153
  }
124
154
 
155
+ if (args.rebuildTotals && command !== "stats") {
156
+ // The flag repairs one thing (the all-time rollup) and reads nothing else,
157
+ // so running it beside `piquota` or `piquota omarchy` would silently skip
158
+ // the command the user asked for.
159
+ err("--rebuild-totals only works with `piquota stats`\n");
160
+ process.stdout.write(HELP);
161
+ return 2;
162
+ }
163
+
164
+ if (command === "stats") {
165
+ return runStatsCommand({ rebuild: args.rebuildTotals, env: process.env });
166
+ }
167
+
125
168
  if (command === "omarchy") {
126
169
  const load = async () => {
127
170
  const everyFamily = enabledFamilies({ env: process.env });
128
- const loadFresh = () => collectQuota({ families: everyFamily, timeoutMs: args.timeoutMs, refresh, force: args.force });
171
+ const loadFresh = () =>
172
+ collectQuota({ families: everyFamily, timeoutMs: args.timeoutMs, refresh, force: args.force, localStats: LOCAL_STATS });
129
173
  const fresh = args.noCache
130
174
  ? await loadFresh()
131
175
  : (await withCache({ ttlMs: args.ttlMs, force: args.force, families: everyFamily, key: CACHE_KEY }, loadFresh)).report;
@@ -149,7 +193,8 @@ async function main() {
149
193
  requested: families.length > 0 ? families : undefined,
150
194
  explicit,
151
195
  });
152
- const load = () => collectQuota({ families: selected, explicit, timeoutMs: args.timeoutMs, refresh, force: args.force });
196
+ const load = () =>
197
+ collectQuota({ families: selected, explicit, timeoutMs: args.timeoutMs, refresh, force: args.force, localStats: LOCAL_STATS });
153
198
 
154
199
  let report;
155
200
  let cached = false;