@cad0p/pi-usage 0.11.2 → 0.11.3-20261102.1
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 +20 -0
- package/README.md +20 -9
- package/bin/piquota.js +12 -4
- package/extensions/quota-panel.ts +178 -27
- package/package.json +1 -1
- package/src/auth/pi-auth.js +3 -0
- package/src/cache.js +12 -1
- package/src/engine.js +22 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.11.3] - 2026-11-02
|
|
6
|
+
|
|
7
|
+
<!-- USER-EDITABLE SECTION START -->
|
|
8
|
+
First fix release of the fork. With Claude Code uninstalled, Claude now reads Pi's own login instead of a dead Claude Code store, and `/usage` shows every OpenCode Go account.
|
|
9
|
+
|
|
10
|
+
- **Claude source:** `auto` passes over an expired Claude Code token when Pi's `anthropic` login is fresh, and adds a warning naming the switch. `PI_QUOTA_CLAUDE_SOURCE=claude-code` still pins the CLI store. The report cache is keyed by the Claude source setting, so a switch refetches.
|
|
11
|
+
- **Multi-account panel:** a provider with several accounts is one header plus one line per account (its fullest window, or `key rejected` / `no Go plan`), rendered as a component so Pi's ten-line widget cap no longer truncates it.
|
|
12
|
+
- **Summary:** `OP-Go: 7/14 ok · lowest 24% (#7)` instead of account #1's number.
|
|
13
|
+
- `piquota --version` reads `package.json`.
|
|
14
|
+
<!-- USER-EDITABLE SECTION END -->
|
|
15
|
+
|
|
16
|
+
### 🐛 Bug Fixes
|
|
17
|
+
|
|
18
|
+
- /usage live bugs — dead Claude store, truncated panel, account-#1 summary (closes #5)
|
|
19
|
+
|
|
20
|
+
|
package/README.md
CHANGED
|
@@ -8,7 +8,8 @@ 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
|
-
> * **`/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.
|
|
11
|
+
> * **`/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
|
+
> * **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.
|
|
12
13
|
>
|
|
13
14
|
> Everything below still applies; the CLI is still called `piquota`.
|
|
14
15
|
|
|
@@ -64,7 +65,7 @@ shuvquota, and it never writes to a credential file.
|
|
|
64
65
|
| --- | --- | --- | --- |
|
|
65
66
|
| Claude | Claude Code CLI store, else `anthropic.access` | `GET api.anthropic.com/api/oauth/usage` | ✅ 5h + weekly + plan |
|
|
66
67
|
| Codex | `openai-codex.access` + `accountId` | `GET chatgpt.com/backend-api/wham/usage` | ✅ 5h + weekly + plan |
|
|
67
|
-
| Antigravity | `antigravity.access` + `projectId` | `POST cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` | ✅ Gemini + Claude/GPT buckets, **with in-memory refresh** |
|
|
68
|
+
| Antigravity | `google-antigravity.access` (or legacy `antigravity`) + `projectId` | `POST cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` | ✅ Gemini + Claude/GPT buckets, **with in-memory refresh** |
|
|
68
69
|
| 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 |
|
|
69
70
|
| 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 |
|
|
70
71
|
|
|
@@ -97,9 +98,12 @@ There are two ways to be signed in to Claude, and a user may have either or both
|
|
|
97
98
|
| 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. |
|
|
98
99
|
| Pi's own entry | `~/.pi/agent/auth.json` → `anthropic` | `/login anthropic` in Pi, without the plugin installed. |
|
|
99
100
|
|
|
100
|
-
**The Claude Code CLI wins when both are present
|
|
101
|
-
a fallback*: only the selected source is tried, so a
|
|
102
|
-
reported rather than silently masked by the other one.
|
|
101
|
+
**The Claude Code CLI wins when both are present and its token is still valid.** The
|
|
102
|
+
choice is a *preference, not a fallback*: only the selected source is tried, so a
|
|
103
|
+
broken preferred source is reported rather than silently masked by the other one.
|
|
104
|
+
The one exception is an expired Claude Code token next to a fresh Pi login: a store
|
|
105
|
+
left behind by an uninstalled CLI never refreshes again, so `auto` reads Pi's login
|
|
106
|
+
instead and adds a warning naming the switch. Override it with
|
|
103
107
|
`PI_QUOTA_CLAUDE_SOURCE=pi` or `=claude-code`; `--explain` prints which one was used
|
|
104
108
|
and why.
|
|
105
109
|
|
|
@@ -226,10 +230,10 @@ Inside Pi:
|
|
|
226
230
|
|
|
227
231
|
| Command | Effect |
|
|
228
232
|
| --- | --- |
|
|
229
|
-
| `/usage` | one on-demand refresh, show the full panel, notify a one-line summary |
|
|
233
|
+
| `/usage` | one on-demand refresh, show the full panel until the next prompt (or a second `/usage`), notify a one-line summary |
|
|
230
234
|
| `/usage box` | pin the five-row box above the editor |
|
|
231
235
|
| `/usage line` | pin the compact line above the editor, in its own row |
|
|
232
|
-
| `/usage panel` | full
|
|
236
|
+
| `/usage panel` | full panel above the editor, pinned across prompts |
|
|
233
237
|
| `/usage hide` | hide the panel/line and stop background refresh until a surface is on again |
|
|
234
238
|
| `/usage status` / `nostatus` | enable/disable the footer status |
|
|
235
239
|
| `/usage refresh` | refresh only |
|
|
@@ -243,6 +247,13 @@ above the editor and leaves the footer alone. Turn a surface on with `/usage box
|
|
|
243
247
|
it fresh with a background refresh (and a gentle refresh after each agent turn).
|
|
244
248
|
`/usage hide` gives the row back.
|
|
245
249
|
|
|
250
|
+
A provider with several accounts is shown as one header (`OP-Go · 7 of 14 accounts
|
|
251
|
+
ok`) plus one line per account with every window and its reset, e.g.
|
|
252
|
+
`#2 ● session 2% (10m) · week 10% (6d) · month 90% (19d)`, or the reason it failed
|
|
253
|
+
(`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
|
|
254
|
+
cap on plain widgets never truncates it, and the summary names the account with the
|
|
255
|
+
most room: `OP-Go: 7/14 ok · lowest 24% (#7)`.
|
|
256
|
+
|
|
246
257
|
`/usage box` pins the **five-row box above the editor**:
|
|
247
258
|
|
|
248
259
|
```
|
|
@@ -477,7 +488,7 @@ extensions/quota-panel.ts Pi TUI line and /usage
|
|
|
477
488
|
| `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. |
|
|
478
489
|
| `Claude Code CLI has no readable credentials; looked for …` | The store is missing, empty or corrupt. `piquota --explain` prints every path that was tried. |
|
|
479
490
|
| `Claude Code token expired or rejected; run \`claude\` once` | Claude Code's own access token lapsed. Run any `claude` command; piQuota never refreshes it. |
|
|
480
|
-
| 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. |
|
|
491
|
+
| 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. |
|
|
481
492
|
| `rate limited (HTTP 429); retry in Ns` | The vendor throttled the usage endpoint. Wait, or raise `--ttl`. |
|
|
482
493
|
| `Antigravity token rejected` | Refresh failed; `/login antigravity` in Pi. `--no-refresh` disables the attempt. |
|
|
483
494
|
| `no "auth" cookie for opencode.ai in N readable store(s)` | Run `piquota auth opencode`, or paste the cookie. |
|
|
@@ -488,7 +499,7 @@ extensions/quota-panel.ts Pi TUI line and /usage
|
|
|
488
499
|
## Tests
|
|
489
500
|
|
|
490
501
|
```bash
|
|
491
|
-
node --test tests/*.test.mjs #
|
|
502
|
+
node --test tests/*.test.mjs # 267 tests, fake tokens only, no network
|
|
492
503
|
```
|
|
493
504
|
|
|
494
505
|
Modules covered: `auth.json` parsing and de-duplication, the Claude Code store
|
package/bin/piquota.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* the rail slot gentle-pi needs to paint the quota card
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
15
17
|
import { clearCache, withCache } from "../src/cache.js";
|
|
16
18
|
import { parseArgs } from "../src/cli/args.js";
|
|
17
19
|
import { runAuthCommand } from "../src/cli/commands/auth.js";
|
|
@@ -19,13 +21,19 @@ import { runGentlePiCommand } from "../src/cli/commands/gentle-pi.js";
|
|
|
19
21
|
import { runOmarchyCommand } from "../src/cli/commands/omarchy.js";
|
|
20
22
|
import { explainLines, resolveFamilies } from "../src/cli/explain.js";
|
|
21
23
|
import { err, out } from "../src/cli/output.js";
|
|
22
|
-
import { collectQuota, enabledFamilies } from "../src/engine.js";
|
|
24
|
+
import { collectQuota, enabledFamilies, normalizeClaudeSourceMode } from "../src/engine.js";
|
|
23
25
|
import { redact } from "../src/http.js";
|
|
24
26
|
import { mergeLastGood } from "../src/sticky.js";
|
|
25
27
|
import { renderBoxWidget, renderCompact, renderPanel, renderStatusLine } from "../src/render/panel.js";
|
|
26
28
|
import { ansiPalette } from "../src/render/theme.js";
|
|
27
29
|
|
|
28
|
-
const VERSION = "
|
|
30
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The cache answers for the settings it was collected under: a Claude source
|
|
34
|
+
* switch must refetch instead of serving the other source's result.
|
|
35
|
+
*/
|
|
36
|
+
const CACHE_KEY = `claude-source:${normalizeClaudeSourceMode(process.env.PI_QUOTA_CLAUDE_SOURCE)}`;
|
|
29
37
|
|
|
30
38
|
const HELP = `piquota ${VERSION} — read-only quota from Pi's provider credentials
|
|
31
39
|
|
|
@@ -120,7 +128,7 @@ async function main() {
|
|
|
120
128
|
const loadFresh = () => collectQuota({ families: everyFamily, timeoutMs: args.timeoutMs, refresh, force: args.force });
|
|
121
129
|
const fresh = args.noCache
|
|
122
130
|
? await loadFresh()
|
|
123
|
-
: (await withCache({ ttlMs: args.ttlMs, force: args.force, families: everyFamily }, loadFresh)).report;
|
|
131
|
+
: (await withCache({ ttlMs: args.ttlMs, force: args.force, families: everyFamily, key: CACHE_KEY }, loadFresh)).report;
|
|
124
132
|
return mergeLastGood(fresh, {}).report;
|
|
125
133
|
};
|
|
126
134
|
return runOmarchyCommand({ load });
|
|
@@ -149,7 +157,7 @@ async function main() {
|
|
|
149
157
|
if (args.noCache) {
|
|
150
158
|
report = await load();
|
|
151
159
|
} else {
|
|
152
|
-
const result = await withCache({ ttlMs: args.ttlMs, force: args.force, families: selected }, load);
|
|
160
|
+
const result = await withCache({ ttlMs: args.ttlMs, force: args.force, families: selected, key: CACHE_KEY }, load);
|
|
153
161
|
report = result.report;
|
|
154
162
|
cached = result.cached;
|
|
155
163
|
ageMs = result.ageMs;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pi quota line — read-only quota for the providers Pi already owns.
|
|
3
3
|
*
|
|
4
|
-
* Surface: nothing at session start. /usage
|
|
4
|
+
* Surface: nothing at session start. Bare /usage shows the panel until the next
|
|
5
|
+
* prompt or a second /usage. /usage box, /usage line or /usage panel pins
|
|
5
6
|
* a surface above the editor, /usage status enables the footer, and /usage hide
|
|
6
7
|
* removes it. No timer or post-turn refresh runs until a surface is on.
|
|
7
8
|
*
|
|
@@ -486,26 +487,162 @@ function renderSidebarCard(report: QuotaReport | null, theme: Theme, width: numb
|
|
|
486
487
|
}
|
|
487
488
|
|
|
488
489
|
/** The detailed panel: every account and every window, with a used-fraction bar. */
|
|
490
|
+
/**
|
|
491
|
+
* Clip one line to `width` visible columns, keeping SGR sequences intact. Every
|
|
492
|
+
* glyph this extension paints is one column wide, so code points are columns.
|
|
493
|
+
*/
|
|
494
|
+
function clipToWidth(line: string, width: number): string {
|
|
495
|
+
let visible = 0;
|
|
496
|
+
let out = "";
|
|
497
|
+
let styled = false;
|
|
498
|
+
for (let index = 0; index < line.length; ) {
|
|
499
|
+
const sgr = /^\u001b\[[0-9;]*m/.exec(line.slice(index));
|
|
500
|
+
if (sgr) {
|
|
501
|
+
out += sgr[0];
|
|
502
|
+
styled = true;
|
|
503
|
+
index += sgr[0].length;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const char = String.fromCodePoint(line.codePointAt(index) as number);
|
|
507
|
+
// Close any open style so a cut line cannot bleed colour into the next one.
|
|
508
|
+
if (visible + 1 > width) return styled ? `${out}\u001b[0m` : out;
|
|
509
|
+
out += char;
|
|
510
|
+
visible += 1;
|
|
511
|
+
index += char.length;
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The panel as a component rather than a string array: Pi caps string-array
|
|
518
|
+
* widgets at ten lines, and a multi-account family alone can exceed that.
|
|
519
|
+
*/
|
|
520
|
+
function panelComponent(lines: string[]) {
|
|
521
|
+
return () => ({
|
|
522
|
+
render: (width: number) => lines.map((line) => ` ${clipToWidth(line, Math.max(0, width - 1))}`),
|
|
523
|
+
invalidate() {},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The one-line notify summary. A multi-account family reports how many accounts
|
|
529
|
+
* answer and the account with the most room, not whichever key happens to be #1.
|
|
530
|
+
*/
|
|
531
|
+
function summarize(report: QuotaReport): string {
|
|
532
|
+
const active = report.providers.filter((provider) => provider.notConfigured !== true);
|
|
533
|
+
return firstPerFamily(active)
|
|
534
|
+
.map((provider) => {
|
|
535
|
+
const name = SHORT_NAME[provider.family] ?? provider.family;
|
|
536
|
+
if (isMultiAccount(active, provider)) {
|
|
537
|
+
const accounts = active.filter((other) => other.family === provider.family);
|
|
538
|
+
const scored = accounts
|
|
539
|
+
.filter((account) => account.ok)
|
|
540
|
+
.map((account) => ({ account, used: usedOf(bindingWindow(account)) }))
|
|
541
|
+
.filter((entry): entry is { account: QuotaProvider; used: number } => entry.used !== null);
|
|
542
|
+
const counted = `${accounts.filter((account) => account.ok).length}/${accounts.length} ok`;
|
|
543
|
+
if (scored.length === 0) return `${name}: ${counted}`;
|
|
544
|
+
const lowest = scored.reduce((best, entry) => (entry.used < best.used ? entry : best));
|
|
545
|
+
return `${name}: ${counted} · lowest ${Math.round(lowest.used)}% (${lowest.account.account})`;
|
|
546
|
+
}
|
|
547
|
+
if (!provider.ok) return `${name}: ${errorCaption(provider.error)}`;
|
|
548
|
+
const used = usedOf(headline(provider));
|
|
549
|
+
return `${name}: ${used === null ? "n/a" : `${Math.round(used)}% used`}`;
|
|
550
|
+
})
|
|
551
|
+
.join(" · ");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Window names for the one-line account rows (same words as the Omarchy panel). */
|
|
555
|
+
const WINDOW_TAG: Record<string, string> = { "5h": "session", weekly: "week", monthly: "month" };
|
|
556
|
+
|
|
557
|
+
/** A reset countdown in its largest unit, so a row of three stays narrow. */
|
|
558
|
+
function shortDuration(seconds: number | null): string {
|
|
559
|
+
if (seconds === null || !Number.isFinite(seconds)) return "";
|
|
560
|
+
if (seconds <= 0) return "now";
|
|
561
|
+
if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d`;
|
|
562
|
+
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h`;
|
|
563
|
+
return `${Math.max(1, Math.floor(seconds / 60))}m`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** The account's fullest window: the one that stops its next prompt. */
|
|
567
|
+
function bindingWindow(provider: QuotaProvider): QuotaWindow | null {
|
|
568
|
+
let best: QuotaWindow | null = null;
|
|
569
|
+
for (const window of provider.windows) {
|
|
570
|
+
const used = usedOf(window);
|
|
571
|
+
if (used === null) continue;
|
|
572
|
+
if (best === null || used > (usedOf(best) as number)) best = window;
|
|
573
|
+
}
|
|
574
|
+
return best;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Short per-account failure reason; mirrors src/omarchy/record.js. */
|
|
578
|
+
function accountReason(error: string | null | undefined): string {
|
|
579
|
+
const text = String(error ?? "");
|
|
580
|
+
if (/key rejected|key was rejected/i.test(text)) return "key rejected";
|
|
581
|
+
if (/Go plan not active|Workspace access denied/i.test(text)) return "no Go plan";
|
|
582
|
+
return errorCaption(error);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** `#N` accounts of one family, i.e. a family Pi holds several credentials for. */
|
|
586
|
+
function isMultiAccount(providers: QuotaProvider[], provider: QuotaProvider): boolean {
|
|
587
|
+
return typeof provider.account === "string"
|
|
588
|
+
&& provider.account.startsWith("#")
|
|
589
|
+
&& providers.filter((other) => other.family === provider.family).length > 1;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* One family, many accounts: a header plus one line per account with only its
|
|
594
|
+
* binding window, so fourteen keys stay fourteen lines instead of fifty-six.
|
|
595
|
+
*/
|
|
596
|
+
function renderAccountRows(accounts: QuotaProvider[], theme: Theme): string[] {
|
|
597
|
+
const family = accounts[0].family;
|
|
598
|
+
const healthy = accounts.filter((account) => account.ok).length;
|
|
599
|
+
const header = `${paint(BRAND[family] ?? "#8B949E", SHORT_NAME[family] ?? accounts[0].label)} ${theme.fg("dim", `· ${healthy} of ${accounts.length} accounts ok`)}`;
|
|
600
|
+
const lines = [header];
|
|
601
|
+
const width = Math.max(...accounts.map((account) => account.account.length));
|
|
602
|
+
for (const account of accounts) {
|
|
603
|
+
const label = theme.fg("dim", account.account.padEnd(width));
|
|
604
|
+
if (!account.ok) {
|
|
605
|
+
const kind = errorKind(account.error);
|
|
606
|
+
const color = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
|
|
607
|
+
lines.push(` ${label} ${theme.fg(color, errorGlyph(account.error))} ${theme.fg("warning", accountReason(account.error))}`);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const window = bindingWindow(account);
|
|
611
|
+
const used = usedOf(window);
|
|
612
|
+
if (window === null || used === null) {
|
|
613
|
+
lines.push(` ${label} ${theme.fg("dim", "no rate-limit windows reported")}`);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
// Every window on one line, each in its own band colour; the leading glyph
|
|
617
|
+
// is the fullest window's, so the column still sorts the accounts by eye.
|
|
618
|
+
const band = bandFor(used);
|
|
619
|
+
const cells = account.windows
|
|
620
|
+
.filter((each) => usedOf(each) !== null)
|
|
621
|
+
.map((each) => {
|
|
622
|
+
const pct = usedOf(each) as number;
|
|
623
|
+
const reset = each.resetsInSec === null ? "" : ` ${theme.fg("dim", `(${shortDuration(each.resetsInSec)})`.padEnd(5))}`;
|
|
624
|
+
return `${theme.fg("dim", WINDOW_TAG[each.id] ?? each.id)} ${paint(bandFor(pct).color, `${Math.round(pct)}%`.padStart(4))}${reset}`;
|
|
625
|
+
});
|
|
626
|
+
lines.push(` ${label} ${paint(band.color, band.glyph)} ${cells.join(theme.fg("dim", " · "))}`);
|
|
627
|
+
}
|
|
628
|
+
return lines;
|
|
629
|
+
}
|
|
630
|
+
|
|
489
631
|
function renderPanel(report: QuotaReport, theme: Theme): string[] {
|
|
490
632
|
const lines: string[] = [];
|
|
491
633
|
const active = report.providers.filter((provider) => provider.notConfigured !== true);
|
|
492
634
|
const unconfigured = report.providers.filter((provider) => provider.notConfigured === true);
|
|
493
|
-
|
|
494
|
-
const familyCounts = new Map<string, number>();
|
|
495
|
-
for (const provider of active) familyCounts.set(provider.family, (familyCounts.get(provider.family) ?? 0) + 1);
|
|
635
|
+
const renderedFamilies = new Set<string>();
|
|
496
636
|
|
|
497
637
|
for (const provider of active) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const name = paint(BRAND[provider.family] ?? "#8B949E", title);
|
|
507
|
-
const meta: string[] = [];
|
|
508
|
-
if (!multi) meta.push(provider.account);
|
|
638
|
+
if (isMultiAccount(active, provider)) {
|
|
639
|
+
if (renderedFamilies.has(provider.family)) continue;
|
|
640
|
+
renderedFamilies.add(provider.family);
|
|
641
|
+
lines.push(...renderAccountRows(active.filter((other) => other.family === provider.family), theme));
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const name = paint(BRAND[provider.family] ?? "#8B949E", provider.label);
|
|
645
|
+
const meta: string[] = [provider.account];
|
|
509
646
|
if (provider.plan) meta.push(`plan ${provider.plan}`);
|
|
510
647
|
// Two stores can back Claude, and this is the one place that says which.
|
|
511
648
|
if (provider.sourceKind === "claude-code") meta.push("Claude Code CLI");
|
|
@@ -566,6 +703,11 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
566
703
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
567
704
|
let agentEndTimer: ReturnType<typeof setTimeout> | null = null;
|
|
568
705
|
let panelVisible = false;
|
|
706
|
+
/**
|
|
707
|
+
* The panel bare `/usage` opens is a glance, not a pin: it goes away when the
|
|
708
|
+
* next prompt starts or when `/usage` is run again. `/usage panel` pins it.
|
|
709
|
+
*/
|
|
710
|
+
let transientPanel = false;
|
|
569
711
|
/**
|
|
570
712
|
* Nothing is pinned by default. A box or line above the editor is a persistent
|
|
571
713
|
* surface, and a quota command should not claim a row until asked.
|
|
@@ -647,7 +789,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
647
789
|
}
|
|
648
790
|
|
|
649
791
|
if (panelVisible && report) {
|
|
650
|
-
ctx.ui.setWidget(WIDGET_KEY, renderPanel(report, theme), { placement: "aboveEditor" });
|
|
792
|
+
ctx.ui.setWidget(WIDGET_KEY, panelComponent(renderPanel(report, theme)), { placement: "aboveEditor" });
|
|
651
793
|
return;
|
|
652
794
|
}
|
|
653
795
|
|
|
@@ -813,7 +955,17 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
813
955
|
paintUi(ui);
|
|
814
956
|
});
|
|
815
957
|
|
|
816
|
-
|
|
958
|
+
function dismissTransientPanel(ui: UiContext): void {
|
|
959
|
+
panelVisible = false;
|
|
960
|
+
transientPanel = false;
|
|
961
|
+
if (!surfaceActive()) stopPolling();
|
|
962
|
+
paintUi(ui);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
966
|
+
// SAFETY: Pi's agent_start context implements UiContext when hasUI is true.
|
|
967
|
+
const ui = ctx as unknown as UiContext;
|
|
968
|
+
if (transientPanel && panelVisible && ui?.hasUI && !disposed) dismissTransientPanel(ui);
|
|
817
969
|
if (agentEndTimer) {
|
|
818
970
|
clearTimeout(agentEndTimer);
|
|
819
971
|
agentEndTimer = null;
|
|
@@ -882,6 +1034,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
882
1034
|
return;
|
|
883
1035
|
case "panel":
|
|
884
1036
|
panelVisible = true;
|
|
1037
|
+
transientPanel = false;
|
|
885
1038
|
lineVisible = false;
|
|
886
1039
|
boxVisible = false;
|
|
887
1040
|
explicitBox = false;
|
|
@@ -928,8 +1081,15 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
928
1081
|
break;
|
|
929
1082
|
}
|
|
930
1083
|
|
|
1084
|
+
// Bare /usage again while its glance is open closes it.
|
|
1085
|
+
if (panelVisible && transientPanel) {
|
|
1086
|
+
dismissTransientPanel(ctx);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
931
1090
|
// Bare /usage: one on-demand refresh, show the panel, report the percentages.
|
|
932
1091
|
panelVisible = true;
|
|
1092
|
+
transientPanel = true;
|
|
933
1093
|
lineVisible = false;
|
|
934
1094
|
boxVisible = false;
|
|
935
1095
|
await refresh(ctx, true);
|
|
@@ -939,16 +1099,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
939
1099
|
ctx.ui.notify(`Quota unavailable: ${lastError}`, "error");
|
|
940
1100
|
return;
|
|
941
1101
|
}
|
|
942
|
-
|
|
943
|
-
const summary = active
|
|
944
|
-
.map((provider) => {
|
|
945
|
-
const name = SHORT_NAME[provider.family] ?? provider.family;
|
|
946
|
-
if (!provider.ok) return `${name}: ${errorCaption(provider.error)}`;
|
|
947
|
-
const used = usedOf(headline(provider));
|
|
948
|
-
return `${name}: ${used === null ? "n/a" : `${Math.round(used)}% used`}`;
|
|
949
|
-
})
|
|
950
|
-
.join(" · ");
|
|
951
|
-
ctx.ui.notify(summary || "No active providers configured in Pi", "info");
|
|
1102
|
+
ctx.ui.notify(summarize(report) || "No active providers configured in Pi", "info");
|
|
952
1103
|
};
|
|
953
1104
|
|
|
954
1105
|
pi.registerCommand("usage", {
|
package/package.json
CHANGED
package/src/auth/pi-auth.js
CHANGED
|
@@ -24,6 +24,9 @@ export const FAMILY_BY_PROVIDER = {
|
|
|
24
24
|
"openai-codex": "codex",
|
|
25
25
|
anthropic: "claude",
|
|
26
26
|
antigravity: "antigravity",
|
|
27
|
+
// Pi's built-in /login stores Antigravity under this key; `antigravity` is
|
|
28
|
+
// kept for stores written by older Pi releases and third-party plugins.
|
|
29
|
+
"google-antigravity": "antigravity",
|
|
27
30
|
"opencode-go": "opencode-go",
|
|
28
31
|
};
|
|
29
32
|
|
package/src/cache.js
CHANGED
|
@@ -69,6 +69,7 @@ export function resolveCachePath(options = {}) {
|
|
|
69
69
|
* now?: number,
|
|
70
70
|
* env?: Record<string, string | undefined>,
|
|
71
71
|
* home?: string,
|
|
72
|
+
* key?: string, Settings fingerprint; a cached report saved under another key is a miss.
|
|
72
73
|
* }} [options]
|
|
73
74
|
* @returns {{ report: import("./engine.js").PiQuotaReport, ageMs: number, path: string } | null}
|
|
74
75
|
*/
|
|
@@ -100,6 +101,9 @@ export function readCache(options = {}) {
|
|
|
100
101
|
if (savedAt === null) return null;
|
|
101
102
|
const ageMs = now - savedAt;
|
|
102
103
|
if (ageMs > ttlMs) return null;
|
|
104
|
+
// A report collected under other settings (e.g. another Claude source) answers a
|
|
105
|
+
// different question, so it is a miss rather than a stale hit.
|
|
106
|
+
if (options.key !== undefined && /** @type {{ key?: unknown }} */ (parsed).key !== options.key) return null;
|
|
103
107
|
|
|
104
108
|
for (const provider of report.providers) {
|
|
105
109
|
for (const win of provider.windows ?? []) {
|
|
@@ -122,6 +126,8 @@ export function readCache(options = {}) {
|
|
|
122
126
|
* now?: number,
|
|
123
127
|
* env?: Record<string, string | undefined>,
|
|
124
128
|
* home?: string,
|
|
129
|
+
* merge?: boolean,
|
|
130
|
+
* key?: string,
|
|
125
131
|
* }} [options]
|
|
126
132
|
* @returns {{ ok: boolean, path: string, error?: string }}
|
|
127
133
|
*/
|
|
@@ -135,7 +141,11 @@ export function writeCache(report, options = {}) {
|
|
|
135
141
|
finalReport = mergeReportIntoCache(existing.report, report);
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
|
-
const payload = JSON.stringify(
|
|
144
|
+
const payload = JSON.stringify(
|
|
145
|
+
options.key === undefined ? { savedAt: now, report: finalReport } : { savedAt: now, key: options.key, report: finalReport },
|
|
146
|
+
null,
|
|
147
|
+
2,
|
|
148
|
+
);
|
|
139
149
|
|
|
140
150
|
try {
|
|
141
151
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -191,6 +201,7 @@ export function describeCache(options = {}) {
|
|
|
191
201
|
* path?: string,
|
|
192
202
|
* families?: string[],
|
|
193
203
|
* merge?: boolean,
|
|
204
|
+
* key?: string,
|
|
194
205
|
* env?: Record<string, string | undefined>,
|
|
195
206
|
* home?: string,
|
|
196
207
|
* }} options
|
package/src/engine.js
CHANGED
|
@@ -100,6 +100,26 @@ export function resolveClaudeCredentials(input) {
|
|
|
100
100
|
now: input.now,
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
// `auto` prefers the Claude Code CLI only while its token is still usable. A
|
|
104
|
+
// leftover store from an uninstalled CLI never refreshes again, so once it has
|
|
105
|
+
// expired Pi's own login is the better source whenever Pi still has a fresh one.
|
|
106
|
+
if (claudeCode.ok && mode === "auto" && !checkFreshness(claudeCode.credential, input.now ?? Date.now()).fresh) {
|
|
107
|
+
const freshPi = fromPi.filter((credential) => checkFreshness(credential, input.now ?? Date.now()).fresh);
|
|
108
|
+
if (freshPi.length > 0) {
|
|
109
|
+
const expiredMin = Math.abs(checkFreshness(claudeCode.credential, input.now ?? Date.now()).expiresInMin ?? 0);
|
|
110
|
+
return {
|
|
111
|
+
credentials: freshPi,
|
|
112
|
+
mode: "pi",
|
|
113
|
+
unavailable: null,
|
|
114
|
+
// Said out loud so the switch is never silent: the report names the source
|
|
115
|
+
// it used and why the preferred one was passed over.
|
|
116
|
+
notice: `claude: the Claude Code token expired ${expiredMin}m ago; using Pi's anthropic login instead`,
|
|
117
|
+
claudeCodePaths: claudeCode.paths,
|
|
118
|
+
recoveredLock: claudeCode.recoveredLock ?? null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
103
123
|
if (claudeCode.ok) {
|
|
104
124
|
return {
|
|
105
125
|
credentials: [claudeCode.credential],
|
|
@@ -191,6 +211,7 @@ export async function collectQuota(options = {}) {
|
|
|
191
211
|
platform: options.platform,
|
|
192
212
|
usersRoot: options.usersRoot,
|
|
193
213
|
claudeCodePaths: options.claudeCodePaths,
|
|
214
|
+
now,
|
|
194
215
|
});
|
|
195
216
|
|
|
196
217
|
/**
|
|
@@ -207,6 +228,7 @@ export async function collectQuota(options = {}) {
|
|
|
207
228
|
const byFamily = {};
|
|
208
229
|
/** @type {string[]} */
|
|
209
230
|
const warnings = [...loaded.warnings];
|
|
231
|
+
if (claude.notice) warnings.push(claude.notice);
|
|
210
232
|
if (claude.recoveredLock) {
|
|
211
233
|
warnings.push(
|
|
212
234
|
`claude: removed abandoned OAuth refresh lock (${Math.round((claude.recoveredLock.ageMs ?? 0) / 1000)}s old) at ${claude.recoveredLock.lockPath}`,
|