pi-better-openai 0.1.19 → 0.1.21

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/src/pets.ts CHANGED
@@ -2,7 +2,7 @@ import { constants, type Dirent } from "node:fs";
2
2
  import { access, lstat, readdir, readFile, realpath } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve, sep } from "node:path";
5
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
7
  calculateImageRows,
8
8
  getCapabilities,
@@ -12,7 +12,7 @@ import {
12
12
  truncateToWidth,
13
13
  type AutocompleteItem,
14
14
  type ImageTheme,
15
- } from "@mariozechner/pi-tui";
15
+ } from "@earendil-works/pi-tui";
16
16
  import sharp from "sharp";
17
17
  import { isRecord, type PetState } from "./config.ts";
18
18
 
@@ -25,6 +25,7 @@ const DEFAULT_SPRITESHEET_PATH = "spritesheet.webp";
25
25
  const EXPECTED_ATLAS_WIDTH = DEFAULT_CELL_WIDTH * PET_COLUMNS;
26
26
  const EXPECTED_ATLAS_HEIGHT = DEFAULT_CELL_HEIGHT * PET_ROWS;
27
27
  const PET_CATALOG_CACHE_TTL_MS = 1500;
28
+ const PET_CATALOG_READ_CONCURRENCY = 4;
28
29
 
29
30
  type ListCodexPetsOptions = { refresh?: boolean };
30
31
 
