@narumitw/pi-codex-usage 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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/codex-usage.ts +127 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-codex-usage",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Pi extension that shows Codex ChatGPT subscription usage without requiring Codex CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@biomejs/biome": "2.4.14",
33
- "@earendil-works/pi-coding-agent": "0.74.0",
33
+ "@earendil-works/pi-coding-agent": "0.79.8",
34
34
  "@types/node": "25.6.0",
35
35
  "typescript": "6.0.3"
36
36
  },
@@ -143,6 +143,8 @@ export default function codexUsage(pi: ExtensionAPI) {
143
143
  let statuslineClearTimer: ReturnType<typeof setTimeout> | undefined;
144
144
  let statuslineRefreshTimer: ReturnType<typeof setTimeout> | undefined;
145
145
  let statuslineRequestId = 0;
146
+ let sessionActive = false;
147
+ let activeStatuslineContext: ExtensionContext | undefined;
146
148
 
147
149
  const clearStatuslineTimers = () => {
148
150
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
@@ -151,25 +153,57 @@ export default function codexUsage(pi: ExtensionAPI) {
151
153
  statuslineRefreshTimer = undefined;
152
154
  };
153
155
 
156
+ const handleStaleContextError = (ctx: ExtensionContext, error: unknown): boolean => {
157
+ if (!isStaleExtensionContextError(error)) return false;
158
+ if (ctx === activeStatuslineContext) {
159
+ statuslineRequestId += 1;
160
+ clearStatuslineTimers();
161
+ activeStatuslineContext = undefined;
162
+ }
163
+ return true;
164
+ };
165
+
166
+ const rethrowUnlessStaleContextError = (ctx: ExtensionContext) => (error: unknown) => {
167
+ if (!handleStaleContextError(ctx, error)) throw error;
168
+ };
169
+
170
+ const setStatuslineValue = (ctx: ExtensionContext, value: string | undefined): boolean => {
171
+ try {
172
+ ctx.ui.setStatus(STATUS_KEY, value);
173
+ return true;
174
+ } catch (error) {
175
+ if (handleStaleContextError(ctx, error)) return false;
176
+ throw error;
177
+ }
178
+ };
179
+
154
180
  const clearUsageStatusline = (ctx: ExtensionContext) => {
155
181
  statuslineRequestId += 1;
156
182
  clearStatuslineTimers();
157
- ctx.ui.setStatus(STATUS_KEY, undefined);
183
+ activeStatuslineContext = undefined;
184
+ setStatuslineValue(ctx, undefined);
158
185
  };
159
186
 
160
187
  const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
161
188
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
189
+ const requestId = statuslineRequestId;
162
190
  statuslineClearTimer = setTimeout(() => {
163
- ctx.ui.setStatus(STATUS_KEY, undefined);
164
191
  statuslineClearTimer = undefined;
192
+ if (!sessionActive || requestId !== statuslineRequestId) return;
193
+ setStatuslineValue(ctx, undefined);
165
194
  }, CACHE_TTL_MS);
166
195
  statuslineClearTimer.unref?.();
167
196
  };
168
197
 
169
- const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
198
+ const scheduleStatuslineRefresh = (ctx: ExtensionContext, model: CodexUsageModel | undefined) => {
170
199
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
200
+ const requestId = statuslineRequestId;
171
201
  statuslineRefreshTimer = setTimeout(() => {
172
- void refreshCurrentCodexUsageStatusline(ctx, true);
202
+ statuslineRefreshTimer = undefined;
203
+ if (!sessionActive || requestId !== statuslineRequestId) return;
204
+ void refreshCurrentCodexUsageStatusline(ctx, true, model).catch(
205
+ rethrowUnlessStaleContextError(ctx),
206
+ );
173
207
  }, CACHE_TTL_MS);
174
208
  statuslineRefreshTimer.unref?.();
175
209
  };
@@ -179,19 +213,23 @@ export default function codexUsage(pi: ExtensionAPI) {
179
213
  report: CodexUsageReport,
180
214
  options: { autoRefresh: boolean; model: CodexUsageModel | undefined },
181
215
  ) => {
216
+ if (!setStatuslineValue(ctx, formatCodexUsageStatusline(report, options.model))) return;
217
+ activeStatuslineContext = ctx;
182
218
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
183
219
  statuslineClearTimer = undefined;
184
- ctx.ui.setStatus(STATUS_KEY, formatCodexUsageStatusline(report, options.model));
185
- if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
220
+ if (options.autoRefresh) scheduleStatuslineRefresh(ctx, options.model);
186
221
  else scheduleTemporaryStatuslineClear(ctx);
187
222
  };
188
223
 
189
224
  const refreshCurrentCodexUsageStatusline = async (
190
225
  ctx: ExtensionContext,
191
226
  force: boolean,
192
- model = ctx.model,
227
+ model?: CodexUsageModel,
193
228
  ) => {
194
- if (!isOpenAICodexModel(model)) {
229
+ if (!sessionActive) return;
230
+ activeStatuslineContext = ctx;
231
+ const selectedModel = model ?? ctx.model;
232
+ if (!isOpenAICodexModel(selectedModel)) {
195
233
  clearUsageStatusline(ctx);
196
234
  return;
197
235
  }
@@ -200,98 +238,117 @@ export default function codexUsage(pi: ExtensionAPI) {
200
238
  statuslineRequestId = requestId;
201
239
  const cached = cache && Date.now() - cache.createdAt < CACHE_TTL_MS ? cache : undefined;
202
240
  if (cached && !force) {
203
- setUsageStatusline(ctx, cached.report, { autoRefresh: true, model });
241
+ setUsageStatusline(ctx, cached.report, { autoRefresh: true, model: selectedModel });
204
242
  return;
205
243
  }
206
244
 
207
- ctx.ui.setStatus(STATUS_KEY, "📊 checking");
245
+ if (!setStatuslineValue(ctx, "📊 checking")) return;
208
246
  const result = await queryUsage(ctx, { timeoutMs: DEFAULT_TIMEOUT_MS });
209
- if (requestId !== statuslineRequestId) return;
210
- if (!isOpenAICodexModel(ctx.model)) {
211
- clearUsageStatusline(ctx);
212
- return;
213
- }
247
+ if (!sessionActive || requestId !== statuslineRequestId) return;
214
248
 
215
249
  if (!result.ok) {
216
- ctx.ui.setStatus(STATUS_KEY, "📊 usage error");
217
- scheduleStatuslineRefresh(ctx);
250
+ if (setStatuslineValue(ctx, "📊 usage error")) {
251
+ scheduleStatuslineRefresh(ctx, selectedModel);
252
+ }
218
253
  return;
219
254
  }
220
255
 
221
256
  cache = { createdAt: Date.now(), report: result.report };
222
- setUsageStatusline(ctx, result.report, { autoRefresh: true, model });
257
+ setUsageStatusline(ctx, result.report, { autoRefresh: true, model: selectedModel });
223
258
  };
224
259
 
225
260
  pi.registerCommand(COMMAND_NAME, {
226
261
  description: "Show Codex ChatGPT subscription usage and rate-limit windows",
227
262
  handler: async (args, ctx) => {
228
- const options = parseArgs(args);
229
- if (!options.ok) {
230
- ctx.ui.notify(options.error, "warning");
231
- return;
232
- }
233
-
234
- if (options.value.clearStatusline) {
235
- clearUsageStatusline(ctx);
236
- ctx.ui.notify("Codex usage statusline cleared.", "info");
237
- return;
238
- }
263
+ try {
264
+ const options = parseArgs(args);
265
+ if (!options.ok) {
266
+ ctx.ui.notify(options.error, "warning");
267
+ return;
268
+ }
239
269
 
240
- const cached = cache && Date.now() - cache.createdAt < CACHE_TTL_MS ? cache : undefined;
241
- if (cached && !options.value.refresh) {
242
- if (options.value.statusline) {
243
- setUsageStatusline(ctx, cached.report, {
244
- autoRefresh: isOpenAICodexModel(ctx.model),
245
- model: ctx.model,
246
- });
270
+ if (options.value.clearStatusline) {
271
+ clearUsageStatusline(ctx);
272
+ ctx.ui.notify("Codex usage statusline cleared.", "info");
273
+ return;
247
274
  }
248
- showReport(ctx, cached.report, true);
249
- return;
250
- }
251
275
 
252
- let keepStatusline = false;
253
- if (options.value.statusline) ctx.ui.setStatus(STATUS_KEY, "📊 checking");
254
- try {
255
- const result = await queryUsage(ctx, options.value);
256
- if (!result.ok) {
257
- ctx.ui.notify(formatQueryErrors(result.errors), "error");
276
+ const cached = cache && Date.now() - cache.createdAt < CACHE_TTL_MS ? cache : undefined;
277
+ if (cached && !options.value.refresh) {
278
+ if (options.value.statusline) {
279
+ setUsageStatusline(ctx, cached.report, {
280
+ autoRefresh: isOpenAICodexModel(ctx.model),
281
+ model: ctx.model,
282
+ });
283
+ }
284
+ showReport(ctx, cached.report, true);
258
285
  return;
259
286
  }
260
287
 
261
- cache = { createdAt: Date.now(), report: result.report };
262
- if (options.value.statusline) {
263
- setUsageStatusline(ctx, result.report, {
264
- autoRefresh: isOpenAICodexModel(ctx.model),
265
- model: ctx.model,
266
- });
267
- keepStatusline = true;
288
+ let keepStatusline = false;
289
+ const statuslineStarted =
290
+ options.value.statusline && setStatuslineValue(ctx, "📊 checking");
291
+ try {
292
+ const result = await queryUsage(ctx, options.value);
293
+ if (!result.ok) {
294
+ ctx.ui.notify(formatQueryErrors(result.errors), "error");
295
+ return;
296
+ }
297
+
298
+ cache = { createdAt: Date.now(), report: result.report };
299
+ if (options.value.statusline) {
300
+ setUsageStatusline(ctx, result.report, {
301
+ autoRefresh: isOpenAICodexModel(ctx.model),
302
+ model: ctx.model,
303
+ });
304
+ keepStatusline = true;
305
+ }
306
+ showReport(ctx, result.report, false);
307
+ } finally {
308
+ if (statuslineStarted && !keepStatusline) setStatuslineValue(ctx, undefined);
268
309
  }
269
- showReport(ctx, result.report, false);
270
- } finally {
271
- if (options.value.statusline && !keepStatusline) ctx.ui.setStatus(STATUS_KEY, undefined);
310
+ } catch (error) {
311
+ if (handleStaleContextError(ctx, error)) return;
312
+ throw error;
272
313
  }
273
314
  },
274
315
  });
275
316
 
276
317
  pi.on("session_start", (_event, ctx) => {
277
- if (isOpenAICodexModel(ctx.model)) void refreshCurrentCodexUsageStatusline(ctx, false);
278
- else clearUsageStatusline(ctx);
318
+ sessionActive = true;
319
+ if (isOpenAICodexModel(ctx.model)) {
320
+ void refreshCurrentCodexUsageStatusline(ctx, false, ctx.model).catch(
321
+ rethrowUnlessStaleContextError(ctx),
322
+ );
323
+ } else {
324
+ clearUsageStatusline(ctx);
325
+ }
279
326
  });
280
327
 
281
328
  pi.on("session_tree", (_event, ctx) => {
282
- if (isOpenAICodexModel(ctx.model)) void refreshCurrentCodexUsageStatusline(ctx, false);
283
- else clearUsageStatusline(ctx);
329
+ if (isOpenAICodexModel(ctx.model)) {
330
+ void refreshCurrentCodexUsageStatusline(ctx, false, ctx.model).catch(
331
+ rethrowUnlessStaleContextError(ctx),
332
+ );
333
+ } else {
334
+ clearUsageStatusline(ctx);
335
+ }
284
336
  });
285
337
 
286
338
  pi.on("model_select", (event, ctx) => {
287
339
  if (isOpenAICodexModel(event.model)) {
288
- void refreshCurrentCodexUsageStatusline(ctx, false, event.model);
340
+ void refreshCurrentCodexUsageStatusline(ctx, false, event.model).catch(
341
+ rethrowUnlessStaleContextError(ctx),
342
+ );
289
343
  } else {
290
344
  clearUsageStatusline(ctx);
291
345
  }
292
346
  });
293
347
 
294
- pi.on("session_shutdown", (_event, ctx) => clearUsageStatusline(ctx));
348
+ pi.on("session_shutdown", (_event, ctx) => {
349
+ sessionActive = false;
350
+ clearUsageStatusline(ctx);
351
+ });
295
352
  }
296
353
 
297
354
  export function parseArgs(
@@ -342,6 +399,13 @@ function isOpenAICodexModel(model: Pick<PiModel, "provider"> | undefined): boole
342
399
  return model?.provider === CODEX_PROVIDER_ID;
343
400
  }
344
401
 
402
+ export function isStaleExtensionContextError(error: unknown): boolean {
403
+ return (
404
+ error instanceof Error &&
405
+ error.message.includes("This extension ctx is stale after session replacement or reload")
406
+ );
407
+ }
408
+
345
409
  async function queryUsage(
346
410
  ctx: ExtensionContext,
347
411
  options: Pick<QueryUsageOptions, "timeoutMs">,
@@ -352,6 +416,7 @@ async function queryUsage(
352
416
  const report = await queryViaPiAuth(ctx, options.timeoutMs);
353
417
  return { ok: true, report };
354
418
  } catch (cause) {
419
+ if (isStaleExtensionContextError(cause)) throw cause;
355
420
  errors.push({ source: "pi-auth", message: errorMessage(cause), cause });
356
421
  }
357
422
 
@@ -359,6 +424,7 @@ async function queryUsage(
359
424
  const report = await queryViaCodexAppServer(options.timeoutMs);
360
425
  return { ok: true, report };
361
426
  } catch (cause) {
427
+ if (isStaleExtensionContextError(cause)) throw cause;
362
428
  errors.push({ source: "codex-app-server", message: errorMessage(cause), cause });
363
429
  }
364
430