@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.
- package/README.md +4 -8
- package/index.ts +197 -97
- 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
|
-
- **
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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(
|
|
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
|
|
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:
|
|
373
|
-
|
|
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:
|
|
381
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
740
|
-
?
|
|
769
|
+
const resetsAt = Number.isFinite(data.rollingUsage.resetInSec)
|
|
770
|
+
? Date.now() + data.rollingUsage.resetInSec * 1000
|
|
741
771
|
: undefined;
|
|
742
|
-
windows.push({ label: "5h", usedPercent,
|
|
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
|
|
748
|
-
?
|
|
777
|
+
const resetsAt = Number.isFinite(data.weeklyUsage.resetInSec)
|
|
778
|
+
? Date.now() + data.weeklyUsage.resetInSec * 1000
|
|
749
779
|
: undefined;
|
|
750
|
-
windows.push({ label: "Week", usedPercent,
|
|
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
|
|
756
|
-
?
|
|
785
|
+
const resetsAt = Number.isFinite(data.monthlyUsage.resetInSec)
|
|
786
|
+
? Date.now() + data.monthlyUsage.resetInSec * 1000
|
|
757
787
|
: undefined;
|
|
758
|
-
windows.push({ label: "Month", usedPercent,
|
|
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 =
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1165
|
-
startRefreshTimer(); // reset the 5min countdown since we just fetched
|
|
1265
|
+
selectProvider(event.model.provider);
|
|
1166
1266
|
});
|
|
1167
1267
|
}
|