@llblab/pi-codex-usage 0.8.0 → 0.8.1

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/BACKLOG.md CHANGED
@@ -1,3 +1,7 @@
1
1
  # Backlog
2
2
 
3
- No open work.
3
+ ## Pending Release Verification
4
+
5
+ - [x] Fix stale extension context usage after session replacement or reload. Source: GitHub issue #1. Timers must not keep using captured stale `ctx` after Pi session lifecycle changes.
6
+ - [x] Make usage refresh cache/in-flight state model-aware for Codex vs Spark. A regular Codex cache or request must not temporarily render Spark as `n/a` after model switching.
7
+ - [x] Preserve the active `codex`/`spark` label for failure states. Spark failures should render `spark n/a` or `spark error`, not `codex ...`.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.1: Stale Context Hotfix
6
+
7
+ - Fixed stale extension context crashes from delayed statusline timers after Pi session replacement or reload. Impact: timers now ignore stale `ctx` failures instead of letting issue #1 crash Pi after idle/session lifecycle changes.
8
+ - Fixed Codex/Spark refresh state to key in-flight usage lookups by active quota bucket and only reuse cached reports that contain the active bucket. Impact: switching between regular Codex and Spark no longer temporarily renders the active bucket as unavailable because of a stale request/cache.
9
+ - Fixed Spark failure labels. Impact: Spark unavailable/error states now render with the `spark` label instead of falling back to `codex`.
10
+ - Added regression coverage for active-bucket cache reuse and stale extension context error detection.
11
+
5
12
  ## 0.8.0: Spark Model Usage Status
6
13
 
7
14
  - Added active-bucket status support for `GPT-5.3-Codex-Spark`. Impact: selecting the Spark Codex subscription model now shows the parallel Spark rate-limit bucket with a `spark` label, while regular Codex models keep the existing `codex` bucket/status behavior.
