@cad0p/pi-usage 0.11.2 → 0.11.3-20261102.0

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 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
 
@@ -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.** The choice is a *preference, not
101
- a fallback*: only the selected source is tried, so a broken preferred source is
102
- reported rather than silently masked by the other one. Override it with
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 boxed panel above the editor |
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 # 261 tests, fake tokens only, no network
502
+ node --test tests/*.test.mjs # 266 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 = "0.11.1";
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 box, /usage line or /usage panel pins
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
- // A multi-account family gets one row per account. The title carries the
499
- // label ("OP-Go #2") so the account is not repeated in the meta line.
500
- const multi = (familyCounts.get(provider.family) ?? 0) > 1
501
- && typeof provider.account === "string"
502
- && provider.account.startsWith("#");
503
- const title = multi
504
- ? `${SHORT_NAME[provider.family] ?? provider.label} ${provider.account}`
505
- : provider.label;
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
- pi.on("agent_start", () => {
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
- const active = firstPerFamily(report.providers.filter((provider) => provider.notConfigured !== true));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cad0p/pi-usage",
3
- "version": "0.11.2",
3
+ "version": "0.11.3-20261102.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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({ savedAt: now, report: finalReport }, null, 2);
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}`,