@ogulcancelik/pi-minimal-footer 0.1.12 → 0.1.14

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.
Files changed (3) hide show
  1. package/README.md +5 -9
  2. package/index.ts +199 -97
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,7 +10,7 @@ Minimal footer for [pi](https://github.com/earendil-works/pi) that replaces the
10
10
 
11
11
  - **Context gauge** — optional working directory and git branch, model, thinking level, and context window usage with token counts
12
12
  - **Subscription usage bars** — rolling window quotas with reset timers for supported providers
13
- - **Auto-refresh** — fetches usage on startup and model switch, then every 5 minutes
13
+ - **Shared usage cache** — all pi instances share one cached result per provider and account, so opening more sessions doesn't mean more quota API calls
14
14
  - **Git integration** — branch name, dirty state, ahead/behind counts
15
15
 
16
16
  ## Supported providers
@@ -46,13 +46,9 @@ Accepted false values: `0`, `false`, `no`, `off` (case-insensitive).
46
46
 
47
47
  ## How it works
48
48
 
49
- The footer reads context usage from the last assistant message's token counts (free — comes with every LLM response). Subscription usage is fetched from each provider's dedicated quota API using your existing auth tokens from `~/.pi/agent/auth.json` or environment variables.
49
+ The footer reads context usage from the last assistant message's token counts (free — comes with every LLM response). Subscription usage is fetched from each provider's dedicated quota API using your existing auth tokens from Pi's agent directory (`~/.pi/agent/auth.json` by default) or environment variables.
50
50
 
51
- Usage is fetched:
52
-
53
- - Once on startup
54
- - Immediately on model switch (Ctrl+P)
55
- - Every 5 minutes after that
51
+ Usage is cached in `~/.pi/agent/pi-minimal-footer/` and shared by every pi instance. Each instance re-reads the cache on startup, on model switch (Ctrl+P), and every 30 seconds. Only one instance fetches when the cache is older than 5 minutes. Failed fetches keep the last good numbers and retry after a minute, or after the provider's `retry-after` when rate-limited.
56
52
 
57
53
  Git state is refreshed:
58
54
 
@@ -66,10 +62,10 @@ The footer adapts to narrow terminals by stacking lines vertically instead of th
66
62
 
67
63
  ### Claude Max usage bar not showing
68
64
 
69
- Anthropic's OAuth usage endpoint (`/api/oauth/usage`) has been returning persistent 429 (rate limit) errors since late March 2026, affecting all third-party tools that display Claude usage data (CodexBar, oh-my-claudecode, claude-pulse, etc.). This is an Anthropic-side issue — tracked in [claude-code#30930](https://github.com/anthropics/claude-code/issues/30930) and [claude-code#31021](https://github.com/anthropics/claude-code/issues/31021). The usage bar will start working again once Anthropic fixes the endpoint.
65
+ Anthropic's OAuth usage endpoint (`/api/oauth/usage`) rate-limits requests by `User-Agent`. Anything other than Claude Code gets persistent 429s ([claude-code#30930](https://github.com/anthropics/claude-code/issues/30930)), so the footer identifies as `claude-code/<version>`. The limit is still tight, so with many pi sessions open the bar may briefly keep showing the last known values.
70
66
 
71
67
  ## Notes
72
68
 
73
69
  - Replaces the default pi footer entirely via `ctx.ui.setFooter()`
74
- - Auth tokens are read from `~/.pi/agent/auth.json` (populated by `/login`) or standard env vars (`ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, etc.)
70
+ - Auth tokens are read from Pi's `auth.json` (populated by `/login`) or standard env vars (`ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, etc.). The footer respects `PI_CODING_AGENT_DIR`, including paths starting with `~/`.
75
71
  - Providers without auth simply don't show a usage bar — no errors
package/index.ts CHANGED
@@ -8,10 +8,20 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { buildSessionContext } from "@earendil-works/pi-coding-agent";
11
+ import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
12
12
  import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
13
13
  import { execSync } from "node:child_process";
14
- import { existsSync, readFileSync } from "node:fs";
14
+ import { createHash } from "node:crypto";
15
+ import {
16
+ existsSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ renameSync,
21
+ statSync,
22
+ unlinkSync,
23
+ writeFileSync,
24
+ } from "node:fs";
15
25
  import { join } from "node:path";
16
26
  import { homedir } from "node:os";
17
27
 
@@ -20,13 +30,14 @@ import { homedir } from "node:os";
20
30
  interface RateWindow {
21
31
  label: string;
22
32
  usedPercent: number;
23
- resetsIn?: string; // human readable "2h38m"
33
+ resetsAt?: number; // epoch ms; formatted at render time so the countdown stays live
24
34
  }
25
35
 
26
36
  interface UsageSnapshot {
27
37
  provider: string;
28
38
  windows: RateWindow[];
29
39
  error?: string;
40
+ retryAfterMs?: number;
30
41
  fetchedAt: number;
31
42
  }
32
43
 
@@ -39,8 +50,15 @@ interface GitCache {
39
50
 
40
51
  // ============ Usage Cache ============
41
52
 
42
- const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes
43
- const usageCache = new Map<string, UsageSnapshot>(); // keyed by provider
53
+ // Usage is cached on disk and shared by every pi instance, so N open panes
54
+ // cost one request per TTL instead of N.
55
+ const USAGE_TTL = 5 * 60_000;
56
+ const USAGE_CHECK_INTERVAL = 30_000;
57
+ const USAGE_ERROR_RETRY = 60_000;
58
+ const USAGE_MAX_STALE = 60 * 60_000;
59
+ const USAGE_CLAIM = 30_000; // other instances hold off this long while one fetches
60
+ const USAGE_FILE_MAX_AGE = 24 * 60 * 60_000;
61
+ const USAGE_CACHE_DIR = join(homedir(), ".pi", "agent", "pi-minimal-footer");
44
62
 
45
63
  // ============ Env Flags ============
46
64
 
@@ -143,8 +161,8 @@ function getEmailPrefixFromJwt(token: string): string | null {
143
161
 
144
162
  // ============ Auth Loading ============
145
163
 
146
- function loadAuthJson(): Record<string, any> {
147
- const authPath = join(homedir(), ".pi", "agent", "auth.json");
164
+ export function loadAuthJson(): Record<string, any> {
165
+ const authPath = join(getAgentDir(), "auth.json");
148
166
  try {
149
167
  if (existsSync(authPath)) {
150
168
  return JSON.parse(readFileSync(authPath, "utf-8"));
@@ -295,13 +313,6 @@ function clampPercent(value: number): number {
295
313
  return Math.max(0, Math.min(100, value));
296
314
  }
297
315
 
298
- /** Normalize a value that might be 0-1 fraction OR 0-100 percent, then clamp. */
299
- function normalizePercent(value: number): number {
300
- if (!Number.isFinite(value)) return 0;
301
- const normalized = value <= 1 && value >= 0 ? value * 100 : value;
302
- return Math.max(0, Math.min(100, normalized));
303
- }
304
-
305
316
  function getWindowLabel(durationMs: number | undefined, fallback: string): string {
306
317
  if (!durationMs || !Number.isFinite(durationMs) || durationMs <= 0) return fallback;
307
318
 
@@ -341,6 +352,33 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 5000
341
352
  }
342
353
  }
343
354
 
355
+ function toTimestamp(value: unknown): number | undefined {
356
+ if (value === undefined || value === null || value === "") return undefined;
357
+ const ms = new Date(value as any).getTime();
358
+ return Number.isFinite(ms) ? ms : undefined;
359
+ }
360
+
361
+ function parseRetryAfter(res: Response): number | undefined {
362
+ const header = res.headers.get("retry-after");
363
+ if (header) {
364
+ const seconds = Number(header);
365
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
366
+ const date = Date.parse(header);
367
+ if (Number.isFinite(date)) return Math.max(0, date - Date.now());
368
+ }
369
+ return res.status === 429 ? USAGE_TTL : undefined;
370
+ }
371
+
372
+ function httpError(provider: string, res: Response): UsageSnapshot {
373
+ return {
374
+ provider,
375
+ windows: [],
376
+ error: `HTTP ${res.status}`,
377
+ retryAfterMs: parseRetryAfter(res),
378
+ fetchedAt: Date.now(),
379
+ };
380
+ }
381
+
344
382
  // ============ Usage Fetchers ============
345
383
 
346
384
  async function fetchClaudeUsage(): Promise<UsageSnapshot> {
@@ -354,11 +392,13 @@ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
354
392
  headers: {
355
393
  Authorization: `Bearer ${token}`,
356
394
  "anthropic-beta": "oauth-2025-04-20",
395
+ // Other user agents land in a much stricter rate-limit bucket (persistent 429s).
396
+ "User-Agent": "claude-code/2.1.282",
357
397
  },
358
398
  });
359
399
 
360
400
  if (!res.ok) {
361
- return { provider: "Claude", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
401
+ return httpError("Claude", res);
362
402
  }
363
403
 
364
404
  const data = (await res.json()) as any;
@@ -367,16 +407,16 @@ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
367
407
  if (data.five_hour?.utilization !== undefined) {
368
408
  windows.push({
369
409
  label: "5h",
370
- usedPercent: normalizePercent(data.five_hour.utilization),
371
- resetsIn: data.five_hour.resets_at ? formatResetTime(new Date(data.five_hour.resets_at)) : undefined,
410
+ usedPercent: clampPercent(data.five_hour.utilization),
411
+ resetsAt: toTimestamp(data.five_hour.resets_at),
372
412
  });
373
413
  }
374
414
 
375
415
  if (data.seven_day?.utilization !== undefined) {
376
416
  windows.push({
377
417
  label: "Week",
378
- usedPercent: normalizePercent(data.seven_day.utilization),
379
- resetsIn: data.seven_day.resets_at ? formatResetTime(new Date(data.seven_day.resets_at)) : undefined,
418
+ usedPercent: clampPercent(data.seven_day.utilization),
419
+ resetsAt: toTimestamp(data.seven_day.resets_at),
380
420
  });
381
421
  }
382
422
 
@@ -404,19 +444,18 @@ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
404
444
  });
405
445
 
406
446
  if (!res.ok) {
407
- return { provider: "Copilot", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
447
+ return httpError("Copilot", res);
408
448
  }
409
449
 
410
450
  const data = (await res.json()) as any;
411
451
  const windows: RateWindow[] = [];
412
452
 
413
- const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
414
- const resetsIn = resetDate ? formatResetTime(resetDate) : undefined;
453
+ const resetsAt = toTimestamp(data.quota_reset_date_utc);
415
454
 
416
455
  if (data.quota_snapshots?.premium_interactions) {
417
456
  const pi = data.quota_snapshots.premium_interactions;
418
457
  const usedPercent = clampPercent(100 - (pi.percent_remaining || 0));
419
- windows.push({ label: "Premium", usedPercent, resetsIn });
458
+ windows.push({ label: "Premium", usedPercent, resetsAt });
420
459
  }
421
460
 
422
461
  if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) {
@@ -424,7 +463,7 @@ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
424
463
  windows.push({
425
464
  label: "Chat",
426
465
  usedPercent: clampPercent(100 - (chat.percent_remaining || 0)),
427
- resetsIn,
466
+ resetsAt,
428
467
  });
429
468
  }
430
469
 
@@ -459,7 +498,7 @@ async function fetchCodexUsage(): Promise<UsageSnapshot> {
459
498
  });
460
499
 
461
500
  if (!res.ok) {
462
- return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
501
+ return httpError(providerLabel, res);
463
502
  }
464
503
 
465
504
  const data = (await res.json()) as any;
@@ -467,23 +506,21 @@ async function fetchCodexUsage(): Promise<UsageSnapshot> {
467
506
 
468
507
  if (data.rate_limit?.primary_window) {
469
508
  const pw = data.rate_limit.primary_window;
470
- const resetDate = pw.reset_at ? new Date(pw.reset_at * 1000) : undefined;
471
509
  const durationMs = typeof pw.limit_window_seconds === "number" ? pw.limit_window_seconds * 1000 : undefined;
472
510
  windows.push({
473
511
  label: getWindowLabel(durationMs, "5h"),
474
512
  usedPercent: clampPercent(pw.used_percent || 0),
475
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
513
+ resetsAt: pw.reset_at ? pw.reset_at * 1000 : undefined,
476
514
  });
477
515
  }
478
516
 
479
517
  if (data.rate_limit?.secondary_window) {
480
518
  const sw = data.rate_limit.secondary_window;
481
- const resetDate = sw.reset_at ? new Date(sw.reset_at * 1000) : undefined;
482
519
  const durationMs = typeof sw.limit_window_seconds === "number" ? sw.limit_window_seconds * 1000 : undefined;
483
520
  windows.push({
484
521
  label: getWindowLabel(durationMs, "Week"),
485
522
  usedPercent: clampPercent(sw.used_percent || 0),
486
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
523
+ resetsAt: sw.reset_at ? sw.reset_at * 1000 : undefined,
487
524
  });
488
525
  }
489
526
 
@@ -507,7 +544,7 @@ async function fetchGeminiUsage(): Promise<UsageSnapshot> {
507
544
  });
508
545
 
509
546
  if (!res.ok) {
510
- return { provider: "Gemini", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
547
+ return httpError("Gemini", res);
511
548
  }
512
549
 
513
550
  const data = (await res.json()) as any;
@@ -569,7 +606,7 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
569
606
  });
570
607
 
571
608
  if (!res.ok) {
572
- return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
609
+ return httpError(providerLabel, res);
573
610
  }
574
611
 
575
612
  const data = (await res.json()) as any;
@@ -608,7 +645,6 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
608
645
  const intervalRemaining = Number(textBucket.current_interval_remaining_percent);
609
646
  if (Number.isFinite(intervalRemaining)) {
610
647
  const usedPercent = clampPercent(100 - intervalRemaining);
611
- const resetDate = textBucket.end_time ? new Date(Number(textBucket.end_time)) : undefined;
612
648
  const durationMs =
613
649
  textBucket.start_time && textBucket.end_time
614
650
  ? Number(textBucket.end_time) - Number(textBucket.start_time)
@@ -616,16 +652,13 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
616
652
  windows.push({
617
653
  label: getWindowLabel(durationMs, "5h"),
618
654
  usedPercent,
619
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
655
+ resetsAt: textBucket.end_time ? Number(textBucket.end_time) : undefined,
620
656
  });
621
657
  }
622
658
 
623
659
  const weeklyRemaining = Number(textBucket.current_weekly_remaining_percent);
624
660
  if (Number.isFinite(weeklyRemaining)) {
625
661
  const usedPercent = clampPercent(100 - weeklyRemaining);
626
- const resetDate = textBucket.weekly_end_time
627
- ? new Date(Number(textBucket.weekly_end_time))
628
- : undefined;
629
662
  const durationMs =
630
663
  textBucket.weekly_start_time && textBucket.weekly_end_time
631
664
  ? Number(textBucket.weekly_end_time) - Number(textBucket.weekly_start_time)
@@ -633,7 +666,7 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
633
666
  windows.push({
634
667
  label: getWindowLabel(durationMs, "Week"),
635
668
  usedPercent,
636
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
669
+ resetsAt: textBucket.weekly_end_time ? Number(textBucket.weekly_end_time) : undefined,
637
670
  });
638
671
  }
639
672
 
@@ -664,7 +697,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
664
697
  });
