@bacnh85/pi-sub 0.1.37 → 0.1.38

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.38 (2026-09-07)
4
+
5
+ ### Fixed
6
+
7
+ - **Crash (pi exits) — third arm of the stale-ctx class**: pi 0.85.1 can
8
+ invalidate the extension ctx without ever delivering a matching
9
+ `session_shutdown` (orphaned/replaced runtime teardown; extension instances
10
+ are shared across sessions), so the 60s usage-refresh interval can fire with
11
+ `state.ctx` still installed but stale — the 0.1.18/0.1.37 guards (fire-time
12
+ ctx resolution, identity-guarded shutdown) never see it. `refreshUsage` is
13
+ async, so its throw became a rejected promise discarded by `void` →
14
+ `unhandledRejection` → pi's `uncaughtException` handler → exit. All deferred
15
+ refresh call sites now go through `deferRefresh`, which catches the
16
+ rejection and self-disarms (stops timers, drops in-flight state and the
17
+ stale ctx); `session_start` re-arms with the fresh ctx. Same crash class as
18
+ pi-messenger#25. The `/sub` command path disarms the same way when run
19
+ against an orphaned stale ctx, and regression tests now cover the 60s
20
+ interval arm, the command arm, and matcher independence from pi's exact
21
+ error wording.
22
+
3
23
  ## 0.1.37 (2026-09-07)
4
24
 
5
25
  ### Fixed
@@ -29,7 +29,7 @@ loadEnvFiles();
29
29
  const STATUS_KEY = "pi-sub";
30
30
  const MESSAGE_TYPE = "pi-sub-status";
31
31
  const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
32
- const REFRESH_INTERVAL_MS = 60_000;
32
+ export const REFRESH_INTERVAL_MS = 60_000;
33
33
  const REFRESH_TTL_MS = 30_000;
34
34
  export const REFRESH_DEBOUNCE_MS = 2_000;
35
35
  const CODEX_PROVIDER = "openai-codex";
@@ -1140,10 +1140,38 @@ export function renderSubscriptionLine(state: State): void {
1140
1140
  ctx.ui.setStatus(STATUS_KEY, theme.fg(color, line));
1141
1141
  }
1142
1142
 
1143
+ function isStaleCtxError(error: unknown): boolean {
1144
+ // pi 0.85.1's wording is "This extension ctx is stale after session
1145
+ // replacement or reload." (verified in the host bundle); "invalidated" is
1146
+ // matched too so wording drift degrades to a harmless extra disarm instead
1147
+ // of silently disabling recovery.
1148
+ return error instanceof Error && /\bctx is stale\b|invalidated/i.test(error.message);
1149
+ }
1150
+
1151
+ function selfDisarm(state: State): void {
1152
+ stopTimer(state);
1153
+ state.inFlight = undefined;
1154
+ state.refreshGeneration++;
1155
+ state.ctx = undefined;
1156
+ }
1157
+
1158
+ /** pi can invalidate state.ctx without ever delivering a matching
1159
+ * session_shutdown (pi 0.85.1 orphaned-runtime teardown; instances are shared
1160
+ * across sessions), so a deferred refresh can hit a stale ctx. refreshUsage
1161
+ * is async — its throw becomes a rejected promise, and a void-discarded
1162
+ * rejection exits pi (unhandledRejection -> uncaughtException). Catch it and
1163
+ * self-disarm; session_start re-arms with the fresh ctx. Non-stale
1164
+ * rejections are swallowed: adapters already resolve error snapshots. */
1165
+ function deferRefresh(state: State, force: boolean): void {
1166
+ refreshUsage(state, force).catch((error) => {
1167
+ if (isStaleCtxError(error)) selfDisarm(state);
1168
+ });
1169
+ }
1170
+
1143
1171
  function startTimer(state: State): void {
1144
1172
  if (state.refreshTimer || !state.adapter) return;
1145
1173
  state.refreshTimer = setInterval(() => {
1146
- void refreshUsage(state, false);
1174
+ deferRefresh(state, false);
1147
1175
  }, REFRESH_INTERVAL_MS);
1148
1176
  }
1149
1177
 
@@ -1208,7 +1236,7 @@ export function scheduleRefresh(state: State): void {
1208
1236
  if (state.debounceTimer) clearTimeout(state.debounceTimer);
1209
1237
  state.debounceTimer = setTimeout(() => {
1210
1238
  state.debounceTimer = undefined;
1211
- void refreshUsage(state, true);
1239
+ deferRefresh(state, true);
1212
1240
  }, REFRESH_DEBOUNCE_MS);
1213
1241
  }
1214
1242
 
@@ -1298,12 +1326,12 @@ export default function (pi: ExtensionAPI) {
1298
1326
  // need to — and a late old-session event must not reinstall a stale ctx.
1299
1327
  state.ctx = ctx;
1300
1328
  updateActiveAdapter(state, ctx.model);
1301
- if (state.adapter) void refreshUsage(state, true);
1329
+ if (state.adapter) deferRefresh(state, true);
1302
1330
  });
1303
1331
 
1304
1332
  pi.on("model_select", async (event, _ctx) => {
1305
1333
  updateActiveAdapter(state, event.model);
1306
- if (state.adapter) void refreshUsage(state, true);
1334
+ if (state.adapter) deferRefresh(state, true);
1307
1335
  });
1308
1336
 
1309
1337
  pi.on("before_provider_request", async (_event, _ctx) => {
@@ -1364,16 +1392,23 @@ export default function (pi: ExtensionAPI) {
1364
1392
  return items.length > 0 ? items : null;
1365
1393
  },
1366
1394
  handler: async (args, ctx) => {
1367
- updateActiveAdapter(state, ctx.model);
1368
- const command = args.trim().toLowerCase();
1369
- const force = command === "refresh";
1370
- const snapshot = state.adapter ? await refreshUsage(state, force || !state.snapshot) : undefined;
1371
- const details = buildDetails(snapshot ?? state.snapshot, state);
1372
- pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
1373
- // state.ctx (not captured ctx): the session could be replaced during the
1374
- // await above; if it was, skip the notification instead of touching a
1375
- // stale ctx.
1376
- if (force) state.ctx?.ui.notify("Subscription usage refreshed", "info");
1395
+ try {
1396
+ updateActiveAdapter(state, ctx.model);
1397
+ const command = args.trim().toLowerCase();
1398
+ const force = command === "refresh";
1399
+ const snapshot = state.adapter ? await refreshUsage(state, force || !state.snapshot) : undefined;
1400
+ const details = buildDetails(snapshot ?? state.snapshot, state);
1401
+ pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
1402
+ // state.ctx (not captured ctx): the session could be replaced during the
1403
+ // await above; if it was, skip the notification instead of touching a
1404
+ // stale ctx.
1405
+ if (force) state.ctx?.ui.notify("Subscription usage refreshed", "info");
1406
+ } catch (error) {
1407
+ // Orphaned stale ctx (invalidated without shutdown): disarm like the
1408
+ // deferred paths instead of throwing into pi's dispatcher.
1409
+ if (!isStaleCtxError(error)) throw error;
1410
+ selfDisarm(state);
1411
+ }
1377
1412
  },
1378
1413
  });
1379
1414
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",