@ogulcancelik/pi-minimal-footer 0.1.13 → 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 +4 -8
  2. package/index.ts +197 -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
 
@@ -71,5 +67,5 @@ Anthropic's OAuth usage endpoint (`/api/oauth/usage`) rate-limits requests by `U
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> {
@@ -360,7 +398,7 @@ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
360
398
  });
361
399
 
362
400
  if (!res.ok) {
363
- return { provider: "Claude", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
401
+ return httpError("Claude", res);
364
402
  }
365
403
 
366
404
  const data = (await res.json()) as any;
@@ -369,16 +407,16 @@ async function fetchClaudeUsage(): Promise<UsageSnapshot> {
369
407
  if (data.five_hour?.utilization !== undefined) {
370
408
  windows.push({
371
409
  label: "5h",
372
- usedPercent: normalizePercent(data.five_hour.utilization),
373
- 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),
374
412
  });
375
413
  }
376
414
 
377
415
  if (data.seven_day?.utilization !== undefined) {
378
416
  windows.push({
379
417
  label: "Week",
380
- usedPercent: normalizePercent(data.seven_day.utilization),
381
- 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),
382
420
  });
383
421
  }
384
422
 
@@ -406,19 +444,18 @@ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
406
444
  });
407
445
 
408
446
  if (!res.ok) {
409
- return { provider: "Copilot", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
447
+ return httpError("Copilot", res);
410
448
  }
411
449
 
412
450
  const data = (await res.json()) as any;
413
451
  const windows: RateWindow[] = [];
414
452
 
415
- const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
416
- const resetsIn = resetDate ? formatResetTime(resetDate) : undefined;
453
+ const resetsAt = toTimestamp(data.quota_reset_date_utc);
417
454
 
418
455
  if (data.quota_snapshots?.premium_interactions) {
419
456
  const pi = data.quota_snapshots.premium_interactions;
420
457
  const usedPercent = clampPercent(100 - (pi.percent_remaining || 0));
421
- windows.push({ label: "Premium", usedPercent, resetsIn });
458
+ windows.push({ label: "Premium", usedPercent, resetsAt });
422
459
  }
423
460
 
424
461
  if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) {
@@ -426,7 +463,7 @@ async function fetchCopilotUsage(): Promise<UsageSnapshot> {
426
463
  windows.push({
427
464
  label: "Chat",
428
465
  usedPercent: clampPercent(100 - (chat.percent_remaining || 0)),
429
- resetsIn,
466
+ resetsAt,
430
467
  });
431
468
  }
432
469
 
@@ -461,7 +498,7 @@ async function fetchCodexUsage(): Promise<UsageSnapshot> {
461
498
  });
462
499
 
463
500
  if (!res.ok) {
464
- return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
501
+ return httpError(providerLabel, res);
465
502
  }
466
503
 
467
504
  const data = (await res.json()) as any;
@@ -469,23 +506,21 @@ async function fetchCodexUsage(): Promise<UsageSnapshot> {
469
506
 
470
507
  if (data.rate_limit?.primary_window) {
471
508
  const pw = data.rate_limit.primary_window;
472
- const resetDate = pw.reset_at ? new Date(pw.reset_at * 1000) : undefined;
473
509
  const durationMs = typeof pw.limit_window_seconds === "number" ? pw.limit_window_seconds * 1000 : undefined;
474
510
  windows.push({
475
511
  label: getWindowLabel(durationMs, "5h"),
476
512
  usedPercent: clampPercent(pw.used_percent || 0),
477
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
513
+ resetsAt: pw.reset_at ? pw.reset_at * 1000 : undefined,
478
514
  });
479
515
  }
480
516
 
481
517
  if (data.rate_limit?.secondary_window) {
482
518
  const sw = data.rate_limit.secondary_window;
483
- const resetDate = sw.reset_at ? new Date(sw.reset_at * 1000) : undefined;
484
519
  const durationMs = typeof sw.limit_window_seconds === "number" ? sw.limit_window_seconds * 1000 : undefined;
485
520
  windows.push({
486
521
  label: getWindowLabel(durationMs, "Week"),
487
522
  usedPercent: clampPercent(sw.used_percent || 0),
488
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
523
+ resetsAt: sw.reset_at ? sw.reset_at * 1000 : undefined,
489
524
  });
490
525
  }
491
526
 
@@ -509,7 +544,7 @@ async function fetchGeminiUsage(): Promise<UsageSnapshot> {
509
544
  });
