@cad0p/pi-usage 0.11.2 → 0.11.3

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
 
@@ -243,6 +247,12 @@ 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: 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
253
+ cap on plain widgets never truncates it, and the summary names the account with the
254
+ most room: `OP-Go: 7/14 ok · lowest 24% (#7)`.
255
+
246
256
  `/usage box` pins the **five-row box above the editor**:
247
257
 
248
258
  ```
@@ -477,7 +487,7 @@ extensions/quota-panel.ts Pi TUI line and /usage
477
487
  | `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
488
  | `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
489
  | `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. |
490
+ | 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
491
  | `rate limited (HTTP 429); retry in Ns` | The vendor throttled the usage endpoint. Wait, or raise `--ttl`. |
482
492
  | `Antigravity token rejected` | Refresh failed; `/login antigravity` in Pi. `--no-refresh` disables the attempt. |
483
493
  | `no "auth" cookie for opencode.ai in N readable store(s)` | Run `piquota auth opencode`, or paste the cookie. |
@@ -488,7 +498,7 @@ extensions/quota-panel.ts Pi TUI line and /usage
488
498
  ## Tests
489
499
 
490
500
  ```bash
491
- node --test tests/*.test.mjs # 261 tests, fake tokens only, no network
501
+ node --test tests/*.test.mjs # 265 tests, fake tokens only, no network
492
502
  ```
493
503
 
494
504
  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;
@@ -486,26 +486,146 @@ function renderSidebarCard(report: QuotaReport | null, theme: Theme, width: numb
486
486
  }
487
487
 
488
488
  /** The detailed panel: every account and every window, with a used-fraction bar. */