665
698
 
666
699
  if (!res.ok) {
667
- return { provider: "Kimi Coding", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
700
+ return httpError("Kimi Coding", res);
668
701
  }
669
702
 
670
703
  const data = (await res.json()) as any;
@@ -676,7 +709,6 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
676
709
  if (windowLimit > 0) {
677
710
  const used = windowLimit - windowRemaining;
678
711
  const usedPercent = clampPercent((used / windowLimit) * 100);
679
- const resetDate = limit.detail?.resetTime ? new Date(limit.detail.resetTime) : undefined;
680
712
  const durationMs =
681
713
  limit.window?.duration && limit.window?.timeUnit === "TIME_UNIT_MINUTE"
682
714
  ? limit.window.duration * 60 * 1000
@@ -685,7 +717,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
685
717
  windows.push({
686
718
  label: getWindowLabel(durationMs, "5h"),
687
719
  usedPercent,
688
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
720
+ resetsAt: toTimestamp(limit.detail?.resetTime),
689
721
  });
690
722
  }
691
723
  }
@@ -700,7 +732,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
700
732
  windows.push({
701
733
  label: "Weekly",
702
734
  usedPercent,
703
- resetsIn: weeklyResetTime ? formatResetTime(new Date(weeklyResetTime)) : undefined,
735
+ resetsAt: toTimestamp(weeklyResetTime),
704
736
  });