@@ -61,8 +62,8 @@ export type CodexPetPackage = {
61
62
  export type PetFrame = {
62
63
  data?: string;
63
64
  rawRgbaData?: string;
65
+ kittyUpload?: string;
64
66
  kittyImageId?: number;
65
- kittyUploaded?: boolean;
66
67
  mimeType: "image/png";
67
68
  durationMs: number;
68
69
  widthPx: number;
@@ -265,6 +266,25 @@ async function readCodexPetPackage(
265
266
  };
266
267
  }
267
268
 
269
+ async function readCodexPetPackages(
270
+ petsDir: string,
271
+ entries: Dirent[],
272
+ options: { validateSpritesheet: boolean },
273
+ ): Promise<CodexPetPackage[]> {
274
+ const pets: Array<CodexPetPackage | undefined> = Array.from({ length: entries.length });
275
+ let nextIndex = 0;
276
+ const readNext = async (): Promise<void> => {
277
+ while (nextIndex < entries.length) {
278
+ const index = nextIndex++;
279
+ pets[index] = await readCodexPetPackage(petsDir, entries[index], options);
280
+ }
281
+ };
282
+ await Promise.all(
283
+ Array.from({ length: Math.min(PET_CATALOG_READ_CONCURRENCY, entries.length) }, readNext),
284
+ );
285
+ return pets.filter((pet): pet is CodexPetPackage => pet !== undefined);
286
+ }
287
+
268
288
  export async function listCodexPets(
269
289
  home = codexHome(),
270
290
  options: ListCodexPetsOptions = {},
@@ -274,22 +294,22 @@ export async function listCodexPets(
274
294
  if (!options.refresh && cached && cached.expiresAt > Date.now()) return cached.pets;
275
295
 
276
296
  const { dir, entries } = await readPetDirectoryEntries(home);
277
- const pets: CodexPetPackage[] = [];
278
- for (const entry of entries) {
279
- const pet = await readCodexPetPackage(dir, entry, { validateSpritesheet: true });
280
- if (pet) pets.push(pet);
281
- }
297
+ const pets = await readCodexPetPackages(dir, entries, { validateSpritesheet: true });
282
298
  pets.sort((a, b) => a.name.localeCompare(b.name));
283
299
  petCatalogCache.set(cacheKey, { expiresAt: Date.now() + PET_CATALOG_CACHE_TTL_MS, pets });
284
300
  return pets;
285
301
  }
286
302
 
287
- function petLookupKey(value: string): string {
288
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
303
+ export function petLookupKey(value: string): string {
304
+ return value
305
+ .normalize("NFKC")
306
+ .toLowerCase()
307
+ .replace(/[^\p{L}\p{N}\p{M}]+/gu, "");
289
308
  }
290
309
 
291
310
  type PetLookupRequest = {
292
311
  hasText: boolean;
312
+ exact?: string;
293
313
  key?: string;
294
314
  };
295
315
 
@@ -297,7 +317,11 @@ function petLookupRequest(value?: string): PetLookupRequest {
297
317
  const requestedText = value?.trim();
298
318
  if (!requestedText) return { hasText: false };
299
319
 
300
- return { hasText: true, key: petLookupKey(requestedText) || undefined };
320
+ return {
321
+ hasText: true,
322
+ exact: requestedText.normalize("NFKC").toLowerCase(),
323
+ key: petLookupKey(requestedText) || undefined,
324
+ };
301
325
  }
302
326
 
303
327
  function petMatchesLookup(pet: CodexPetPackage, requested: string): boolean {
@@ -308,11 +332,35 @@ function petMatchesLookup(pet: CodexPetPackage, requested: string): boolean {
308
332
  );
309
333
  }
310
334
 
335
+ function exactPetMatches(
336
+ pets: CodexPetPackage[],
337
+ requested: string,
338
+ ): CodexPetPackage[] | undefined {
339
+ const fields: Array<(pet: CodexPetPackage) => string | undefined> = [
340
+ (pet) => pet.slug,
341
+ (pet) => pet.id,
342
+ (pet) => pet.name,
343
+ ];
344
+ for (const field of fields) {
345
+ const matches = pets.filter((pet) => field(pet)?.normalize("NFKC").toLowerCase() === requested);
346
+ if (matches.length > 0) return matches;
347
+ }
348
+ return undefined;
349
+ }
350
+
351
+ function uniquePetMatch(pets: CodexPetPackage[]): CodexPetPackage | undefined {
352
+ return pets.length === 1 ? pets[0] : undefined;
353
+ }
354
+
311
355
  export function findCodexPet(pets: CodexPetPackage[], value?: string): CodexPetPackage | undefined {
312
356
  const request = petLookupRequest(value);
313
357
  const { key } = request;
314
358
  if (!key) return undefined;
315
- return pets.find((pet) => petMatchesLookup(pet, key));
359
+ if (request.exact) {
360
+ const exactMatches = exactPetMatches(pets, request.exact);
361
+ if (exactMatches) return uniquePetMatch(exactMatches);
362
+ }
363
+ return uniquePetMatch(pets.filter((pet) => petMatchesLookup(pet, key)));
316
364
  }
317
365
 
318
366
  export function findReadyCodexPet(
@@ -324,7 +372,14 @@ export function findReadyCodexPet(
324
372
 
325
373
  const { key } = request;
326
374
  if (!key) return undefined;
327
- return pets.find((pet) => pet.hasSpritesheet && petMatchesLookup(pet, key));
375
+ if (request.exact) {
376
+ const exactMatches = exactPetMatches(pets, request.exact);
377
+ if (exactMatches) {
378
+ const exact = uniquePetMatch(exactMatches);
379
+ return exact?.hasSpritesheet ? exact : undefined;
380
+ }
381
+ }
382
+ return uniquePetMatch(pets.filter((pet) => pet.hasSpritesheet && petMatchesLookup(pet, key)));
328
383
  }
329
384
 
330
385
  function selectPet(pets: CodexPetPackage[], slug?: string): CodexPetPackage | undefined {
@@ -431,18 +486,15 @@ async function findCodexPetDirect(
431
486
  const request = petLookupRequest(slug);
432
487
  if (!request.key) return undefined;
433
488
  const { dir, entries } = await readPetDirectoryEntries(home);
434
- for (const entry of entries) {
435
- const candidate = await readCodexPetPackage(dir, entry, { validateSpritesheet: false });
436
- if (!candidate || !petMatchesLookup(candidate, request.key)) continue;
437
- if (candidate.spritesheetIssue) return candidate;
438
- const spritesheetIssue = await validatePetSpritesheet(candidate.dir, candidate.spritesheetPath);
439
- return {
440
- ...candidate,
441
- hasSpritesheet: spritesheetIssue === undefined,
442
- spritesheetIssue,
443
- };
444
- }
445
- return undefined;
489
+ const candidates = await readCodexPetPackages(dir, entries, { validateSpritesheet: false });
490
+ const candidate = findCodexPet(candidates, slug);
491
+ if (!candidate || candidate.spritesheetIssue) return candidate;
492
+ const spritesheetIssue = await validatePetSpritesheet(candidate.dir, candidate.spritesheetPath);
493
+ return {
494
+ ...candidate,
495
+ hasSpritesheet: spritesheetIssue === undefined,
496
+ spritesheetIssue,
497
+ };
446
498
  }
447
499
 
448
500
  export async function loadCodexPet(
@@ -513,12 +565,20 @@ export async function loadCodexPet(
513
565
  kernel: sharp.kernel.nearest,
514
566
  });
515
567
  }
568
+ const kittyImageId = useKitty ? kittyImageBase + kittyFrameOffset++ : undefined;
516
569
  const encoded = useKitty
517
- ? { rawRgbaData: (await frame.clone().ensureAlpha().raw().toBuffer()).toString("base64") }
518
- : { data: (await frame.clone().png().toBuffer()).toString("base64") };
570
+ ? {
571
+ kittyUpload: encodeKittyRawRgbaData(
572
+ (await frame.ensureAlpha().raw().toBuffer()).toString("base64"),
573
+ outputWidthPx,
574
+ outputHeightPx,
575
+ kittyImageId!,
576
+ ),
577
+ }
578
+ : { data: (await frame.png().toBuffer()).toString("base64") };
519
579
  states[state].push({
520
580
  ...encoded,
521
- kittyImageId: useKitty ? kittyImageBase + kittyFrameOffset++ : undefined,
581
+ kittyImageId,
522
582
  mimeType: "image/png",
523
583
  durationMs,
524
584
  widthPx: outputWidthPx,
@@ -575,12 +635,6 @@ function forEachCodexPetFrame(
575
635
  }
576
636
  }
577
637
 
578
- function markCodexPetKittyFramesUnuploaded(pet?: LoadedCodexPet): void {
579
- forEachCodexPetFrame(pet, (frame) => {
580
- frame.kittyUploaded = false;
581
- });
582
- }
583
-
584
638
  function codexPetKittyImageIds(pet?: LoadedCodexPet): number[] {
585
639
  const imageIds = new Set<number>();
586
640
  forEachCodexPetFrame(pet, (frame) => {
@@ -601,18 +655,15 @@ function placeKittyImage(imageId: number, columns: number, rows: number): string
601
655
  return `\x1b_Ga=p,i=${imageId},p=1,c=${columns},r=${rows},q=2,C=1\x1b\\`;
602
656
  }
603
657
 
604
- function encodeKittyRawRgba(frame: PetFrame, imageId: number): string {
605
- const rawRgbaData = frame.rawRgbaData;
658
+ function encodeKittyRawRgbaData(
659
+ rawRgbaData: string,
660
+ widthPx: number,
661
+ heightPx: number,
662
+ imageId: number,
663
+ ): string {
606
664
  if (!rawRgbaData) return "";
607
665
  const chunkSize = 4096;
608
- const params = [
609
- "a=t",
610
- "f=32",
611
- `s=${frame.widthPx}`,
612
- `v=${frame.heightPx}`,
613
- `i=${imageId}`,
614
- "q=2",
615
- ].join(",");
666
+ const params = ["a=t", "f=32", `s=${widthPx}`, `v=${heightPx}`, `i=${imageId}`, "q=2"].join(",");
616
667
  if (rawRgbaData.length <= chunkSize) {
617
668
  return `\x1b_G${params};${rawRgbaData}\x1b\\`;
618
669
  }
@@ -628,6 +679,15 @@ function encodeKittyRawRgba(frame: PetFrame, imageId: number): string {
628
679
  return chunks.join("");
629
680
  }
630
681
 
682
+ function encodeKittyRawRgba(frame: PetFrame, imageId: number): string {
683
+ return (
684
+ frame.kittyUpload ??
685
+ (frame.rawRgbaData
686
+ ? encodeKittyRawRgbaData(frame.rawRgbaData, frame.widthPx, frame.heightPx, imageId)
687
+ : "")
688
+ );
689
+ }
690
+
631
691
  export class CodexPetKittyManager {
632
692
  private previousFrameImageId: number | undefined;
633
693
  private readonly pendingCleanupImageIds = new Set<number>();
@@ -641,7 +701,6 @@ export class CodexPetKittyManager {
641
701
  invalidate(pet?: LoadedCodexPet): void {
642
702
  this.queueCleanup(pet);
643
703
  this.previousFrameImageId = undefined;
644
- markCodexPetKittyFramesUnuploaded(pet);
645
704
  }
646
705
 
647
706
  resetForResize(pet?: LoadedCodexPet): void {
@@ -683,7 +742,6 @@ export class CodexPetKittyManager {
683
742
  // occasionally invisible pet. The TUI diff still avoids writing this payload
684
743
  // again when the rendered line is unchanged.
685
744
  const upload = encodeKittyRawRgba(frame, frameImageId);
686
- frame.kittyUploaded = true;
687
745
  this.previousFrameImageId = frameImageId;
688
746
  // Recent pi-tui versions automatically free Kitty image IDs they own on changed lines.
689
747
  // Pet frames are managed here and intentionally reused across animation loops, so keep
@@ -716,7 +774,6 @@ function defaultKittyManager(placementImageId: number): CodexPetKittyManager {
716
774
  export function resetCodexPetKittyCache(pet?: LoadedCodexPet, placementImageId?: number): void {
717
775
  if (placementImageId !== undefined) defaultKittyManagers.get(placementImageId)?.invalidate(pet);
718
776
  else for (const manager of defaultKittyManagers.values()) manager.invalidate(pet);
719
- markCodexPetKittyFramesUnuploaded(pet);
720
777
  }
721
778
 
722
779
  export function renderCodexPetFrame(
@@ -1,6 +1,6 @@
1
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { ResolvedConfig } from "./config.ts";
3
- import { maskIdentifier } from "./format.ts";
3
+ import { maskIdentifier, sanitizeDiagnosticError } from "./format.ts";
4
4
  import {
5
5
  AUTH_FILE,
6
6
  type UsageSnapshot,
@@ -9,19 +9,34 @@ import {
9
9
  parseUsageSnapshot,
10
10
  readCodexAuth,
11
11
  requestCodexUsage,
12
+ usageScopeForModel,
12
13
  } from "./usage.ts";
13
14
  import { currentModelKey } from "./fast-controller.ts";
14
15
 
15
- export function isOpenAISubscriptionModel(ctx: ExtensionContext, cfg: ResolvedConfig): boolean {
16
- if (!ctx.model || (ctx.model.provider !== "openai" && ctx.model.provider !== "openai-codex"))
17
- return false;
18
- return !cfg.usage.showOnlyOnSubscriptionModels || ctx.modelRegistry.isUsingOAuth(ctx.model);
16
+ export function isOpenAISubscriptionModel(
17
+ ctx: ExtensionContext,
18
+ cfg: ResolvedConfig,
19
+ isUsingOAuth?: boolean,
20
+ ): boolean {
21
+ const model = ctx.model;
22
+ if (!model || (model.provider !== "openai" && model.provider !== "openai-codex")) return false;
23
+ return (
24
+ !cfg.usage.showOnlyOnSubscriptionModels ||
25
+ (isUsingOAuth ?? ctx.modelRegistry.isUsingOAuth(model))
26
+ );
27
+ }
28
+
29
+ const STALE_EXTENSION_CONTEXT_MESSAGE = "This extension ctx is stale";
30
+
31
+ function isStaleExtensionContextError(error: unknown): boolean {
32
+ return error instanceof Error && error.message.includes(STALE_EXTENSION_CONTEXT_MESSAGE);
19
33
  }
20
34
 
21
35
  type UsageRefreshOptions = { notify?: boolean; force?: boolean };
22
36
 
23
37
  type QueuedUsageRefresh = {
24
38
  ctx: ExtensionContext;
39
+ generation: number;
25
40
  modelId?: string;
26
41
  notify?: boolean;
27
42
  force?: boolean;
@@ -39,6 +54,7 @@ export class UsageController {
39
54
  private usageAbortController: AbortController | undefined;
40
55
  private sessionAbortSignal: AbortSignal | undefined;
41
56
  private sessionAbortHandler: (() => void) | undefined;
57
+ private sessionGeneration = 0;
42
58
 
43
59
  constructor(
44
60
  private readonly getConfig: (ctx: ExtensionContext) => ResolvedConfig,
@@ -49,8 +65,16 @@ export class UsageController {
49
65
  return this.usageSnapshot;
50
66
  }
51
67
 
52
- statusLine(ctx: ExtensionContext, cfg = this.getConfig(ctx)): string | undefined {
53
- return this.usageSnapshot && cfg.usage.enabled && isOpenAISubscriptionModel(ctx, cfg)
68
+ statusLine(
69
+ ctx: ExtensionContext,
70
+ cfg = this.getConfig(ctx),
71
+ isUsingOAuth?: boolean,
72
+ ): string | undefined {
73
+ return this.usageSnapshot &&
74
+ !this.usageError &&
75
+ this.usageSnapshot.scope === usageScopeForModel(ctx.model?.id) &&
76
+ cfg.usage.enabled &&
77
+ isOpenAISubscriptionModel(ctx, cfg, isUsingOAuth)
54
78
  ? formatUsageSnapshot(this.usageSnapshot, cfg.usage)
55
79
  : undefined;
56
80
  }
@@ -60,8 +84,9 @@ export class UsageController {
60
84
  if (!cfg.usage.enabled) return "Usage display is disabled.";
61
85
  if (!isOpenAISubscriptionModel(ctx, cfg))
62
86
  return "Usage hidden: current model is not an OpenAI subscription model.";
63
- if (!this.usageSnapshot)
64
- return `Usage unavailable${this.usageError ? `: ${this.usageError}` : "."}`;
87
+ if (this.usageError) return `Usage unavailable: ${this.usageError}`;
88
+ if (!this.usageSnapshot || this.usageSnapshot.scope !== usageScopeForModel(ctx.model?.id))
89
+ return "Usage unavailable.";
65
90
  const stale =
66
91
  this.usageUpdatedAt && Date.now() - this.usageUpdatedAt > cfg.usage.refreshIntervalMs * 2
67
92
  ? ` | stale ${formatResetCountdown((Date.now() - this.usageUpdatedAt) / 1000)}`
@@ -87,74 +112,124 @@ export class UsageController {
87
112
  ].join("\n");
88
113
  }
89
114
 
115
+ private isGenerationCurrent(generation: number): boolean {
116
+ return !this.shuttingDown && generation === this.sessionGeneration;
117
+ }
118
+
119
+ private deactivateGeneration(generation: number): void {
120
+ if (generation !== this.sessionGeneration) return;
121
+ this.shuttingDown = true;
122
+ this.sessionGeneration++;
123
+ this.queuedUsageRefresh = undefined;
124
+ this.usageAbortController?.abort();
125
+ this.usageAbortController = undefined;
126
+ this.stopTimer();
127
+ }
128
+
129
+ private handleStaleContextError(error: unknown, generation: number): boolean {
130
+ if (!isStaleExtensionContextError(error)) return false;
131
+ this.deactivateGeneration(generation);
132
+ return true;
133
+ }
134
+
90
135
  async refresh(
91
136
  ctx: ExtensionContext,
92
- modelId = ctx.model?.id,
137
+ modelId?: string,
93
138
  options?: UsageRefreshOptions,
139
+ generation = this.sessionGeneration,
94
140
  ): Promise<void> {
95
- if (this.shuttingDown || !ctx.hasUI) return;
141
+ if (!this.isGenerationCurrent(generation)) return;
142
+
143
+ let resolvedModelId = modelId;
144
+ try {
145
+ if (!ctx.hasUI) return;
146
+ resolvedModelId ??= ctx.model?.id;
147
+ } catch (error) {
148
+ this.handleStaleContextError(error, generation);
149
+ return;
150
+ }
151
+
96
152
  if (this.usageRefreshInFlight) {
153
+ const queued =
154
+ this.queuedUsageRefresh?.generation === generation ? this.queuedUsageRefresh : undefined;
97
155
  this.queuedUsageRefresh = {
98
156
  ctx,
99
- modelId,
100
- notify: this.queuedUsageRefresh?.notify || options?.notify,
101
- force: this.queuedUsageRefresh?.force || options?.force,
157
+ generation,
158
+ modelId: resolvedModelId,
159
+ notify: queued?.notify || options?.notify,
160
+ force: queued?.force || options?.force,
102
161
  };
103
162
  return;
104
163
  }
164
+
105
165
  this.usageRefreshInFlight = true;
106
- const cfg = this.getConfig(ctx);
107
166
  try {
167
+ const cfg = this.getConfig(ctx);
168
+ if (!this.isGenerationCurrent(generation)) return;
169
+
108
170
  if (!cfg.usage.enabled) {
109
171
  this.usageSnapshot = undefined;
110
172
  this.usageError = "Usage display is disabled.";
111
- if (!this.shuttingDown) this.updateFooter(ctx);
112
- if (!this.shuttingDown && options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
173
+ this.updateFooter(ctx);
174
+ if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
113
175
  return;
114
176
  }
115
177
  if (!isOpenAISubscriptionModel(ctx, cfg)) {
116
- if (!this.shuttingDown) this.updateFooter(ctx);
117
- if (!this.shuttingDown && options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
178
+ this.updateFooter(ctx);
179
+ if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
118
180
  return;
119
181
  }
120
182
  const shouldThrottle =
121
183
  !options?.force &&
122
184
  !options?.notify &&
123
185
  this.usageLastFetchAt !== undefined &&
124
- Date.now() - this.usageLastFetchAt < cfg.usage.refreshIntervalMs &&
125
- this.usageSnapshot !== undefined &&
126
- this.usageError === undefined;
127
- if (shouldThrottle) {
128
- if (!this.shuttingDown) this.updateFooter(ctx);
129
- return;
130
- }
186
+ Date.now() - this.usageLastFetchAt < cfg.usage.refreshIntervalMs;
187
+ if (shouldThrottle) return;
188
+ this.usageLastFetchAt = Date.now();
131
189
  this.usageAbortController = new AbortController();
132
190
  const timeoutSignal = AbortSignal.timeout(10_000);
133
191
  const signal = ctx.signal
134
192
  ? AbortSignal.any([ctx.signal, timeoutSignal, this.usageAbortController.signal])
135
193
  : AbortSignal.any([timeoutSignal, this.usageAbortController.signal]);
136
194
  const data = await requestCodexUsage(ctx, signal);
137
- this.usageLastFetchAt = Date.now();
138
- this.usageSnapshot = data ? parseUsageSnapshot(data, modelId) : undefined;
195
+ if (!this.isGenerationCurrent(generation)) return;
196
+
197
+ this.usageSnapshot = data ? parseUsageSnapshot(data, resolvedModelId) : undefined;
139
198
  this.usageUpdatedAt = this.usageSnapshot ? Date.now() : undefined;
140
199
  this.usageError = data
141
200
  ? undefined
142
201
  : `Missing openai-codex OAuth credentials in ${AUTH_FILE}.`;
143
- if (!this.shuttingDown) this.updateFooter(ctx);
144
- if (!this.shuttingDown && options?.notify)
202
+ this.updateFooter(ctx);
203
+ if (options?.notify)
145
204
  ctx.ui.notify(this.formatStatus(ctx), this.usageSnapshot ? "info" : "warning");
146
205
  } catch (error) {
147
- if (this.shuttingDown) return;
148
- this.usageError = error instanceof Error ? error.message : String(error);
149
- this.updateFooter(ctx);
150
- if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
206
+ if (this.handleStaleContextError(error, generation) || !this.isGenerationCurrent(generation))
207
+ return;
208
+ this.usageError = sanitizeDiagnosticError(
209
+ error instanceof Error ? error.message : String(error),
210
+ );
211
+ try {
212
+ this.updateFooter(ctx);
213
+ if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
214
+ } catch (secondaryError) {
215
+ if (!this.handleStaleContextError(secondaryError, generation)) {
216
+ this.usageError = sanitizeDiagnosticError(
217
+ secondaryError instanceof Error ? secondaryError.message : String(secondaryError),
218
+ );
219
+ }
220
+ }
151
221
  } finally {
152
222
  this.usageAbortController = undefined;
153
223
  this.usageRefreshInFlight = false;
154
- if (!this.shuttingDown && this.queuedUsageRefresh) {
155
- const next = this.queuedUsageRefresh;
156
- this.queuedUsageRefresh = undefined;
157
- void this.refresh(next.ctx, next.modelId, { notify: next.notify, force: next.force });
224
+ const next = this.queuedUsageRefresh;
225
+ this.queuedUsageRefresh = undefined;
226
+ if (next && !this.shuttingDown && next.generation === this.sessionGeneration) {
227
+ void this.refresh(
228
+ next.ctx,
229
+ next.modelId,
230
+ { notify: next.notify, force: next.force },
231
+ next.generation,
232
+ );
158
233
  }
159
234
  }
160
235
  }
@@ -170,33 +245,42 @@ export class UsageController {
170
245
  }
171
246
 
172
247
  start(ctx: ExtensionContext): void {
173
- this.shuttingDown = false;
248
+ this.usageAbortController?.abort();
249
+ this.queuedUsageRefresh = undefined;
174
250
  this.stopTimer();
251
+ const generation = ++this.sessionGeneration;
252
+ this.shuttingDown = false;
253
+
175
254
  const cfg = this.getConfig(ctx);
176
255
  if (!cfg.usage.enabled) return;
177
256
  const sessionSignal = ctx.signal;
178
- if (sessionSignal?.aborted) return;
257
+ if (sessionSignal?.aborted) {
258
+ this.deactivateGeneration(generation);
259
+ return;
260
+ }
179
261
  this.sessionAbortSignal = sessionSignal;
180
262
  this.sessionAbortHandler = () => {
181
- this.stopTimer();
182
- this.usageAbortController?.abort();
183
- this.usageAbortController = undefined;
184
- this.queuedUsageRefresh = undefined;
263
+ this.deactivateGeneration(generation);
185
264
  };
186
265
  sessionSignal?.addEventListener("abort", this.sessionAbortHandler, { once: true });
187
- void this.refresh(ctx, undefined, { force: true });
266
+ void this.refresh(ctx, undefined, { force: true }, generation);
188
267
  this.usageTimer = setInterval(() => {
268
+ if (!this.isGenerationCurrent(generation)) return;
189
269
  if (sessionSignal?.aborted) {
190
- this.stopTimer();
270
+ this.deactivateGeneration(generation);
191
271
  return;
192
272
  }
193
- void this.refresh(ctx);
273
+ void this.refresh(ctx, undefined, undefined, generation);
194
274
  }, cfg.usage.refreshIntervalMs);
195
275
  this.usageTimer.unref?.();
196
276
  }
197
277
 
198
278
  restartAfterSettingsChange(ctx: ExtensionContext, cfg: ResolvedConfig): void {
279
+ this.usageAbortController?.abort();
280
+ this.queuedUsageRefresh = undefined;
199
281
  this.stopTimer();
282
+ this.sessionGeneration++;
283
+ this.shuttingDown = false;
200
284
  if (cfg.usage.enabled) this.start(ctx);
201
285
  else {
202
286
  this.usageSnapshot = undefined;
@@ -206,6 +290,7 @@ export class UsageController {
206
290
 
207
291
  shutdown(): void {
208
292
  this.shuttingDown = true;
293
+ this.sessionGeneration++;
209
294
  this.queuedUsageRefresh = undefined;
210
295
  this.usageAbortController?.abort();
211
296
  this.usageAbortController = undefined;