489
+ /**
490
+ * Clip one line to `width` visible columns, keeping SGR sequences intact. Every
491
+ * glyph this extension paints is one column wide, so code points are columns.
492
+ */
493
+ function clipToWidth(line: string, width: number): string {
494
+ let visible = 0;
495
+ let out = "";
496
+ let styled = false;
497
+ for (let index = 0; index < line.length; ) {
498
+ const sgr = /^\u001b\[[0-9;]*m/.exec(line.slice(index));
499
+ if (sgr) {
500
+ out += sgr[0];
501
+ styled = true;
502
+ index += sgr[0].length;
503
+ continue;
504
+ }
505
+ const char = String.fromCodePoint(line.codePointAt(index) as number);
506
+ // Close any open style so a cut line cannot bleed colour into the next one.
507
+ if (visible + 1 > width) return styled ? `${out}\u001b[0m` : out;
508
+ out += char;
509
+ visible += 1;
510
+ index += char.length;
511
+ }
512
+ return out;
513
+ }
514
+
515
+ /**
516
+ * The panel as a component rather than a string array: Pi caps string-array
517
+ * widgets at ten lines, and a multi-account family alone can exceed that.
518
+ */
519
+ function panelComponent(lines: string[]) {
520
+ return () => ({
521
+ render: (width: number) => lines.map((line) => ` ${clipToWidth(line, Math.max(0, width - 1))}`),
522
+ invalidate() {},
523
+ });
524
+ }
525
+
526
+ /**
527
+ * The one-line notify summary. A multi-account family reports how many accounts
528
+ * answer and the account with the most room, not whichever key happens to be #1.
529
+ */
530
+ function summarize(report: QuotaReport): string {
531
+ const active = report.providers.filter((provider) => provider.notConfigured !== true);
532
+ return firstPerFamily(active)
533
+ .map((provider) => {
534
+ const name = SHORT_NAME[provider.family] ?? provider.family;
535
+ if (isMultiAccount(active, provider)) {
536
+ const accounts = active.filter((other) => other.family === provider.family);
537
+ const scored = accounts
538
+ .filter((account) => account.ok)
539
+ .map((account) => ({ account, used: usedOf(bindingWindow(account)) }))
540
+ .filter((entry): entry is { account: QuotaProvider; used: number } => entry.used !== null);
541
+ const counted = `${accounts.filter((account) => account.ok).length}/${accounts.length} ok`;
542
+ if (scored.length === 0) return `${name}: ${counted}`;
543
+ const lowest = scored.reduce((best, entry) => (entry.used < best.used ? entry : best));
544
+ return `${name}: ${counted} · lowest ${Math.round(lowest.used)}% (${lowest.account.account})`;
545
+ }
546
+ if (!provider.ok) return `${name}: ${errorCaption(provider.error)}`;
547
+ const used = usedOf(headline(provider));
548
+ return `${name}: ${used === null ? "n/a" : `${Math.round(used)}% used`}`;
549
+ })
550
+ .join(" · ");
551
+ }
552
+
553
+ /** The account's fullest window: the one that stops its next prompt. */
554
+ function bindingWindow(provider: QuotaProvider): QuotaWindow | null {
555
+ let best: QuotaWindow | null = null;
556
+ for (const window of provider.windows) {
557
+ const used = usedOf(window);
558
+ if (used === null) continue;
559
+ if (best === null || used > (usedOf(best) as number)) best = window;
560
+ }
561
+ return best;
562
+ }
563
+
564
+ /** Short per-account failure reason; mirrors src/omarchy/record.js. */
565
+ function accountReason(error: string | null | undefined): string {
566
+ const text = String(error ?? "");
567
+ if (/key rejected|key was rejected/i.test(text)) return "key rejected";
568
+ if (/Go plan not active|Workspace access denied/i.test(text)) return "no Go plan";
569
+ return errorCaption(error);
570
+ }
571
+
572
+ /** `#N` accounts of one family, i.e. a family Pi holds several credentials for. */
573
+ function isMultiAccount(providers: QuotaProvider[], provider: QuotaProvider): boolean {
574
+ return typeof provider.account === "string"
575
+ && provider.account.startsWith("#")
576
+ && providers.filter((other) => other.family === provider.family).length > 1;
577
+ }
578
+
579
+ /**
580
+ * One family, many accounts: a header plus one line per account with only its
581
+ * binding window, so fourteen keys stay fourteen lines instead of fifty-six.
582
+ */
583
+ function renderAccountRows(accounts: QuotaProvider[], theme: Theme): string[] {
584
+ const family = accounts[0].family;
585
+ const healthy = accounts.filter((account) => account.ok).length;
586
+ const header = `${paint(BRAND[family] ?? "#8B949E", SHORT_NAME[family] ?? accounts[0].label)} ${theme.fg("dim", `· ${healthy} of ${accounts.length} accounts ok`)}`;
587
+ const lines = [header];
588
+ const width = Math.max(...accounts.map((account) => account.account.length));
589
+ for (const account of accounts) {
590
+ const label = theme.fg("dim", account.account.padEnd(width));
591
+ if (!account.ok) {
592
+ const kind = errorKind(account.error);
593
+ const color = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
594
+ lines.push(` ${label} ${theme.fg(color, errorGlyph(account.error))} ${theme.fg("warning", accountReason(account.error))}`);
595
+ continue;
596
+ }
597
+ const window = bindingWindow(account);
598
+ const used = usedOf(window);
599
+ if (window === null || used === null) {
600
+ lines.push(` ${label} ${theme.fg("dim", "no rate-limit windows reported")}`);
601
+ continue;
602
+ }
603
+ const band = bandFor(used);
604
+ const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
605
+ lines.push(
606
+ ` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
607
+ `${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
608
+ `${theme.fg("dim", window.id)}${reset}`,
609
+ );
610
+ }
611
+ return lines;
612
+ }
613
+
489
614
  function renderPanel(report: QuotaReport, theme: Theme): string[] {
490
615
  const lines: string[] = [];
491
616
  const active = report.providers.filter((provider) => provider.notConfigured !== true);
492
617
  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);
618
+ const renderedFamilies = new Set<string>();
496
619
 
497
620
  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);
621
+ if (isMultiAccount(active, provider)) {
622
+ if (renderedFamilies.has(provider.family)) continue;
623
+ renderedFamilies.add(provider.family);
624
+ lines.push(...renderAccountRows(active.filter((other) => other.family === provider.family), theme));
625
+ continue;
626
+ }
627
+ const name = paint(BRAND[provider.family] ?? "#8B949E", provider.label);
628
+ const meta: string[] = [provider.account];
509
629
  if (provider.plan) meta.push(`plan ${provider.plan}`);
510
630
  // Two stores can back Claude, and this is the one place that says which.
511
631
  if (provider.sourceKind === "claude-code") meta.push("Claude Code CLI");
@@ -647,7 +767,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
647
767
  }
648
768
 
649
769
  if (panelVisible && report) {
650
- ctx.ui.setWidget(WIDGET_KEY, renderPanel(report, theme), { placement: "aboveEditor" });
770
+ ctx.ui.setWidget(WIDGET_KEY, panelComponent(renderPanel(report, theme)), { placement: "aboveEditor" });
651
771
  return;
652
772
  }
653
773
 
@@ -939,16 +1059,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
939
1059
  ctx.ui.notify(`Quota unavailable: ${lastError}`, "error");
940
1060
  return;
941
1061
  }
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");
1062
+ ctx.ui.notify(summarize(report) || "No active providers configured in Pi", "info");
952
1063
  };
953
1064
 
954
1065
  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",
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}`,