705
737
  }
706
738
 
@@ -726,7 +758,7 @@ async function fetchOpencodeGoUsage(): Promise<UsageSnapshot> {
726
758
  });
727
759
 
728
760
  if (!res.ok) {
729
- return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
761
+ return httpError(providerLabel, res);
730
762
  }
731
763
 
732
764
  const data = (await res.json()) as any;
@@ -734,26 +766,26 @@ async function fetchOpencodeGoUsage(): Promise<UsageSnapshot> {
734
766
 
735
767
  if (data.rollingUsage) {
736
768
  const usedPercent = clampPercent(data.rollingUsage.usagePercent ?? 0);
737
- const resetsIn = Number.isFinite(data.rollingUsage.resetInSec)
738
- ? formatResetTime(new Date(Date.now() + data.rollingUsage.resetInSec * 1000))
769
+ const resetsAt = Number.isFinite(data.rollingUsage.resetInSec)
770
+ ? Date.now() + data.rollingUsage.resetInSec * 1000
739
771
  : undefined;
740
- windows.push({ label: "5h", usedPercent, resetsIn });
772
+ windows.push({ label: "5h", usedPercent, resetsAt });
741
773
  }
742
774
 
743
775
  if (data.weeklyUsage) {
744
776
  const usedPercent = clampPercent(data.weeklyUsage.usagePercent ?? 0);
745
- const resetsIn = Number.isFinite(data.weeklyUsage.resetInSec)
746
- ? formatResetTime(new Date(Date.now() + data.weeklyUsage.resetInSec * 1000))
777
+ const resetsAt = Number.isFinite(data.weeklyUsage.resetInSec)
778
+ ? Date.now() + data.weeklyUsage.resetInSec * 1000
747
779
  : undefined;
748
- windows.push({ label: "Week", usedPercent, resetsIn });
780
+ windows.push({ label: "Week", usedPercent, resetsAt });
749
781
  }
750
782
 
751
783
  if (data.monthlyUsage) {
752
784
  const usedPercent = clampPercent(data.monthlyUsage.usagePercent ?? 0);
753
- const resetsIn = Number.isFinite(data.monthlyUsage.resetInSec)
754
- ? formatResetTime(new Date(Date.now() + data.monthlyUsage.resetInSec * 1000))
785
+ const resetsAt = Number.isFinite(data.monthlyUsage.resetInSec)
786
+ ? Date.now() + data.monthlyUsage.resetInSec * 1000
755
787
  : undefined;
756
- windows.push({ label: "Month", usedPercent, resetsIn });
788
+ windows.push({ label: "Month", usedPercent, resetsAt });
757
789
  }
758
790
 
759
791
  return { provider: providerLabel, windows, fetchedAt: Date.now() };
@@ -780,6 +812,73 @@ function detectProvider(modelProvider: string): string | null {
780
812
  return PROVIDER_MAP[modelProvider] || null;
781
813
  }
782
814
 
815
+ function getCredential(provider: string): string | undefined {
816
+ switch (provider) {
817
+ case "claude":
818
+ return getClaudeToken();
819
+ case "codex": {
820
+ const creds = getCodexToken();
821
+ return creds ? `${creds.accountId ?? ""}:${creds.token}` : undefined;
822
+ }
823
+ case "copilot":
824
+ return getCopilotToken();
825
+ case "gemini":
826
+ return getGeminiToken();
827
+ case "minimax":
828
+ case "minimax-cn":
829
+ return getMinimaxToken(provider);
830
+ case "kimi-coding":
831
+ return getKimiToken();
832
+ case "opencode-go":
833
+ return getOpencodeToken();
834
+ default:
835
+ return undefined;
836
+ }
837
+ }
838
+
839
+ // ============ Shared Usage Cache ============
840
+
841
+ interface SharedUsage {
842
+ snapshot?: UsageSnapshot; // last successful result; failures never overwrite it
843
+ nextFetchAt: number;
844
+ }
845
+
846
+ /** One file per provider + account, so different logins never share numbers. */
847
+ function sharedUsagePath(provider: string, credential: string): string {
848
+ const account = createHash("sha256").update(credential).digest("hex").slice(0, 16);
849
+ return join(USAGE_CACHE_DIR, `${provider}-${account}.json`);
850
+ }
851
+
852
+ function readSharedUsage(path: string): SharedUsage | null {
853
+ try {
854
+ return JSON.parse(readFileSync(path, "utf-8"));
855
+ } catch {
856
+ return null;
857
+ }
858
+ }
859
+
860
+ function writeSharedUsage(path: string, data: SharedUsage): void {
861
+ try {
862
+ mkdirSync(USAGE_CACHE_DIR, { recursive: true });
863
+ // Write-then-rename so readers never see a half-written file.
864
+ const tmp = `${path}.${process.pid}.tmp`;
865
+ writeFileSync(tmp, JSON.stringify(data));
866
+ renameSync(tmp, path);
867
+ pruneSharedUsage();
868
+ } catch {}
869
+ }
870
+
871
+ /** Token rotation creates a new file per account; drop ones nobody touched in a day. */
872
+ function pruneSharedUsage(): void {
873
+ try {
874
+ const now = Date.now();
875
+ for (const name of readdirSync(USAGE_CACHE_DIR)) {
876
+ const file = join(USAGE_CACHE_DIR, name);
877
+ if (now - statSync(file).mtimeMs > USAGE_FILE_MAX_AGE) unlinkSync(file);
878
+ }
879
+ } catch {}
880
+ }
881
+
783
882
  async function fetchUsageForProvider(provider: string): Promise<UsageSnapshot> {
784
883
  switch (provider) {
785
884
  case "claude":
@@ -914,7 +1013,10 @@ export default function (pi: ExtensionAPI) {
914
1013
  const dim = (s: string) => theme.fg("dim", s);
915
1014
  const bar = renderUsageBar(window.usedPercent, Math.max(4, options?.barWidth ?? 10), theme);
916
1015
  const pct = dim(`${Math.round(window.usedPercent)}%`);
917
- const timeStr = options?.includeReset === false || !window.resetsIn ? "" : " " + dim(window.resetsIn);
1016
+ const timeStr =
1017
+ options?.includeReset === false || window.resetsAt === undefined
1018
+ ? ""
1019
+ : " " + dim(formatResetTime(new Date(window.resetsAt)));
918
1020
  return `${dim(window.label)} ${bar} ${pct}${timeStr}`;
919
1021
  }
920
1022
 
@@ -981,58 +1083,62 @@ export default function (pi: ExtensionAPI) {
981
1083
  if (refreshGitCache()) tuiRef?.requestRender();
982
1084
  }
983
1085
 
984
- /** Fetch usage for the active provider. Shows cached data immediately,
985
- * then fetches fresh in the background. Discards results if provider
986
- * changed while the fetch was in flight. */
987
- function fetchUsage(modelProvider: string): void {
988
- const provider = detectProvider(modelProvider);
989
- if (!provider) {
990
- activeProvider = null;
991
- latestUsage = null;
992
- stopRefreshTimer();
993
- tuiRef?.requestRender();
1086
+ function showSnapshot(provider: string, snapshot: UsageSnapshot | undefined): void {
1087
+ if (activeProvider !== provider) return;
1088
+ const fresh = snapshot && Date.now() - snapshot.fetchedAt < USAGE_MAX_STALE;
1089
+ latestUsage = fresh ? snapshot : null;
1090
+ tuiRef?.requestRender();
1091
+ }
1092
+
1093
+ /** Show the shared cached usage, and fetch only if it's due. Coordination is best-effort:
1094
+ * the fetcher pushes nextFetchAt forward first, so other instances skip while it works. */
1095
+ function syncUsage(provider: string): void {
1096
+ const credential = getCredential(provider);
1097
+ if (!credential) {
1098
+ showSnapshot(provider, undefined);
994
1099
  return;
995
1100
  }
996
1101
 
997
- activeProvider = provider;
1102
+ const path = sharedUsagePath(provider, credential);
1103
+ const shared = readSharedUsage(path);
1104
+ showSnapshot(provider, shared?.snapshot);
1105
+ if (shared && Date.now() < shared.nextFetchAt) return;
998
1106
 
999
- // Show cached data immediately if available
1000
- const cached = usageCache.get(provider);
1001
- if (cached && cached.windows.length > 0) {
1002
- latestUsage = cached;
1003
- tuiRef?.requestRender();
1004
- }
1107
+ writeSharedUsage(path, { snapshot: shared?.snapshot, nextFetchAt: Date.now() + USAGE_CLAIM });
1005
1108
 
1006
- // Fetch fresh in background — keep cached data on transient errors
1007
1109
  fetchUsageForProvider(provider)
1008
1110
  .then((u) => {
1009
- if (!u || activeProvider !== provider) return;
1010
- if (u.windows.length === 0 && u.error && cached?.windows.length) return;
1011
- usageCache.set(provider, u);
1012
- latestUsage = u;
1013
- tuiRef?.requestRender();
1111
+ const now = Date.now();
1112
+ const latest = readSharedUsage(path);
1113
+ const next: SharedUsage = u.error
1114
+ ? {
1115
+ snapshot: latest?.snapshot,
1116
+ // Never shorten a backoff another instance may have recorded meanwhile.
1117
+ nextFetchAt: Math.max(now + (u.retryAfterMs ?? USAGE_ERROR_RETRY), latest?.nextFetchAt ?? 0),
1118
+ }
1119
+ : { snapshot: u, nextFetchAt: now + USAGE_TTL };
1120
+ writeSharedUsage(path, next);
1121
+ showSnapshot(provider, next.snapshot);
1014
1122
  })
1015
1123
  .catch(() => {});
1016
1124
  }
1017
1125
 
1018
- /** Start (or restart) the periodic refresh timer. */
1126
+ function selectProvider(modelProvider: string): void {
1127
+ activeProvider = detectProvider(modelProvider);
1128
+ if (activeProvider) {
1129
+ syncUsage(activeProvider);
1130
+ } else {
1131
+ latestUsage = null;
1132
+ tuiRef?.requestRender();
1133
+ }
1134
+ }
1135
+
1136
+ /** Re-read the shared cache periodically; also keeps reset countdowns ticking. */
1019
1137
  function startRefreshTimer(): void {
1020
1138
  if (refreshTimer) clearInterval(refreshTimer);
1021
1139
  refreshTimer = setInterval(() => {
1022
- if (activeProvider) {
1023
- const provider = activeProvider;
1024
- const cached = usageCache.get(provider);
1025
- fetchUsageForProvider(provider)
1026
- .then((u) => {
1027
- if (!u || activeProvider !== provider) return;
1028
- if (u.windows.length === 0 && u.error && cached?.windows.length) return;
1029
- usageCache.set(provider, u);
1030
- latestUsage = u;
1031
- tuiRef?.requestRender();
1032
- })
1033
- .catch(() => {});
1034
- }
1035
- }, USAGE_REFRESH_INTERVAL);
1140
+ if (activeProvider) syncUsage(activeProvider);
1141
+ }, USAGE_CHECK_INTERVAL);
1036
1142
  }
1037
1143
 
1038
1144
  function stopRefreshTimer(): void {
@@ -1056,10 +1162,8 @@ export default function (pi: ExtensionAPI) {
1056
1162
 
1057
1163
  // Initial fetch inside factory — tui is guaranteed available here,
1058
1164
  // so requestRender() will work when the async fetch completes.
1059
- if (ctx.model?.provider) {
1060
- fetchUsage(ctx.model.provider);
1061
- startRefreshTimer();
1062
- }
1165
+ if (ctx.model?.provider) selectProvider(ctx.model.provider);
1166
+ startRefreshTimer();
1063
1167
 
1064
1168
  return {
1065
1169
  dispose: () => {
@@ -1156,10 +1260,8 @@ export default function (pi: ExtensionAPI) {
1156
1260
  refreshGitFooter();
1157
1261
  });
1158
1262
 
1159
- // Refresh when model changes — fetch immediately, restart timer
1160
1263
  pi.on("model_select", (event, _ctx) => {
1161
1264
  if (!event.model?.provider) return;
1162
- fetchUsage(event.model.provider);
1163
- startRefreshTimer(); // reset the 5min countdown since we just fetched
1265
+ selectProvider(event.model.provider);
1164
1266
  });
1165
1267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogulcancelik/pi-minimal-footer",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Minimal footer for pi with context gauge and subscription usage bars",
5
5
  "type": "module",
6
6
  "keywords": [