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 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.4.2
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 restore(ctx: ExtensionContext): Promise<void> {
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(() => restore(ctx), failureMessage);
274
+ return enqueue(() => restorePinnedModel(ctx), failureMessage);
250
275
  }
251
276
 
252
- pi.on("session_start", (_event, ctx) => schedule(ctx));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "A bundle of small, frequently-used Pi extensions.",
6
6
  "keywords": ["pi-package"],
@@ -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 { readFileSync, renameSync, mkdirSync, writeFileSync } from "node:fs";
9
- import { dirname } from "node:path";
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 BALANCE_CACHE_FILE = `${process.env.HOME ?? ""}/.pi/agent/cache/provider-balance.json`;
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
- type BalanceCache = Record<string, BalanceCacheEntry>;
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
- /** Read the freshest non-expired cached balance for a provider. */
605
- export function readCachedBalance(
606
- provider: string,
607
- nowMs = Date.now(),
608
- ): Balance | null {
609
- let raw: string;
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
- raw = readFileSync(BALANCE_CACHE_FILE, "utf8");
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; // Missing/unreadable cache is a normal cold start.
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 entry = asRecord(asRecord(JSON.parse(raw))?.[provider]);
618
- const fetchedAt = numericProperty(entry, "fetchedAt");
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 null; // Corrupt cache must never break the footer.
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
- * Persist a fresh reading so other live sessions and future freshly-loaded
629
- * sessions render it instantly. Write is atomic (tmp + rename) because
630
- * several pi processes can fetch concurrently.
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 writeCachedBalance(provider: string, balance: Balance): void {
633
- let cache: BalanceCache = {};
716
+ function cleanupBalanceCache(
717
+ cacheDir: string,
718
+ nowMs = Date.now(),
719
+ protectedPath?: string,
720
+ ): void {
634
721
  try {
635
- const existing = asRecord(
636
- JSON.parse(readFileSync(BALANCE_CACHE_FILE, "utf8")),
637
- );
638
- if (existing) cache = existing as BalanceCache;
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
- // Missing or corrupt cache: start fresh rather than failing the write.
817
+ // The cache is an accelerator only; maintenance is never authoritative.
641
818
  }
642
- cache[provider] = { fetchedAt: Date.now(), balance };
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
- mkdirSync(dirname(BALANCE_CACHE_FILE), { recursive: true });
646
- const tempFile = `${BALANCE_CACHE_FILE}.${process.pid}.tmp`;
647
- writeFileSync(tempFile, JSON.stringify(cache));
648
- renameSync(tempFile, BALANCE_CACHE_FILE);
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 default function providerBalance(pi: ExtensionAPI): void {
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
- /** Provider the currently-displayed balance belongs to. */
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
- refreshController?.abort();
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 (an
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 ? BALANCE_ADAPTERS[providerId] : undefined;
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
- const token = await ctx.modelRegistry.getApiKeyForProvider(providerId);
863
- if (!token || generation !== refreshGeneration) return;
864
- balance = await adapter.fetch(token, controller.signal);
865
- if (generation === refreshGeneration) {
866
- writeCachedBalance(providerId, balance);
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) return;
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) refreshController = undefined;
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
- balance ? formatBalance(balance) : undefined,
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
  }