bermudis-pi-goodies 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/fixed-defaults.ts +56 -3
- package/package.json +1 -1
- package/provider-balance.ts +559 -71
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ extensions. One entry point, ten independent features.
|
|
|
21
21
|
After publishing the package to npm:
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
pi install npm:bermudis-pi-goodies@0.
|
|
24
|
+
pi install npm:bermudis-pi-goodies@0.5.0
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
|
|
@@ -79,6 +79,9 @@ manually create or edit a pin for B while settings still name A, the current
|
|
|
79
79
|
session remains on A and B starts with the next fresh session. Resuming an
|
|
80
80
|
existing session restores that session's model instead.
|
|
81
81
|
|
|
82
|
+
`/new` keeps the model from the session you were just in, rather than switching
|
|
83
|
+
to the pinned default. The pin still applies to a fresh `pi` launch.
|
|
84
|
+
|
|
82
85
|
Older config files may contain `thinkingLevel`; that field is accepted for
|
|
83
86
|
compatibility but ignored and should be managed in `model-thinking.json` instead.
|
|
84
87
|
`fixed-defaults` logs a warning and shows the migration in its status when it
|
package/fixed-defaults.ts
CHANGED
|
@@ -13,6 +13,13 @@ import {
|
|
|
13
13
|
} from "./json-file.ts";
|
|
14
14
|
|
|
15
15
|
const CONFIG_FILENAME = "fixed-defaults.json";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Model active in the session being replaced by `/new`, captured during
|
|
19
|
+
* `session_before_switch`. The factory is re-invoked per session, so this must
|
|
20
|
+
* live at module scope to survive the switch to the new extension instance.
|
|
21
|
+
*/
|
|
22
|
+
let previousModelForNewSession: { provider: string; id: string } | null = null;
|
|
16
23
|
/**
|
|
17
24
|
* Values read from the override file. `provider` and `model` are a coupled
|
|
18
25
|
* pair — a model id is meaningless without its provider, so both must be
|
|
@@ -213,7 +220,7 @@ export default function fixedDefaults(
|
|
|
213
220
|
}
|
|
214
221
|
}
|
|
215
222
|
|
|
216
|
-
async function
|
|
223
|
+
async function restorePinnedModel(ctx: ExtensionContext): Promise<void> {
|
|
217
224
|
const { override, error } = store.load();
|
|
218
225
|
// A broken or absent override means no pin: leave settings untouched so
|
|
219
226
|
// Pi's native last-selection behavior is preserved rather than guessed at.
|
|
@@ -225,6 +232,24 @@ export default function fixedDefaults(
|
|
|
225
232
|
await persistModel(ctx, override.provider!, override.model!);
|
|
226
233
|
}
|
|
227
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Restore the model that was active before `/new`. Unlike the pin, this only
|
|
237
|
+
* applies to the session being created, so the next fresh `pi` still starts
|
|
238
|
+
* from the pin.
|
|
239
|
+
*/
|
|
240
|
+
async function restorePreviousModel(
|
|
241
|
+
ctx: ExtensionContext,
|
|
242
|
+
previous: { provider: string; id: string } | null,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
if (!previous) return;
|
|
245
|
+
const model = ctx.modelRegistry.find(previous.provider, previous.id);
|
|
246
|
+
if (!model) return;
|
|
247
|
+
// setModel writes the restored model to settings.json, then its model_select
|
|
248
|
+
// notification re-applies the pin afterwards (see restorePinnedModel), so
|
|
249
|
+
// the active session keeps the previous model while the pin survives.
|
|
250
|
+
await pi.setModel(model);
|
|
251
|
+
}
|
|
252
|
+
|
|
228
253
|
function enqueue<T>(
|
|
229
254
|
operation: () => Promise<T>,
|
|
230
255
|
failureMessage: string,
|
|
@@ -246,12 +271,40 @@ export default function fixedDefaults(
|
|
|
246
271
|
ctx: ExtensionContext,
|
|
247
272
|
failureMessage = "[fixed-defaults] failed to restore defaults:",
|
|
248
273
|
): Promise<void> {
|
|
249
|
-
return enqueue(() =>
|
|
274
|
+
return enqueue(() => restorePinnedModel(ctx), failureMessage);
|
|
250
275
|
}
|
|
251
276
|
|
|
252
|
-
pi.on("session_start", (
|
|
277
|
+
pi.on("session_start", (event, ctx) => {
|
|
278
|
+
// `/new` has already selected a model (the pinned default) by the time this
|
|
279
|
+
// fires, so set the model that was active before the switch here. The
|
|
280
|
+
// resulting model_select notification re-applies the pin in settings.json.
|
|
281
|
+
if (event.reason === "new") {
|
|
282
|
+
const previous = previousModelForNewSession;
|
|
283
|
+
previousModelForNewSession = null;
|
|
284
|
+
if (!previous) return;
|
|
285
|
+
// Run outside the shared queue: pi.setModel emits model_select, whose
|
|
286
|
+
// handler enqueues the pin restore. Enqueuing this operation too would
|
|
287
|
+
// deadlock that handler by making it wait on this operation to finish.
|
|
288
|
+
return restorePreviousModel(ctx, previous).catch((error: unknown) => {
|
|
289
|
+
console.error(
|
|
290
|
+
"[fixed-defaults] failed to restore the previous session model after /new:",
|
|
291
|
+
error,
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return schedule(ctx);
|
|
296
|
+
});
|
|
253
297
|
pi.on("model_select", (_event, ctx) => schedule(ctx));
|
|
254
298
|
|
|
299
|
+
pi.on("session_before_switch", (event, ctx) => {
|
|
300
|
+
if (event.reason === "new") {
|
|
301
|
+
const model = ctx.model;
|
|
302
|
+
previousModelForNewSession = model
|
|
303
|
+
? { provider: model.provider, id: model.id }
|
|
304
|
+
: null;
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
255
308
|
pi.registerCommand("fixed-defaults", {
|
|
256
309
|
description: "Show, set, or reset the pinned startup model",
|
|
257
310
|
handler: async (args, ctx) => {
|
package/package.json
CHANGED
package/provider-balance.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
-
import { FooterComponent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { FooterComponent, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type {
|
|
4
4
|
ExtensionAPI,
|
|
5
5
|
ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
closeSync,
|
|
12
|
+
constants as fsConstants,
|
|
13
|
+
fstatSync,
|
|
14
|
+
lstatSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
openSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
rmdirSync,
|
|
20
|
+
unlinkSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { writeJsonFileAtomic } from "./json-file.ts";
|
|
10
24
|
|
|
11
25
|
const KILO_API_BASE = process.env.KILO_API_URL || "https://api.kilo.ai";
|
|
12
26
|
const KILO_BALANCE_ENDPOINT = `${KILO_API_BASE}/api/profile/balance`;
|
|
@@ -22,12 +36,19 @@ const CODEX_API_BASE = (
|
|
|
22
36
|
const CODEX_USAGE_ENDPOINT = `${CODEX_API_BASE}/wham/usage`;
|
|
23
37
|
const CODEX_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
24
38
|
const BALANCE_FETCH_TIMEOUT_MS = 5_000;
|
|
39
|
+
/**
|
|
40
|
+
* While Pi is waiting for input, periodically adopt another session's fresh
|
|
41
|
+
* cache entry or fetch one ourselves. Jitter prevents a row of idle Pi
|
|
42
|
+
* processes from hitting the provider at exactly the same instant.
|
|
43
|
+
*/
|
|
44
|
+
const IDLE_REFRESH_INTERVAL_MS = 60_000;
|
|
45
|
+
const IDLE_REFRESH_JITTER_MS = 15_000;
|
|
25
46
|
/** Refresh the footer balance every Nth turn end during a run. See turn_end handler. */
|
|
26
47
|
const REFRESH_EVERY_N_TURNS = 5;
|
|
27
48
|
|
|
28
49
|
/**
|
|
29
50
|
* Balance cache shared across every pi process on the machine, keyed by
|
|
30
|
-
* provider. Two motivations:
|
|
51
|
+
* provider and a one-way credential fingerprint. Two motivations:
|
|
31
52
|
*
|
|
32
53
|
* 1. The user runs several pi instances against the same metered account
|
|
33
54
|
* (quota/credits are per-account, not per-session), so every session may
|
|
@@ -38,11 +59,15 @@ const REFRESH_EVERY_N_TURNS = 5;
|
|
|
38
59
|
* session's footer is blank/stale until its own first fetch lands, which
|
|
39
60
|
* can be agent_settled or the 5th turn_end.
|
|
40
61
|
*/
|
|
41
|
-
const
|
|
62
|
+
const BALANCE_CACHE_DIR = join(getAgentDir(), "cache", "provider-balances");
|
|
42
63
|
/** Ignore cache entries older than this; stale balances mislead. */
|
|
43
64
|
const BALANCE_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
65
|
+
/** Keep abandoned accounts and crash leftovers from growing without bound. */
|
|
66
|
+
const BALANCE_CACHE_MAX_ENTRIES = 256;
|
|
67
|
+
const BALANCE_CACHE_MAX_BYTES = 1_000_000;
|
|
68
|
+
const BALANCE_CACHE_ACCOUNT_DIR_PATTERN = /^[a-f0-9]{64}$/;
|
|
44
69
|
|
|
45
|
-
interface BalanceAdapter {
|
|
70
|
+
export interface BalanceAdapter {
|
|
46
71
|
fetch(token: string, signal: AbortSignal): Promise<Balance>;
|
|
47
72
|
requiresOAuth?: boolean;
|
|
48
73
|
}
|
|
@@ -564,7 +589,32 @@ interface BalanceCacheEntry {
|
|
|
564
589
|
balance: Balance;
|
|
565
590
|
}
|
|
566
591
|
|
|
567
|
-
|
|
592
|
+
/**
|
|
593
|
+
* Keep accounts isolated without persisting the credential itself. Sessions
|
|
594
|
+
* using the same credential get the same cache key and can share a reading.
|
|
595
|
+
*/
|
|
596
|
+
export function balanceCacheKey(provider: string, token: string): string {
|
|
597
|
+
// Codex access tokens rotate, but their account ID is stable. Other
|
|
598
|
+
// providers expose no account identifier here, so the token is the best
|
|
599
|
+
// available identity. Include the endpoint because custom backends can use
|
|
600
|
+
// overlapping account IDs while reporting unrelated balances.
|
|
601
|
+
const identity =
|
|
602
|
+
provider === "openai-codex" ? (parseCodexAccountId(token) ?? token) : token;
|
|
603
|
+
const endpoint =
|
|
604
|
+
provider === "kilo"
|
|
605
|
+
? KILO_BALANCE_ENDPOINT
|
|
606
|
+
: provider === "openai-codex"
|
|
607
|
+
? CODEX_USAGE_ENDPOINT
|
|
608
|
+
: provider === "openrouter"
|
|
609
|
+
? OPENROUTER_CREDITS_ENDPOINT
|
|
610
|
+
: provider === "zai-coding-cn"
|
|
611
|
+
? ZAI_CODING_CN_QUOTA_ENDPOINT
|
|
612
|
+
: provider === "zai"
|
|
613
|
+
? ZAI_QUOTA_ENDPOINT
|
|
614
|
+
: provider;
|
|
615
|
+
const fingerprint = createHash("sha256").update(identity).digest("hex");
|
|
616
|
+
return `v2:${provider}:${endpoint}:${fingerprint}`;
|
|
617
|
+
}
|
|
568
618
|
|
|
569
619
|
function parseCachedBalance(value: unknown): Balance | null {
|
|
570
620
|
if (!Array.isArray(value)) return null;
|
|
@@ -601,54 +651,259 @@ function parseCachedBalance(value: unknown): Balance | null {
|
|
|
601
651
|
return segments;
|
|
602
652
|
}
|
|
603
653
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
654
|
+
function balanceCacheAccountDir(cacheKey: string, cacheDir: string): string {
|
|
655
|
+
// Hash the already one-way key again so neither account identifiers nor
|
|
656
|
+
// credential fingerprints are exposed in directory listings.
|
|
657
|
+
const directory = createHash("sha256").update(cacheKey).digest("hex");
|
|
658
|
+
return join(cacheDir, directory);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function readRegularJsonFile(path: string): string | null {
|
|
662
|
+
let fd: number | undefined;
|
|
610
663
|
try {
|
|
611
|
-
|
|
664
|
+
const link = lstatSync(path);
|
|
665
|
+
if (!link.isFile()) return null;
|
|
666
|
+
fd = openSync(
|
|
667
|
+
path,
|
|
668
|
+
fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK,
|
|
669
|
+
);
|
|
670
|
+
if (!fstatSync(fd).isFile()) return null;
|
|
671
|
+
return readFileSync(fd, "utf8");
|
|
612
672
|
} catch {
|
|
613
|
-
return null;
|
|
673
|
+
return null;
|
|
674
|
+
} finally {
|
|
675
|
+
if (fd !== undefined) {
|
|
676
|
+
try {
|
|
677
|
+
closeSync(fd);
|
|
678
|
+
} catch {
|
|
679
|
+
// The descriptor is already unusable; there is nothing useful to do.
|
|
680
|
+
}
|
|
681
|
+
}
|
|
614
682
|
}
|
|
683
|
+
}
|
|
615
684
|
|
|
685
|
+
function isSafeDirectory(path: string): boolean {
|
|
616
686
|
try {
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
if (entry === null || fetchedAt === null) return null;
|
|
620
|
-
if (nowMs - fetchedAt >= BALANCE_CACHE_TTL_MS) return null;
|
|
621
|
-
return parseCachedBalance(entry.balance);
|
|
687
|
+
const directory = lstatSync(path);
|
|
688
|
+
return directory.isDirectory() && !directory.isSymbolicLink();
|
|
622
689
|
} catch {
|
|
623
|
-
return
|
|
690
|
+
return false;
|
|
624
691
|
}
|
|
625
692
|
}
|
|
626
693
|
|
|
694
|
+
function compareCachePaths(
|
|
695
|
+
left: { fetchedAt: number; path: string },
|
|
696
|
+
right: { fetchedAt: number; path: string },
|
|
697
|
+
): number {
|
|
698
|
+
return (
|
|
699
|
+
left.fetchedAt - right.fetchedAt || left.path.localeCompare(right.path)
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
interface CacheObservation {
|
|
704
|
+
accountDir: string;
|
|
705
|
+
filename: string;
|
|
706
|
+
path: string;
|
|
707
|
+
fetchedAt: number;
|
|
708
|
+
bytes: number;
|
|
709
|
+
}
|
|
710
|
+
|
|
627
711
|
/**
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
712
|
+
* Remove stale, malformed, and excess observations across every account. This
|
|
713
|
+
* is deliberately best effort: cache maintenance must never hide a provider
|
|
714
|
+
* response or make the footer fail.
|
|
631
715
|
*/
|
|
632
|
-
function
|
|
633
|
-
|
|
716
|
+
function cleanupBalanceCache(
|
|
717
|
+
cacheDir: string,
|
|
718
|
+
nowMs = Date.now(),
|
|
719
|
+
protectedPath?: string,
|
|
720
|
+
): void {
|
|
634
721
|
try {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
722
|
+
if (!isSafeDirectory(cacheDir)) return;
|
|
723
|
+
const observations: CacheObservation[] = [];
|
|
724
|
+
|
|
725
|
+
for (const accountName of readdirSync(cacheDir)) {
|
|
726
|
+
const accountDir = join(cacheDir, accountName);
|
|
727
|
+
if (
|
|
728
|
+
!BALANCE_CACHE_ACCOUNT_DIR_PATTERN.test(accountName) ||
|
|
729
|
+
!isSafeDirectory(accountDir)
|
|
730
|
+
) {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
for (const filename of readdirSync(accountDir)) {
|
|
734
|
+
const path = join(accountDir, filename);
|
|
735
|
+
let file;
|
|
736
|
+
try {
|
|
737
|
+
file = lstatSync(path);
|
|
738
|
+
} catch {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (file.isSymbolicLink() || !file.isFile()) continue;
|
|
742
|
+
|
|
743
|
+
// Atomic-write leftovers and unexpected files are safe to discard.
|
|
744
|
+
if (!filename.endsWith(".json")) {
|
|
745
|
+
try {
|
|
746
|
+
unlinkSync(path);
|
|
747
|
+
} catch {
|
|
748
|
+
// Best effort.
|
|
749
|
+
}
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
try {
|
|
754
|
+
const raw = readRegularJsonFile(path);
|
|
755
|
+
const entry = raw === null ? null : asRecord(JSON.parse(raw));
|
|
756
|
+
const fetchedAt = numericProperty(entry, "fetchedAt");
|
|
757
|
+
const balance = parseCachedBalance(entry?.balance);
|
|
758
|
+
if (
|
|
759
|
+
fetchedAt === null ||
|
|
760
|
+
balance === null ||
|
|
761
|
+
balance.length === 0 ||
|
|
762
|
+
nowMs - fetchedAt >= BALANCE_CACHE_TTL_MS
|
|
763
|
+
) {
|
|
764
|
+
unlinkSync(path);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
observations.push({
|
|
768
|
+
accountDir,
|
|
769
|
+
filename,
|
|
770
|
+
path,
|
|
771
|
+
fetchedAt,
|
|
772
|
+
bytes: file.size,
|
|
773
|
+
});
|
|
774
|
+
} catch {
|
|
775
|
+
try {
|
|
776
|
+
unlinkSync(path);
|
|
777
|
+
} catch {
|
|
778
|
+
// Best effort.
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
observations.sort(compareCachePaths);
|
|
785
|
+
let bytes = observations.reduce((total, entry) => total + entry.bytes, 0);
|
|
786
|
+
let index = 0;
|
|
787
|
+
while (
|
|
788
|
+
index < observations.length &&
|
|
789
|
+
(observations.length > BALANCE_CACHE_MAX_ENTRIES ||
|
|
790
|
+
bytes > BALANCE_CACHE_MAX_BYTES)
|
|
791
|
+
) {
|
|
792
|
+
const entry = observations[index];
|
|
793
|
+
if (entry.path === protectedPath) {
|
|
794
|
+
index++;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
unlinkSync(entry.path);
|
|
799
|
+
bytes -= entry.bytes;
|
|
800
|
+
observations.splice(index, 1);
|
|
801
|
+
} catch {
|
|
802
|
+
index++;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
for (const accountName of readdirSync(cacheDir)) {
|
|
807
|
+
const accountDir = join(cacheDir, accountName);
|
|
808
|
+
if (BALANCE_CACHE_ACCOUNT_DIR_PATTERN.test(accountName)) {
|
|
809
|
+
try {
|
|
810
|
+
rmdirSync(accountDir);
|
|
811
|
+
} catch {
|
|
812
|
+
// Non-empty directories and races are harmless.
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
639
816
|
} catch {
|
|
640
|
-
//
|
|
817
|
+
// The cache is an accelerator only; maintenance is never authoritative.
|
|
641
818
|
}
|
|
642
|
-
|
|
819
|
+
}
|
|
643
820
|
|
|
821
|
+
function readCachedBalanceEntry(
|
|
822
|
+
cacheKey: string,
|
|
823
|
+
nowMs = Date.now(),
|
|
824
|
+
cacheDir = BALANCE_CACHE_DIR,
|
|
825
|
+
): BalanceCacheEntry | null {
|
|
826
|
+
let freshest: (BalanceCacheEntry & { filename: string }) | null = null;
|
|
644
827
|
try {
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
828
|
+
if (!isSafeDirectory(cacheDir)) return null;
|
|
829
|
+
const accountDir = balanceCacheAccountDir(cacheKey, cacheDir);
|
|
830
|
+
if (!isSafeDirectory(accountDir)) return null;
|
|
831
|
+
for (const filename of readdirSync(accountDir)) {
|
|
832
|
+
if (!filename.endsWith(".json")) continue;
|
|
833
|
+
try {
|
|
834
|
+
const raw = readRegularJsonFile(join(accountDir, filename));
|
|
835
|
+
if (raw === null) continue;
|
|
836
|
+
const entry = asRecord(JSON.parse(raw));
|
|
837
|
+
const fetchedAt = numericProperty(entry, "fetchedAt");
|
|
838
|
+
if (entry === null || fetchedAt === null) continue;
|
|
839
|
+
const ageMs = nowMs - fetchedAt;
|
|
840
|
+
if (ageMs < 0 || ageMs >= BALANCE_CACHE_TTL_MS) continue;
|
|
841
|
+
const balance = parseCachedBalance(entry.balance);
|
|
842
|
+
if (
|
|
843
|
+
balance &&
|
|
844
|
+
balance.length > 0 &&
|
|
845
|
+
(!freshest ||
|
|
846
|
+
fetchedAt > freshest.fetchedAt ||
|
|
847
|
+
(fetchedAt === freshest.fetchedAt && filename > freshest.filename))
|
|
848
|
+
) {
|
|
849
|
+
freshest = { fetchedAt, balance, filename };
|
|
850
|
+
}
|
|
851
|
+
} catch {
|
|
852
|
+
// One damaged observation must not hide another valid one.
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
} catch {
|
|
856
|
+
return null; // Missing/unreadable cache is a cold start.
|
|
857
|
+
}
|
|
858
|
+
return freshest
|
|
859
|
+
? { fetchedAt: freshest.fetchedAt, balance: freshest.balance }
|
|
860
|
+
: null;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Read a non-expired cache entry. The key must include account identity. */
|
|
864
|
+
export function readCachedBalance(
|
|
865
|
+
cacheKey: string,
|
|
866
|
+
nowMs = Date.now(),
|
|
867
|
+
cacheDir = BALANCE_CACHE_DIR,
|
|
868
|
+
): Balance | null {
|
|
869
|
+
return readCachedBalanceEntry(cacheKey, nowMs, cacheDir)?.balance ?? null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Persist one account's reading in its own atomic file. Separate files avoid
|
|
874
|
+
* the lost-update race of a shared read-modify-write JSON object.
|
|
875
|
+
*/
|
|
876
|
+
export function writeCachedBalance(
|
|
877
|
+
cacheKey: string,
|
|
878
|
+
balance: Balance,
|
|
879
|
+
observedAtMs = Date.now(),
|
|
880
|
+
cacheDir = BALANCE_CACHE_DIR,
|
|
881
|
+
): boolean {
|
|
882
|
+
const accountDir = balanceCacheAccountDir(cacheKey, cacheDir);
|
|
883
|
+
const path = join(
|
|
884
|
+
accountDir,
|
|
885
|
+
`${observedAtMs}.${process.pid}.${randomUUID()}.json`,
|
|
886
|
+
);
|
|
887
|
+
try {
|
|
888
|
+
mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
889
|
+
const directory = lstatSync(cacheDir);
|
|
890
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) return false;
|
|
891
|
+
chmodSync(cacheDir, 0o700);
|
|
892
|
+
|
|
893
|
+
mkdirSync(accountDir, { recursive: true, mode: 0o700 });
|
|
894
|
+
const accountDirectory = lstatSync(accountDir);
|
|
895
|
+
if (!accountDirectory.isDirectory() || accountDirectory.isSymbolicLink()) {
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
chmodSync(accountDir, 0o700);
|
|
899
|
+
writeJsonFileAtomic(path, { fetchedAt: observedAtMs, balance });
|
|
649
900
|
} catch {
|
|
650
901
|
// The cache is an accelerator only; the footer works without it.
|
|
902
|
+
return false;
|
|
651
903
|
}
|
|
904
|
+
|
|
905
|
+
cleanupBalanceCache(cacheDir, observedAtMs, path);
|
|
906
|
+
return true;
|
|
652
907
|
}
|
|
653
908
|
|
|
654
909
|
type FooterSession = ConstructorParameters<typeof FooterComponent>[0];
|
|
@@ -791,83 +1046,271 @@ function addBalanceToWorkingDirectoryLine(
|
|
|
791
1046
|
return [`${left}${padding}${right}`, ...lines.slice(1)];
|
|
792
1047
|
}
|
|
793
1048
|
|
|
794
|
-
export
|
|
1049
|
+
export interface ProviderBalanceDependencies {
|
|
1050
|
+
adapters?: Readonly<Record<string, BalanceAdapter>>;
|
|
1051
|
+
cacheDir?: string;
|
|
1052
|
+
now?: () => number;
|
|
1053
|
+
random?: () => number;
|
|
1054
|
+
setTimeout?: typeof setTimeout;
|
|
1055
|
+
clearTimeout?: typeof clearTimeout;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export default function providerBalance(
|
|
1059
|
+
pi: ExtensionAPI,
|
|
1060
|
+
dependencies: ProviderBalanceDependencies = {},
|
|
1061
|
+
): void {
|
|
1062
|
+
const adapters = dependencies.adapters ?? BALANCE_ADAPTERS;
|
|
1063
|
+
const cacheDir = dependencies.cacheDir ?? BALANCE_CACHE_DIR;
|
|
1064
|
+
const now = dependencies.now ?? Date.now;
|
|
1065
|
+
const random = dependencies.random ?? Math.random;
|
|
1066
|
+
const setTimer = dependencies.setTimeout ?? setTimeout;
|
|
1067
|
+
const clearTimer = dependencies.clearTimeout ?? clearTimeout;
|
|
1068
|
+
|
|
795
1069
|
let activeContext: ExtensionContext | undefined;
|
|
796
1070
|
let balance: Balance | undefined;
|
|
797
|
-
|
|
1071
|
+
let balanceFetchedAt: number | undefined;
|
|
1072
|
+
let identityPending = false;
|
|
1073
|
+
/** Provider and account the currently displayed balance belongs to. */
|
|
798
1074
|
let displayedProvider: string | undefined;
|
|
1075
|
+
let displayedCacheKey: string | undefined;
|
|
799
1076
|
let refreshGeneration = 0;
|
|
1077
|
+
let refreshInFlight = false;
|
|
800
1078
|
let refreshController: AbortController | undefined;
|
|
1079
|
+
let idleRefreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1080
|
+
let authTransitionTimer: ReturnType<typeof setTimeout> | undefined;
|
|
801
1081
|
let requestRender: (() => void) | undefined;
|
|
802
1082
|
let activeThinkingLevel: ActiveThinkingLevel = "off";
|
|
803
1083
|
|
|
804
1084
|
function clearBalance(): void {
|
|
805
1085
|
balance = undefined;
|
|
1086
|
+
balanceFetchedAt = undefined;
|
|
1087
|
+
identityPending = false;
|
|
806
1088
|
requestRender?.();
|
|
807
1089
|
}
|
|
808
1090
|
|
|
1091
|
+
/** Login/logout is delivered as input before the command changes auth. */
|
|
1092
|
+
function invalidateAuthTransition(provider: string): void {
|
|
1093
|
+
if (provider !== displayedProvider) return;
|
|
1094
|
+
refreshGeneration++;
|
|
1095
|
+
refreshInFlight = false;
|
|
1096
|
+
refreshController?.abort();
|
|
1097
|
+
refreshController = undefined;
|
|
1098
|
+
displayedCacheKey = undefined;
|
|
1099
|
+
clearBalance();
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function scheduleAuthTransitionCheck(
|
|
1103
|
+
ctx: ExtensionContext,
|
|
1104
|
+
provider: string,
|
|
1105
|
+
previousCacheKey: string | undefined,
|
|
1106
|
+
): void {
|
|
1107
|
+
if (authTransitionTimer !== undefined) clearTimer(authTransitionTimer);
|
|
1108
|
+
const generation = refreshGeneration;
|
|
1109
|
+
let attempts = 0;
|
|
1110
|
+
const check = async (): Promise<void> => {
|
|
1111
|
+
attempts++;
|
|
1112
|
+
if (generation !== refreshGeneration || activeContext !== ctx) return;
|
|
1113
|
+
let token: string | undefined;
|
|
1114
|
+
try {
|
|
1115
|
+
token = await ctx.modelRegistry.getApiKeyForProvider(provider);
|
|
1116
|
+
} catch {
|
|
1117
|
+
if (attempts < 10) {
|
|
1118
|
+
authTransitionTimer = setTimer(() => void check(), 1_000);
|
|
1119
|
+
} else {
|
|
1120
|
+
authTransitionTimer = undefined;
|
|
1121
|
+
}
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (generation !== refreshGeneration || activeContext !== ctx) return;
|
|
1125
|
+
const currentCacheKey = token
|
|
1126
|
+
? balanceCacheKey(provider, token)
|
|
1127
|
+
: undefined;
|
|
1128
|
+
if (currentCacheKey === previousCacheKey) {
|
|
1129
|
+
if (attempts < 10) {
|
|
1130
|
+
authTransitionTimer = setTimer(() => void check(), 1_000);
|
|
1131
|
+
} else {
|
|
1132
|
+
authTransitionTimer = undefined;
|
|
1133
|
+
}
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
authTransitionTimer = undefined;
|
|
1137
|
+
void refreshForModel(ctx, ctx.model);
|
|
1138
|
+
};
|
|
1139
|
+
authTransitionTimer = setTimer(() => void check(), 250);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
809
1142
|
/** Refresh the balance for whatever model is active. Provider-agnostic: the
|
|
810
1143
|
* adapter registry in refreshBalance decides whether there is anything to
|
|
811
1144
|
* fetch, so this is a no-op for providers without a balance adapter. */
|
|
812
1145
|
function refreshForModel(
|
|
813
1146
|
ctx: ExtensionContext,
|
|
814
1147
|
model: ExtensionContext["model"],
|
|
1148
|
+
maxCacheAgeMs?: number,
|
|
815
1149
|
): Promise<void> {
|
|
816
|
-
return refreshBalance(ctx, model?.provider, model);
|
|
1150
|
+
return refreshBalance(ctx, model?.provider, model, maxCacheAgeMs);
|
|
817
1151
|
}
|
|
818
1152
|
|
|
819
1153
|
async function refreshBalance(
|
|
820
1154
|
ctx: ExtensionContext,
|
|
821
1155
|
provider: string | undefined,
|
|
822
1156
|
model: ExtensionContext["model"],
|
|
1157
|
+
maxCacheAgeMs?: number,
|
|
823
1158
|
): Promise<void> {
|
|
1159
|
+
const isIdleRefresh = maxCacheAgeMs !== undefined;
|
|
1160
|
+
// An idle poll is opportunistic. It must never cancel the post-run or
|
|
1161
|
+
// model-change refresh that provides the authoritative new reading.
|
|
1162
|
+
if (isIdleRefresh && refreshInFlight) return;
|
|
1163
|
+
|
|
824
1164
|
const generation = ++refreshGeneration;
|
|
825
|
-
|
|
1165
|
+
refreshInFlight = true;
|
|
1166
|
+
if (!isIdleRefresh) refreshController?.abort();
|
|
826
1167
|
const controller = new AbortController();
|
|
827
1168
|
refreshController = controller;
|
|
828
1169
|
|
|
829
1170
|
// Only blank the footer when the displayed value is for a different
|
|
830
1171
|
// provider than the one we're about to fetch. A same-provider refresh
|
|
831
|
-
// keeps the last known value on screen until the fresh one lands
|
|
832
|
-
// atomic swap), which matters now that we refresh mid-turn rather than
|
|
833
|
-
// once per run — otherwise the number would flicker off on every refresh.
|
|
1172
|
+
// keeps the last known value on screen until the fresh one lands.
|
|
834
1173
|
if (provider !== displayedProvider) {
|
|
835
1174
|
displayedProvider = provider;
|
|
1175
|
+
displayedCacheKey = undefined;
|
|
836
1176
|
clearBalance();
|
|
837
1177
|
}
|
|
838
1178
|
|
|
839
1179
|
const providerId = provider;
|
|
840
|
-
const adapter = providerId ?
|
|
841
|
-
if (!adapter || !providerId) return;
|
|
842
|
-
|
|
843
|
-
// Paint the freshest known value for this account immediately: another
|
|
844
|
-
// live pi instance may have fetched seconds ago, and on session switch
|
|
845
|
-
// this is what keeps the new session's footer warm instead of blank until
|
|
846
|
-
// its own first fetch lands.
|
|
847
|
-
const cached = readCachedBalance(providerId);
|
|
848
|
-
if (cached) {
|
|
849
|
-
balance = cached;
|
|
850
|
-
requestRender?.();
|
|
851
|
-
}
|
|
852
|
-
if (
|
|
853
|
-
adapter.requiresOAuth &&
|
|
854
|
-
(!model ||
|
|
855
|
-
model.provider !== providerId ||
|
|
856
|
-
!ctx.modelRegistry.isUsingOAuth(model))
|
|
857
|
-
) {
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
1180
|
+
const adapter = providerId ? adapters[providerId] : undefined;
|
|
860
1181
|
|
|
861
1182
|
try {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
if (
|
|
866
|
-
|
|
1183
|
+
if (!adapter || !providerId) return;
|
|
1184
|
+
// Codex balances describe OAuth subscription quota. Never adopt or fetch
|
|
1185
|
+
// one while this model is using ordinary API-key authentication.
|
|
1186
|
+
if (
|
|
1187
|
+
adapter.requiresOAuth &&
|
|
1188
|
+
(!model ||
|
|
1189
|
+
model.provider !== providerId ||
|
|
1190
|
+
!ctx.modelRegistry.isUsingOAuth(model))
|
|
1191
|
+
) {
|
|
1192
|
+
displayedCacheKey = undefined;
|
|
1193
|
+
clearBalance();
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Do not render a cached value while credentials are being resolved. The
|
|
1198
|
+
// same provider can represent a different account after login/logout.
|
|
1199
|
+
identityPending = true;
|
|
1200
|
+
requestRender?.();
|
|
1201
|
+
|
|
1202
|
+
// Preserve a known same-account value during routine credential refresh,
|
|
1203
|
+
// but clear it if identity resolution fails or reveals another account.
|
|
1204
|
+
let token: string | undefined;
|
|
1205
|
+
try {
|
|
1206
|
+
token = await ctx.modelRegistry.getApiKeyForProvider(providerId);
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
if (generation === refreshGeneration) {
|
|
1209
|
+
displayedCacheKey = undefined;
|
|
1210
|
+
clearBalance();
|
|
1211
|
+
}
|
|
1212
|
+
throw error;
|
|
1213
|
+
}
|
|
1214
|
+
if (generation !== refreshGeneration) return;
|
|
1215
|
+
if (!token) {
|
|
1216
|
+
displayedCacheKey = undefined;
|
|
1217
|
+
identityPending = false;
|
|
1218
|
+
clearBalance();
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const cacheKey = balanceCacheKey(providerId, token);
|
|
1222
|
+
if (displayedCacheKey !== cacheKey) clearBalance();
|
|
1223
|
+
displayedCacheKey = cacheKey;
|
|
1224
|
+
identityPending = false;
|
|
1225
|
+
|
|
1226
|
+
// Paint the freshest known value for this account immediately. The
|
|
1227
|
+
// credential fingerprint prevents sessions for different accounts from
|
|
1228
|
+
// showing or suppressing one another's readings.
|
|
1229
|
+
const cached = readCachedBalanceEntry(cacheKey, now(), cacheDir);
|
|
1230
|
+
if (cached && generation === refreshGeneration) {
|
|
1231
|
+
balance = cached.balance;
|
|
1232
|
+
balanceFetchedAt = cached.fetchedAt;
|
|
867
1233
|
requestRender?.();
|
|
1234
|
+
if (
|
|
1235
|
+
maxCacheAgeMs !== undefined &&
|
|
1236
|
+
now() - cached.fetchedAt < maxCacheAgeMs
|
|
1237
|
+
) {
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const nextBalance = await adapter.fetch(token, controller.signal);
|
|
1243
|
+
const observedAtMs = now();
|
|
1244
|
+
if (generation !== refreshGeneration) return;
|
|
1245
|
+
|
|
1246
|
+
// Login/logout does not emit model_select. Re-resolve identity before
|
|
1247
|
+
// committing so an account switch during the request cannot paint the
|
|
1248
|
+
// previous account's result.
|
|
1249
|
+
if (
|
|
1250
|
+
adapter.requiresOAuth &&
|
|
1251
|
+
(!model || !ctx.modelRegistry.isUsingOAuth(model))
|
|
1252
|
+
) {
|
|
1253
|
+
displayedCacheKey = undefined;
|
|
1254
|
+
clearBalance();
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
identityPending = true;
|
|
1258
|
+
requestRender?.();
|
|
1259
|
+
let currentToken: string | undefined;
|
|
1260
|
+
let confirmedToken: string | undefined;
|
|
1261
|
+
try {
|
|
1262
|
+
currentToken = await ctx.modelRegistry.getApiKeyForProvider(providerId);
|
|
1263
|
+
// A credential can change while the first final lookup is pending.
|
|
1264
|
+
// Resolve it once more before accepting the provider response.
|
|
1265
|
+
confirmedToken =
|
|
1266
|
+
await ctx.modelRegistry.getApiKeyForProvider(providerId);
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
if (generation === refreshGeneration) {
|
|
1269
|
+
displayedCacheKey = undefined;
|
|
1270
|
+
clearBalance();
|
|
1271
|
+
}
|
|
1272
|
+
throw error;
|
|
1273
|
+
}
|
|
1274
|
+
const currentModel = activeContext === ctx ? ctx.model : undefined;
|
|
1275
|
+
if (
|
|
1276
|
+
generation !== refreshGeneration ||
|
|
1277
|
+
currentModel?.provider !== providerId ||
|
|
1278
|
+
(adapter.requiresOAuth &&
|
|
1279
|
+
(!currentModel || !ctx.modelRegistry.isUsingOAuth(currentModel))) ||
|
|
1280
|
+
!currentToken ||
|
|
1281
|
+
!confirmedToken ||
|
|
1282
|
+
balanceCacheKey(providerId, currentToken) !== cacheKey ||
|
|
1283
|
+
balanceCacheKey(providerId, confirmedToken) !== cacheKey
|
|
1284
|
+
) {
|
|
1285
|
+
if (generation === refreshGeneration) {
|
|
1286
|
+
displayedCacheKey = undefined;
|
|
1287
|
+
clearBalance();
|
|
1288
|
+
if (currentToken || confirmedToken) {
|
|
1289
|
+
void refreshForModel(ctx, currentModel);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
return;
|
|
868
1293
|
}
|
|
1294
|
+
|
|
1295
|
+
identityPending = false;
|
|
1296
|
+
const persisted = writeCachedBalance(
|
|
1297
|
+
cacheKey,
|
|
1298
|
+
nextBalance,
|
|
1299
|
+
observedAtMs,
|
|
1300
|
+
cacheDir,
|
|
1301
|
+
);
|
|
1302
|
+
const freshest = persisted
|
|
1303
|
+
? readCachedBalanceEntry(cacheKey, observedAtMs, cacheDir)
|
|
1304
|
+
: null;
|
|
1305
|
+
balance = freshest?.balance ?? nextBalance;
|
|
1306
|
+
balanceFetchedAt = freshest?.fetchedAt ?? observedAtMs;
|
|
1307
|
+
requestRender?.();
|
|
869
1308
|
} catch (error) {
|
|
870
|
-
if (generation !== refreshGeneration || controller.signal.aborted)
|
|
1309
|
+
if (generation !== refreshGeneration || controller.signal.aborted) {
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
identityPending = false;
|
|
1313
|
+
clearBalance();
|
|
871
1314
|
// This is a best-effort background refresh. Writing to stdout/stderr while
|
|
872
1315
|
// Pi owns the terminal corrupts the TUI (the text appears in the editor),
|
|
873
1316
|
// so expose failures to other extensions without producing terminal output.
|
|
@@ -876,10 +1319,30 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
876
1319
|
message: error instanceof Error ? error.message : String(error),
|
|
877
1320
|
});
|
|
878
1321
|
} finally {
|
|
879
|
-
if (generation === refreshGeneration)
|
|
1322
|
+
if (generation === refreshGeneration) {
|
|
1323
|
+
refreshInFlight = false;
|
|
1324
|
+
refreshController = undefined;
|
|
1325
|
+
}
|
|
880
1326
|
}
|
|
881
1327
|
}
|
|
882
1328
|
|
|
1329
|
+
function scheduleIdleRefresh(): void {
|
|
1330
|
+
if (idleRefreshTimer !== undefined) clearTimer(idleRefreshTimer);
|
|
1331
|
+
const delay =
|
|
1332
|
+
IDLE_REFRESH_INTERVAL_MS + Math.floor(random() * IDLE_REFRESH_JITTER_MS);
|
|
1333
|
+
idleRefreshTimer = setTimer(() => {
|
|
1334
|
+
idleRefreshTimer = undefined;
|
|
1335
|
+
const ctx = activeContext;
|
|
1336
|
+
if (ctx?.mode === "tui" && ctx.isIdle()) {
|
|
1337
|
+
// Countdown text is derived at render time, so repaint even when the
|
|
1338
|
+
// cache is stale or the provider request fails.
|
|
1339
|
+
requestRender?.();
|
|
1340
|
+
void refreshForModel(ctx, ctx.model, IDLE_REFRESH_INTERVAL_MS);
|
|
1341
|
+
}
|
|
1342
|
+
scheduleIdleRefresh();
|
|
1343
|
+
}, delay);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
883
1346
|
function installFooter(ctx: ExtensionContext): void {
|
|
884
1347
|
if (ctx.mode !== "tui") return;
|
|
885
1348
|
activeContext = ctx;
|
|
@@ -908,7 +1371,12 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
908
1371
|
footer.render(width),
|
|
909
1372
|
width,
|
|
910
1373
|
theme,
|
|
911
|
-
|
|
1374
|
+
!identityPending &&
|
|
1375
|
+
balance &&
|
|
1376
|
+
balanceFetchedAt !== undefined &&
|
|
1377
|
+
now() - balanceFetchedAt < BALANCE_CACHE_TTL_MS
|
|
1378
|
+
? formatBalance(balance, now())
|
|
1379
|
+
: undefined,
|
|
912
1380
|
),
|
|
913
1381
|
dispose: () => {
|
|
914
1382
|
unsubscribeBranchChange();
|
|
@@ -929,10 +1397,22 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
929
1397
|
activeContext = ctx;
|
|
930
1398
|
activeThinkingLevel = restoredThinkingLevel(ctx);
|
|
931
1399
|
installFooter(ctx);
|
|
1400
|
+
cleanupBalanceCache(cacheDir, now());
|
|
1401
|
+
if (ctx.mode === "tui") scheduleIdleRefresh();
|
|
932
1402
|
// Footer data is supplemental. Never hold up session readiness on network.
|
|
933
1403
|
void refreshForModel(ctx, ctx.model);
|
|
934
1404
|
});
|
|
935
1405
|
|
|
1406
|
+
pi.on("input", (event, ctx) => {
|
|
1407
|
+
const match = /^\/(login|logout)(?:\s+(\S+))?/.exec(event.text.trim());
|
|
1408
|
+
if (!match) return;
|
|
1409
|
+
const provider = match[2] ?? ctx.model?.provider;
|
|
1410
|
+
if (!provider || provider !== ctx.model?.provider) return;
|
|
1411
|
+
const previousCacheKey = displayedCacheKey;
|
|
1412
|
+
invalidateAuthTransition(provider);
|
|
1413
|
+
scheduleAuthTransitionCheck(ctx, provider, previousCacheKey);
|
|
1414
|
+
});
|
|
1415
|
+
|
|
936
1416
|
pi.on("model_select", (event, ctx) => {
|
|
937
1417
|
activeContext = ctx;
|
|
938
1418
|
// AgentSession awaits model_select handlers before the picker can close.
|
|
@@ -967,14 +1447,22 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
967
1447
|
});
|
|
968
1448
|
|
|
969
1449
|
pi.on("session_shutdown", () => {
|
|
1450
|
+
refreshGeneration++;
|
|
1451
|
+
refreshInFlight = false;
|
|
970
1452
|
refreshController?.abort();
|
|
971
1453
|
refreshController = undefined;
|
|
1454
|
+
if (idleRefreshTimer !== undefined) clearTimer(idleRefreshTimer);
|
|
1455
|
+
idleRefreshTimer = undefined;
|
|
1456
|
+
if (authTransitionTimer !== undefined) clearTimer(authTransitionTimer);
|
|
1457
|
+
authTransitionTimer = undefined;
|
|
972
1458
|
activeContext = undefined;
|
|
973
1459
|
activeThinkingLevel = "off";
|
|
974
1460
|
requestRender = undefined;
|
|
975
1461
|
// Drop the prior session's balance so the next footer doesn't flash a
|
|
976
1462
|
// stale value from a different provider before its first refresh lands.
|
|
977
1463
|
balance = undefined;
|
|
1464
|
+
balanceFetchedAt = undefined;
|
|
978
1465
|
displayedProvider = undefined;
|
|
1466
|
+
displayedCacheKey = undefined;
|
|
979
1467
|
});
|
|
980
1468
|
}
|