510
545
 
511
546
  if (!res.ok) {
512
- return { provider: "Gemini", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
547
+ return httpError("Gemini", res);
513
548
  }
514
549
 
515
550
  const data = (await res.json()) as any;
@@ -571,7 +606,7 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
571
606
  });
572
607
 
573
608
  if (!res.ok) {
574
- return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
609
+ return httpError(providerLabel, res);
575
610
  }
576
611
 
577
612
  const data = (await res.json()) as any;
@@ -610,7 +645,6 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
610
645
  const intervalRemaining = Number(textBucket.current_interval_remaining_percent);
611
646
  if (Number.isFinite(intervalRemaining)) {
612
647
  const usedPercent = clampPercent(100 - intervalRemaining);
613
- const resetDate = textBucket.end_time ? new Date(Number(textBucket.end_time)) : undefined;
614
648
  const durationMs =
615
649
  textBucket.start_time && textBucket.end_time
616
650
  ? Number(textBucket.end_time) - Number(textBucket.start_time)
@@ -618,16 +652,13 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
618
652
  windows.push({
619
653
  label: getWindowLabel(durationMs, "5h"),
620
654
  usedPercent,
621
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
655
+ resetsAt: textBucket.end_time ? Number(textBucket.end_time) : undefined,
622
656
  });
623
657
  }
624
658
 
625
659
  const weeklyRemaining = Number(textBucket.current_weekly_remaining_percent);
626
660
  if (Number.isFinite(weeklyRemaining)) {
627
661
  const usedPercent = clampPercent(100 - weeklyRemaining);
628
- const resetDate = textBucket.weekly_end_time
629
- ? new Date(Number(textBucket.weekly_end_time))
630
- : undefined;
631
662
  const durationMs =
632
663
  textBucket.weekly_start_time && textBucket.weekly_end_time
633
664
  ? Number(textBucket.weekly_end_time) - Number(textBucket.weekly_start_time)
@@ -635,7 +666,7 @@ async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<Us
635
666
  windows.push({
636
667
  label: getWindowLabel(durationMs, "Week"),
637
668
  usedPercent,
638
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
669
+ resetsAt: textBucket.weekly_end_time ? Number(textBucket.weekly_end_time) : undefined,
639
670
  });
640
671
  }
641
672
 
@@ -666,7 +697,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
666
697
  });
667
698
 
668
699
  if (!res.ok) {
669
- return { provider: "Kimi Coding", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
700
+ return httpError("Kimi Coding", res);
670
701
  }
671
702
 
672
703
  const data = (await res.json()) as any;
@@ -678,7 +709,6 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
678
709
  if (windowLimit > 0) {
679
710
  const used = windowLimit - windowRemaining;
680
711
  const usedPercent = clampPercent((used / windowLimit) * 100);
681
- const resetDate = limit.detail?.resetTime ? new Date(limit.detail.resetTime) : undefined;
682
712
  const durationMs =
683
713
  limit.window?.duration && limit.window?.timeUnit === "TIME_UNIT_MINUTE"
684
714
  ? limit.window.duration * 60 * 1000
@@ -687,7 +717,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
687
717
  windows.push({
688
718
  label: getWindowLabel(durationMs, "5h"),
689
719
  usedPercent,
690
- resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
720
+ resetsAt: toTimestamp(limit.detail?.resetTime),
691
721
  });
692
722
  }
693
723
  }
