@bacnh85/pi-sub 0.1.36 → 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,60 @@
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
+
23
+ ## 0.1.37 (2026-09-07)
24
+
25
+ ### Fixed
26
+
27
+ - **Legible provider API errors**: structured API failures (e.g. Z.ai's
28
+ `500 Internal service error` returned by `api.z.ai/api/monitor/usage/quota/limit`
29
+ during its 2026-09-07 outage) no longer masquerade as a generic
30
+ "usage unavailable" — the footer shows the server's own message
31
+ (`Sub Z.ai (Anthropic) API error: Internal service error`), with
32
+ credential-shaped material (`sk-…`, `Bearer …`, JWTs) scrubbed and length
33
+ capped; auth-looking messages stay redacted.
34
+ - **Crash (pi exits) on session replacement (`/new`, fork, switch, `/reload`)**:
35
+ a usage-refresh debounce timer armed by a late `after_provider_response` event
36
+ — delivered after `session_shutdown` had already cleared the previous timers —
37
+ captured the old extension ctx; ~2s later the timer fired, touched the now
38
+ stale `ctx.ui`, and the uncaught error killed pi. Deferred helpers
39
+ (`renderSubscriptionLine`, `refreshUsage`, `startTimer`, `scheduleRefresh`,
40
+ `updateActiveAdapter`) no longer take a captured ctx: they resolve `state.ctx`
41
+ at execution time. `state.ctx` is installed only by `session_start` (fires
42
+ before a session's other events, always fresh) and cleared by
43
+ `session_shutdown`, so mid-session handlers can never reinstall a stale ctx
44
+ and timers firing in the teardown window safely no-op. `session_shutdown`
45
+ now no-ops entirely unless it belongs to the installed session
46
+ (`state.ctx === ctx`), so a late old-session shutdown delivered after the
47
+ next `session_start` can neither touch an invalidated ctx nor stop the live
48
+ session's refresh timer / drop its in-flight fetch. Regression tests in
49
+ `extensions/test/stale-ctx-regression.test.ts`.
50
+
51
+ ### Changed
52
+
53
+ - **Tests are now gated**: `npm test` runs both test files via
54
+ `node --import tsx --test` (new `tsx` devDependency), and CI runs
55
+ `npm ci && npm test` for pi-sub instead of only `npm pack --dry-run` — the
56
+ stale-ctx regression tests can no longer be bypassed by a refactor.
57
+
3
58
  ## 0.1.36 (2026-09-07)
4
59
 
5
60
  ### Fixed
@@ -29,9 +29,9 @@ 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
- const REFRESH_DEBOUNCE_MS = 2_000;
34
+ export const REFRESH_DEBOUNCE_MS = 2_000;
35
35
  const CODEX_PROVIDER = "openai-codex";
36
36
  const OPC_PROVIDER = "opencode-go";
37
37
  const ZAI_PROVIDER = "zai";
@@ -115,10 +115,11 @@ type SubscriptionProviderAdapter = {
115
115
  fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot>;
116
116
  };
117
117
 
118
- interface State {
118
+ export interface State {
119
119
  model?: ModelLike;
120
120
  adapter?: SubscriptionProviderAdapter;
121
121
  adapterId?: string;
122
+ ctx?: ExtensionContext;
122
123
  snapshot?: SubscriptionUsageSnapshot;
123
124
  lastRefreshAt: number;
124
125
  refreshGeneration: number;
@@ -507,7 +508,11 @@ async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): P
507
508
  return parseUsageResponse(await response.json());
508
509
  }
509
510
 
510
- function redactedError(error: unknown, provider = "Codex"): string {
511
+ // ponytail: shared redaction — show provider + failure class, never leak keys.
512
+ // Structured API errors carry the server's own message (e.g. Z.ai's
513
+ // "Internal service error" outage); auth-looking messages were already
514
+ // redacted above, so surface the rest verbatim for diagnosability.
515
+ export function redactedError(error: unknown, provider = "Codex"): string {
511
516
  const message = error instanceof Error ? error.message : String(error || "Unknown error");
512
517
  if (/ENOENT|no such file/i.test(message)) return "Pi auth not found";
513
518
  if (/missing openai-codex/i.test(message)) return "openai-codex auth not found";
@@ -516,7 +521,14 @@ function redactedError(error: unknown, provider = "Codex"): string {
516
521
  if (/missing commandcode/i.test(message)) return "commandcode auth not found";
517
522
  if (/timed out|timeout|aborted/i.test(message)) return `${provider} usage refresh timed out`;
518
523
  if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return `${provider} auth unavailable`;
519
- return `${provider} usage unavailable`;
524
+ const apiMatch = / API error: (.+)$/.exec(message);
525
+ if (!apiMatch) return `${provider} usage unavailable`;
526
+ // Trust boundary: the msg is remote-controlled — scrub credential-shaped
527
+ // material and cap length before it reaches the status bar.
528
+ const scrubbed = apiMatch[1]
529
+ .replace(/sk-[A-Za-z0-9_-]+|Bearer\s+\S+|eyJ[A-Za-z0-9._-]+/g, "[REDACTED]")
530
+ .slice(0, 120);
531
+ return scrubbed ? `${provider} API error: ${scrubbed}` : `${provider} usage unavailable`;
520
532
  }
521
533
 
522
534
  async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
@@ -1085,7 +1097,11 @@ function windowSegments(account: SubscriptionAccountSnapshot | undefined): strin
1085
1097
  return segments;
1086
1098
  }
1087
1099
 
1088
- function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
1100
+ export function renderSubscriptionLine(state: State): void {
1101
+ // ponytail: resolve ctx at render time — any captured ctx goes stale on
1102
+ // session replacement (new/fork/switch/reload) and ctx.ui then throws.
1103
+ const ctx = state.ctx;
1104
+ if (!ctx) return;
1089
1105
  const theme = ctx.ui.theme;
1090
1106
  if (!state.adapter) {
1091
1107
  // Unsupported provider (e.g. Ollama): still show the last response speed.
@@ -1124,10 +1140,38 @@ function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
1124
1140
  ctx.ui.setStatus(STATUS_KEY, theme.fg(color, line));
1125
1141
  }
1126
1142
 
1127
- function startTimer(ctx: ExtensionContext, state: State): void {
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
+
1171
+ function startTimer(state: State): void {
1128
1172
  if (state.refreshTimer || !state.adapter) return;
1129
1173
  state.refreshTimer = setInterval(() => {
1130
- void refreshUsage(ctx, state, false);
1174
+ deferRefresh(state, false);
1131
1175
  }, REFRESH_INTERVAL_MS);
1132
1176
  }
1133
1177
 
@@ -1138,7 +1182,7 @@ function stopTimer(state: State): void {
1138
1182
  state.debounceTimer = undefined;
1139
1183
  }
1140
1184
 
1141
- function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLike): void {
1185
+ function updateActiveAdapter(state: State, model: ModelLike): void {
1142
1186
  const nextAdapter = supportedAdapter(model);
1143
1187
  const adapterChanged = state.adapterId !== nextAdapter?.id;
1144
1188
 
@@ -1156,25 +1200,28 @@ function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLi
1156
1200
  if (!state.adapter) {
1157
1201
  stopTimer(state);
1158
1202
  }
1159
- renderSubscriptionLine(ctx, state);
1160
- if (state.adapter) startTimer(ctx, state);
1203
+ renderSubscriptionLine(state);
1204
+ if (state.adapter) startTimer(state);
1161
1205
  }
1162
1206
 
1163
- async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean): Promise<SubscriptionUsageSnapshot | undefined> {
1207
+ async function refreshUsage(state: State, force: boolean): Promise<SubscriptionUsageSnapshot | undefined> {
1164
1208
  const adapter = state.adapter;
1165
- if (!adapter) {
1166
- renderSubscriptionLine(ctx, state);
1209
+ // ponytail: resolve ctx at call time, never capture it across the fetch —
1210
+ // the session can be replaced while the promise is in flight.
1211
+ const ctx = state.ctx;
1212
+ if (!adapter || !ctx) {
1213
+ renderSubscriptionLine(state);
1167
1214
  return undefined;
1168
1215
  }
1169
1216
  if (!force && state.snapshot && Date.now() - state.lastRefreshAt < REFRESH_TTL_MS) return state.snapshot;
1170
1217
  if (state.inFlight) return state.inFlight;
1171
1218
  const generation = state.refreshGeneration;
1172
- renderSubscriptionLine(ctx, state);
1219
+ renderSubscriptionLine(state);
1173
1220
  state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
1174
1221
  if (state.refreshGeneration !== generation) return snapshot;
1175
1222
  state.snapshot = snapshot;
1176
1223
  state.lastRefreshAt = Date.now();
1177
- renderSubscriptionLine(ctx, state);
1224
+ renderSubscriptionLine(state);
1178
1225
  return snapshot;
1179
1226
  }).finally(() => {
1180
1227
  if (state.refreshGeneration === generation) {
@@ -1184,12 +1231,12 @@ async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean)
1184
1231
  return state.inFlight;
1185
1232
  }
1186
1233
 
1187
- function scheduleRefresh(ctx: ExtensionContext, state: State): void {
1234
+ export function scheduleRefresh(state: State): void {
1188
1235
  if (!state.adapter) return;
1189
1236
  if (state.debounceTimer) clearTimeout(state.debounceTimer);
1190
1237
  state.debounceTimer = setTimeout(() => {
1191
1238
  state.debounceTimer = undefined;
1192
- void refreshUsage(ctx, state, true);
1239
+ deferRefresh(state, true);
1193
1240
  }, REFRESH_DEBOUNCE_MS);
1194
1241
  }
1195
1242
 
@@ -1274,20 +1321,24 @@ export default function (pi: ExtensionAPI) {
1274
1321
  const state: State = { lastRefreshAt: 0, refreshGeneration: 0, cumulativeOutput: 0, cumulativeDurationMs: 0, cumulativeCost: 0 };
1275
1322
 
1276
1323
  pi.on("session_start", async (_event, ctx) => {
1277
- updateActiveAdapter(ctx, state, ctx.model);
1278
- if (state.adapter) void refreshUsage(ctx, state, true);
1324
+ // Only session_start installs state.ctx: it fires (startup/new/fork/switch/
1325
+ // reload) before the session's other events, so mid-session handlers never
1326
+ // need to — and a late old-session event must not reinstall a stale ctx.
1327
+ state.ctx = ctx;
1328
+ updateActiveAdapter(state, ctx.model);
1329
+ if (state.adapter) deferRefresh(state, true);
1279
1330
  });
1280
1331
 
1281
- pi.on("model_select", async (event, ctx) => {
1282
- updateActiveAdapter(ctx, state, event.model);
1283
- if (state.adapter) void refreshUsage(ctx, state, true);
1332
+ pi.on("model_select", async (event, _ctx) => {
1333
+ updateActiveAdapter(state, event.model);
1334
+ if (state.adapter) deferRefresh(state, true);
1284
1335
  });
1285
1336
 
1286
1337
  pi.on("before_provider_request", async (_event, _ctx) => {
1287
1338
  state.responseStartTime = Date.now();
1288
1339
  });
1289
1340
 
1290
- pi.on("message_end", async (event, ctx) => {
1341
+ pi.on("message_end", async (event, _ctx) => {
1291
1342
  if (event.message.role === "assistant") {
1292
1343
  state.cumulativeCost += (event.message.usage as any)?.cost?.total ?? 0;
1293
1344
  if (state.responseStartTime) {
@@ -1304,18 +1355,24 @@ export default function (pi: ExtensionAPI) {
1304
1355
  state.cumulativeDurationMs += elapsed;
1305
1356
  }
1306
1357
  }
1307
- renderSubscriptionLine(ctx, state);
1358
+ renderSubscriptionLine(state);
1308
1359
  }
1309
1360
  });
1310
1361
 
1311
- pi.on("after_provider_response", async (event, ctx) => {
1362
+ pi.on("after_provider_response", async (event, _ctx) => {
1312
1363
  if (event.status >= 400) {
1313
1364
  state.responseStartTime = undefined;
1314
1365
  }
1315
- if (state.adapter) scheduleRefresh(ctx, state);
1366
+ if (state.adapter) scheduleRefresh(state);
1316
1367
  });
1317
1368
 
1318
1369
  pi.on("session_shutdown", async (_event, ctx) => {
1370
+ // Only tear down if this shutdown belongs to the installed session: a late
1371
+ // old-session shutdown (delivered after the next session_start) must not
1372
+ // stop the live refresh timer, drop the live in-flight fetch, or touch a
1373
+ // ctx that may already be invalidated. Normal flow: session_start installed
1374
+ // this ctx, so the identity always matches for the session being torn down.
1375
+ if (state.ctx !== ctx) return;
1319
1376
  stopTimer(state);
1320
1377
  // ponytail: session is being torn down (new/fork/switch/reload). Pi invalidates
1321
1378
  // this ctx next; no-op any in-flight fetch .then that captured it, and drop the
@@ -1323,6 +1380,7 @@ export default function (pi: ExtensionAPI) {
1323
1380
  state.inFlight = undefined;
1324
1381
  state.refreshGeneration++;
1325
1382
  ctx.ui.setStatus(STATUS_KEY, undefined);
1383
+ state.ctx = undefined;
1326
1384
  });
1327
1385
 
1328
1386
  pi.registerCommand("sub", {
@@ -1334,13 +1392,26 @@ export default function (pi: ExtensionAPI) {
1334
1392
  return items.length > 0 ? items : null;
1335
1393
  },
1336
1394
  handler: async (args, ctx) => {
1337
- updateActiveAdapter(ctx, state, ctx.model);
1338
- const command = args.trim().toLowerCase();
1339
- const force = command === "refresh";
1340
- const snapshot = state.adapter ? await refreshUsage(ctx, state, force || !state.snapshot) : undefined;
1341
- const details = buildDetails(snapshot ?? state.snapshot, state);
1342
- pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
1343
- if (force) 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
+ }
1344
1412
  },
1345
1413
  });
1414
+
1415
+ // Returned for tests only — Pi ignores the extension setup return value.
1416
+ return state;
1346
1417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.36",
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",
@@ -42,5 +42,9 @@
42
42
  "@earendil-works/pi-coding-agent": ">=0.80.8 <0.86.0"
43
43
  },
44
44
  "devDependencies": {
45
+ "tsx": "^4.23.13"
46
+ },
47
+ "scripts": {
48
+ "test": "node --import tsx --test extensions/test/*.test.ts"
45
49
  }
46
50
  }