package/index.ts CHANGED
@@ -163,7 +163,9 @@ type PendingRpc = {
163
163
  export default function codexUsage(pi: ExtensionAPI) {
164
164
  let cache: CachedReport | undefined;
165
165
  let failedRefreshes = 0;
166
- let inFlightUsageQuery: Promise<QueryUsageResult> | undefined;
166
+ let inFlightUsageQuery:
167
+ | { limitId: string; promise: Promise<QueryUsageResult> }
168
+ | undefined;
167
169
  let statuslineBlinkTimer: TimeoutHandle | undefined;
168
170
  let statuslineClearTimer: TimeoutHandle | undefined;
169
171
  let statuslineCountdownTimer: TimeoutHandle | undefined;
@@ -210,8 +212,12 @@ export default function codexUsage(pi: ExtensionAPI) {
210
212
  const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
211
213
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
212
214
  statuslineClearTimer = setTimeout(() => {
213
- ctx.ui.setStatus(STATUS_KEY, undefined);
214
- statuslineClearTimer = undefined;
215
+ try {
216
+ ctx.ui.setStatus(STATUS_KEY, undefined);
217
+ statuslineClearTimer = undefined;
218
+ } catch (error) {
219
+ handleTimerError(error);
220
+ }
215
221
  }, REFRESH_INTERVAL_MS) as TimeoutHandle;
216
222
  statuslineClearTimer.unref?.();
217
223
  };
@@ -219,7 +225,7 @@ export default function codexUsage(pi: ExtensionAPI) {
219
225
  const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
220
226
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
221
227
  statuslineRefreshTimer = setTimeout(() => {
222
- void refreshCurrentCodexUsageStatusline(ctx, true);
228
+ void refreshCurrentCodexUsageStatusline(ctx, true).catch(handleAsyncTimerError);
223
229
  }, REFRESH_INTERVAL_MS) as TimeoutHandle;
224
230
  statuslineRefreshTimer.unref?.();
225
231
  };
@@ -236,12 +242,16 @@ export default function codexUsage(pi: ExtensionAPI) {
236
242
  return;
237
243
  }
238
244
  statuslineCountdownTimer = setTimeout(() => {
239
- if (isOpenAICodexModel(ctx.model)) {
240
- ctx.ui.setStatus(
241
- STATUS_KEY,
242
- formatCodexUsageStatusline(report, ctx, model),
243
- );
244
- scheduleStatuslineCountdown(ctx, report, model);
245
+ try {
246
+ if (isOpenAICodexModel(ctx.model)) {
247
+ ctx.ui.setStatus(
248
+ STATUS_KEY,
249
+ formatCodexUsageStatusline(report, ctx, model),
250
+ );
251
+ scheduleStatuslineCountdown(ctx, report, model);
252
+ }
253
+ } catch (error) {
254
+ handleTimerError(error);
245
255
  }
246
256
  }, delayMs) as TimeoutHandle;
247
257
  statuslineCountdownTimer.unref?.();
@@ -266,9 +276,13 @@ export default function codexUsage(pi: ExtensionAPI) {
266
276
  if (options.blink) {
267
277
  ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
268
278
  statuslineBlinkTimer = setTimeout(() => {
269
- ctx.ui.setStatus(STATUS_KEY, text);
270
- scheduleStatuslineCountdown(ctx, report, options.model);
271
- statuslineBlinkTimer = undefined;
279
+ try {
280
+ ctx.ui.setStatus(STATUS_KEY, text);
281
+ scheduleStatuslineCountdown(ctx, report, options.model);
282
+ statuslineBlinkTimer = undefined;
283
+ } catch (error) {
284
+ handleTimerError(error);
285
+ }
272
286
  }, REDRAW_BLINK_MS) as TimeoutHandle;
273
287
  statuslineBlinkTimer.unref?.();
274
288
  } else {
@@ -283,74 +297,87 @@ export default function codexUsage(pi: ExtensionAPI) {
283
297
  ctx: ExtensionContext,
284
298
  model: CodexUsageModel | undefined,
285
299
  ) => {
286
- if (!inFlightUsageQuery) {
287
- inFlightUsageQuery = queryUsage(
288
- ctx,
289
- { timeoutMs: DEFAULT_TIMEOUT_MS },
290
- model,
291
- ).finally(() => {
292
- inFlightUsageQuery = undefined;
293
- });
294
- }
295
- return inFlightUsageQuery;
300
+ const limitId = activeUsageLimitId(model);
301
+ if (inFlightUsageQuery?.limitId === limitId) return inFlightUsageQuery.promise;
302
+ const promise = queryUsage(
303
+ ctx,
304
+ { timeoutMs: DEFAULT_TIMEOUT_MS },
305
+ model,
306
+ ).finally(() => {
307
+ if (inFlightUsageQuery?.promise === promise) inFlightUsageQuery = undefined;
308
+ });
309
+ inFlightUsageQuery = { limitId, promise };
310
+ return promise;
296
311
  };
297
312
 
298
313
  const refreshCurrentCodexUsageStatusline = async (
299
314
  ctx: ExtensionContext,
300
315
  force: boolean,
301
- model = ctx.model,
316
+ model?: CodexUsageModel,
302
317
  ) => {
303
- if (!isOpenAICodexModel(model)) {
304
- clearUsageStatusline(ctx);
305
- return;
306
- }
318
+ try {
319
+ const activeModel = model ?? ctx.model;
320
+ if (!isOpenAICodexModel(activeModel)) {
321
+ clearUsageStatusline(ctx);
322
+ return;
323
+ }
307
324
 
308
- if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
309
- const requestId = statuslineRequestId + 1;
310
- statuslineRequestId = requestId;
311
- const cached =
312
- cache && Date.now() - cache.createdAt < REFRESH_INTERVAL_MS
313
- ? cache
314
- : undefined;
315
- if (cached && !force) {
316
- setUsageStatusline(ctx, cached.report, {
317
- autoRefresh: true,
318
- blink: false,
319
- model,
320
- });
321
- return;
322
- }
325
+ if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
326
+ const requestId = statuslineRequestId + 1;
327
+ statuslineRequestId = requestId;
328
+ const cached =
329
+ cache &&
330
+ Date.now() - cache.createdAt < REFRESH_INTERVAL_MS &&
331
+ canReuseCachedReport(cache.report, activeModel)
332
+ ? cache
333
+ : undefined;
334
+ if (cached && !force) {
335
+ setUsageStatusline(ctx, cached.report, {
336
+ autoRefresh: true,
337
+ blink: false,
338
+ model: activeModel,
339
+ });
340
+ return;
341
+ }
323
342
 
324
- const result = await queryCurrentUsage(ctx, model);
325
- if (requestId !== statuslineRequestId) return;
326
- if (!isOpenAICodexModel(ctx.model)) {
327
- clearUsageStatusline(ctx);
328
- return;
329
- }
343
+ const result = await queryCurrentUsage(ctx, activeModel);
344
+ if (requestId !== statuslineRequestId) return;
345
+ if (!isOpenAICodexModel(ctx.model)) {
346
+ clearUsageStatusline(ctx);
347
+ return;
348
+ }
330
349
 
331
- if (!result.ok) {
332
- failedRefreshes += 1;
333
- if (!cache || failedRefreshes >= 5) {
334
- ctx.ui.setStatus(
335
- STATUS_KEY,
336
- formatStatuslineProblem(ctx, result.errors),
337
- );
350
+ if (!result.ok) {
351
+ failedRefreshes += 1;
352
+ if (!cache || failedRefreshes >= 5) {
353
+ ctx.ui.setStatus(
354
+ STATUS_KEY,
355
+ formatStatuslineProblem(ctx, result.errors, activeModel),
356
+ );
357
+ }
358
+ scheduleStatuslineRefresh(ctx);
359
+ return;
338
360
  }
339
- scheduleStatuslineRefresh(ctx);
340
- return;
341
- }
342
361
 
343
- const previousReport = cache?.report;
344
- const blink = previousReport
345
- ? formatReportBar(previousReport) !== formatReportBar(result.report)
346
- : false;
347
- failedRefreshes = 0;
348
- cache = { createdAt: Date.now(), report: result.report };
349
- setUsageStatusline(ctx, result.report, {
350
- autoRefresh: true,
351
- blink,
352
- model,
353
- });
362
+ const previousReport = cache?.report;
363
+ const blink = previousReport
364
+ ? formatReportBar(previousReport, activeModel) !==
365
+ formatReportBar(result.report, activeModel)
366
+ : false;
367
+ failedRefreshes = 0;
368
+ cache = { createdAt: Date.now(), report: result.report };
369
+ setUsageStatusline(ctx, result.report, {
370
+ autoRefresh: true,
371
+ blink,
372
+ model: activeModel,
373
+ });
374
+ } catch (error) {
375
+ if (isStaleExtensionContextError(error)) {
376
+ clearStatuslineTimers();
377
+ return;
378
+ }
379
+ throw error;
380
+ }
354
381
  };
355
382
 
356
383
  ensureTelegramStatusLineRegistered();
@@ -358,19 +385,19 @@ export default function codexUsage(pi: ExtensionAPI) {
358
385
  pi.on("session_start", (_event, ctx) => {
359
386
  ensureTelegramStatusLineRegistered();
360
387
  if (isOpenAICodexModel(ctx.model))
361
- void refreshCurrentCodexUsageStatusline(ctx, false);
388
+ void refreshCurrentCodexUsageStatusline(ctx, false).catch(handleAsyncTimerError);
362
389
  else clearUsageStatusline(ctx);
363
390
  });
364
391
 
365
392
  pi.on("session_tree", (_event, ctx) => {
366
393
  if (isOpenAICodexModel(ctx.model))
367
- void refreshCurrentCodexUsageStatusline(ctx, false);
394
+ void refreshCurrentCodexUsageStatusline(ctx, false).catch(handleAsyncTimerError);
368
395
  else clearUsageStatusline(ctx);
369
396
  });
370
397
 
371
398
  pi.on("model_select", (event, ctx) => {
372
399
  if (isOpenAICodexModel(event.model)) {
373
- void refreshCurrentCodexUsageStatusline(ctx, false, event.model);
400
+ void refreshCurrentCodexUsageStatusline(ctx, false, event.model).catch(handleAsyncTimerError);
374
401
  } else {
375
402
  clearUsageStatusline(ctx);
376
403
  }
@@ -383,6 +410,19 @@ export default function codexUsage(pi: ExtensionAPI) {
383
410
  });
384
411
  }
385
412
 
413
+ function handleAsyncTimerError(error: unknown): void {
414
+ handleTimerError(error);
415
+ }
416
+
417
+ function handleTimerError(error: unknown): void {
418
+ if (isStaleExtensionContextError(error)) return;
419
+ throw error;
420
+ }
421
+
422
+ export function isStaleExtensionContextError(error: unknown): boolean {
423
+ return error instanceof Error && error.message.includes("ctx is stale");
424
+ }
425
+
386
426
  function isOpenAICodexModel(
387
427
  model: Pick<PiModel, "provider"> | undefined,
388
428
  ): boolean {
@@ -1084,8 +1124,9 @@ function formatEmptyStatuslineBar(ctx: ExtensionContext): string {
1084
1124
  function formatStatuslineProblem(
1085
1125
  ctx: ExtensionContext,
1086
1126
  errors: UsageQueryError[],
1127
+ model?: CodexUsageModel,
1087
1128
  ): string {
1088
- const label = ctx.ui.theme.fg("accent", DEFAULT_STATUS_LABEL_TEXT);
1129
+ const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
1089
1130
  const value = isUsageUnavailable(errors)
1090
1131
  ? ctx.ui.theme.fg("muted", "n/a")
1091
1132
  : ctx.ui.theme.fg("error", "error");
@@ -1179,6 +1220,13 @@ function hasExhaustedQuotaWindow(
1179
1220
  );
1180
1221
  }
1181
1222
 
1223
+ export function canReuseCachedReport(
1224
+ report: CodexUsageReport,
1225
+ model: CodexUsageModel | undefined,
1226
+ ): boolean {
1227
+ return selectActiveUsageSnapshot(report, model) !== undefined;
1228
+ }
1229
+
1182
1230
  function selectActiveUsageSnapshot(
1183
1231
  report: CodexUsageReport,
1184
1232
  model: CodexUsageModel | undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
6
6
  "keywords": [