@@ -702,7 +732,7 @@ async function fetchKimiUsage(): Promise<UsageSnapshot> {
702
732
  windows.push({
703
733
  label: "Weekly",
704
734
  usedPercent,
705
- resetsIn: weeklyResetTime ? formatResetTime(new Date(weeklyResetTime)) : undefined,
735
+ resetsAt: toTimestamp(weeklyResetTime),
706
736
  });
707
737
  }
708
738
 
@@ -728,7 +758,7 @@ async function fetchOpencodeGoUsage(): Promise<UsageSnapshot> {
728
758
  });
729
759
 
730
760
  if (!res.ok) {
731
- return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
761
+ return httpError(providerLabel, res);
732
762
  }
733
763
 
734
764
  const data = (await res.json()) as any;
@@ -736,26 +766,26 @@ async function fetchOpencodeGoUsage(): Promise<UsageSnapshot> {
736
766
 
737
767
  if (data.rollingUsage) {
738
768
  const usedPercent = clampPercent(data.rollingUsage.usagePercent ?? 0);
739
- const resetsIn = Number.isFinite(data.rollingUsage.resetInSec)
740
- ? formatResetTime(new Date(Date.now() + data.rollingUsage.resetInSec * 1000))
769
+ const resetsAt = Number.isFinite(data.rollingUsage.resetInSec)
770
+ ? Date.now() + data.rollingUsage.resetInSec * 1000
741
771
  : undefined;
742
- windows.push({ label: "5h", usedPercent, resetsIn });
772
+ windows.push({ label: "5h", usedPercent, resetsAt });
743
773
  }
744
774
 
745
775
  if (data.weeklyUsage) {
746
776
  const usedPercent = clampPercent(data.weeklyUsage.usagePercent ?? 0);
747
- const resetsIn = Number.isFinite(data.weeklyUsage.resetInSec)
748
- ? formatResetTime(new Date(Date.now() + data.weeklyUsage.resetInSec * 1000))
777
+ const resetsAt = Number.isFinite(data.weeklyUsage.resetInSec)
778
+ ? Date.now() + data.weeklyUsage.resetInSec * 1000
749
779
  : undefined;
750
- windows.push({ label: "Week", usedPercent, resetsIn });
780
+ windows.push({ label: "Week", usedPercent, resetsAt });
751
781
  }
752
782
 
753
783
  if (data.monthlyUsage) {
754
784
  const usedPercent = clampPercent(data.monthlyUsage.usagePercent ?? 0);
755
- const resetsIn = Number.isFinite(data.monthlyUsage.resetInSec)
756
- ? formatResetTime(new Date(Date.now() + data.monthlyUsage.resetInSec * 1000))
785
+ const resetsAt = Number.isFinite(data.monthlyUsage.resetInSec)
786
+ ? Date.now() + data.monthlyUsage.resetInSec * 1000
757
787
  : undefined;
758
- windows.push({ label: "Month", usedPercent, resetsIn });
788
+ windows.push({ label: "Month", usedPercent, resetsAt });
759
789
  }
760
790
 
761
791
  return { provider: providerLabel, windows, fetchedAt: Date.now() };
@@ -782,6 +812,73 @@ function detectProvider(modelProvider: string): string | null {
782
812
  return PROVIDER_MAP[modelProvider] || null;
783
813
  }
784
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
+
785
882
  async function fetchUsageForProvider(provider: string): Promise<UsageSnapshot> {
786
883
  switch (provider) {
787
884
  case "claude":
@@ -916,7 +1013,10 @@ export default function (pi: ExtensionAPI) {
916
1013
  const dim = (s: string) => theme.fg("dim", s);
917
1014
  const bar = renderUsageBar(window.usedPercent, Math.max(4, options?.barWidth ?? 10), theme);
918
1015
  const pct = dim(`${Math.round(window.usedPercent)}%`);
919
- 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)));
920
1020
  return `${dim(window.label)} ${bar} ${pct}${timeStr}`;
921
1021
  }
922
1022
 
@@ -983,58 +1083,62 @@ export default function (pi: ExtensionAPI) {
983
1083
  if (refreshGitCache()) tuiRef?.requestRender();
984
1084
  }
985
1085
 
986
- /** Fetch usage for the active provider. Shows cached data immediately,
987
- * then fetches fresh in the background. Discards results if provider
988
- * changed while the fetch was in flight. */
989
- function fetchUsage(modelProvider: string): void {
990
- const provider = detectProvider(modelProvider);
991
- if (!provider) {
992
- activeProvider = null;
993
- latestUsage = null;
994
- stopRefreshTimer();
995
- 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);
996
1099
  return;
997
1100
  }
998
1101
 
999
- 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;
1000
1106
 
1001
- // Show cached data immediately if available
1002
- const cached = usageCache.get(provider);
1003
- if (cached && cached.windows.length > 0) {
1004
- latestUsage = cached;
1005
- tuiRef?.requestRender();
1006
- }
1107
+ writeSharedUsage(path, { snapshot: shared?.snapshot, nextFetchAt: Date.now() + USAGE_CLAIM });
1007
1108
 
1008
- // Fetch fresh in background — keep cached data on transient errors
1009
1109
  fetchUsageForProvider(provider)
1010
1110
  .then((u) => {
1011
- if (!u || activeProvider !== provider) return;
1012
- if (u.windows.length === 0 && u.error && cached?.windows.length) return;
1013
- usageCache.set(provider, u);
1014
- latestUsage = u;
1015
- 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);
1016
1122
  })
1017
1123
  .catch(() => {});
1018
1124
  }
1019
1125
 
1020
- /** 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. */
1021
1137
  function startRefreshTimer(): void {
1022
1138
  if (refreshTimer) clearInterval(refreshTimer);
1023
1139
  refreshTimer = setInterval(() => {
1024
- if (activeProvider) {
1025
- const provider = activeProvider;
1026
- const cached = usageCache.get(provider);
1027
- fetchUsageForProvider(provider)
1028
- .then((u) => {
1029
- if (!u || activeProvider !== provider) return;
1030
- if (u.windows.length === 0 && u.error && cached?.windows.length) return;
1031
- usageCache.set(provider, u);
1032
- latestUsage = u;
1033
- tuiRef?.requestRender();
1034
- })
1035
- .catch(() => {});
1036
- }
1037
- }, USAGE_REFRESH_INTERVAL);
1140
+ if (activeProvider) syncUsage(activeProvider);
1141
+ }, USAGE_CHECK_INTERVAL);
1038
1142
  }
1039
1143
 
1040
1144
  function stopRefreshTimer(): void {
@@ -1058,10 +1162,8 @@ export default function (pi: ExtensionAPI) {
1058
1162
 
1059
1163
  // Initial fetch inside factory — tui is guaranteed available here,
1060
1164
  // so requestRender() will work when the async fetch completes.
1061
- if (ctx.model?.provider) {
1062
- fetchUsage(ctx.model.provider);
1063
- startRefreshTimer();
1064
- }
1165
+ if (ctx.model?.provider) selectProvider(ctx.model.provider);
1166
+ startRefreshTimer();
1065
1167
 
1066
1168
  return {
1067
1169
  dispose: () => {
@@ -1158,10 +1260,8 @@ export default function (pi: ExtensionAPI) {
1158
1260
  refreshGitFooter();
1159
1261
  });
1160
1262
 
1161
- // Refresh when model changes — fetch immediately, restart timer
1162
1263
  pi.on("model_select", (event, _ctx) => {
1163
1264
  if (!event.model?.provider) return;
1164
- fetchUsage(event.model.provider);
1165
- startRefreshTimer(); // reset the 5min countdown since we just fetched
1265
+ selectProvider(event.model.provider);
1166
1266
  });
1167
1267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogulcancelik/pi-minimal-footer",
3
- "version": "0.1.13",
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": [