@bacnh85/pi-sub 0.1.36 → 0.1.37

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,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.37 (2026-09-07)
4
+
5
+ ### Fixed
6
+
7
+ - **Legible provider API errors**: structured API failures (e.g. Z.ai's
8
+ `500 Internal service error` returned by `api.z.ai/api/monitor/usage/quota/limit`
9
+ during its 2026-09-07 outage) no longer masquerade as a generic
10
+ "usage unavailable" — the footer shows the server's own message
11
+ (`Sub Z.ai (Anthropic) API error: Internal service error`), with
12
+ credential-shaped material (`sk-…`, `Bearer …`, JWTs) scrubbed and length
13
+ capped; auth-looking messages stay redacted.
14
+ - **Crash (pi exits) on session replacement (`/new`, fork, switch, `/reload`)**:
15
+ a usage-refresh debounce timer armed by a late `after_provider_response` event
16
+ — delivered after `session_shutdown` had already cleared the previous timers —
17
+ captured the old extension ctx; ~2s later the timer fired, touched the now
18
+ stale `ctx.ui`, and the uncaught error killed pi. Deferred helpers
19
+ (`renderSubscriptionLine`, `refreshUsage`, `startTimer`, `scheduleRefresh`,
20
+ `updateActiveAdapter`) no longer take a captured ctx: they resolve `state.ctx`
21
+ at execution time. `state.ctx` is installed only by `session_start` (fires
22
+ before a session's other events, always fresh) and cleared by
23
+ `session_shutdown`, so mid-session handlers can never reinstall a stale ctx
24
+ and timers firing in the teardown window safely no-op. `session_shutdown`
25
+ now no-ops entirely unless it belongs to the installed session
26
+ (`state.ctx === ctx`), so a late old-session shutdown delivered after the
27
+ next `session_start` can neither touch an invalidated ctx nor stop the live
28
+ session's refresh timer / drop its in-flight fetch. Regression tests in
29
+ `extensions/test/stale-ctx-regression.test.ts`.
30
+
31
+ ### Changed
32
+
33
+ - **Tests are now gated**: `npm test` runs both test files via
34
+ `node --import tsx --test` (new `tsx` devDependency), and CI runs
35
+ `npm ci && npm test` for pi-sub instead of only `npm pack --dry-run` — the
36
+ stale-ctx regression tests can no longer be bypassed by a refactor.
37
+
3
38
  ## 0.1.36 (2026-09-07)
4
39
 
5
40
  ### Fixed
@@ -31,7 +31,7 @@ const MESSAGE_TYPE = "pi-sub-status";
31
31
  const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
32
32
  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,10 @@ 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 startTimer(state: State): void {
1128
1144
  if (state.refreshTimer || !state.adapter) return;
1129
1145
  state.refreshTimer = setInterval(() => {
1130
- void refreshUsage(ctx, state, false);
1146
+ void refreshUsage(state, false);
1131
1147
  }, REFRESH_INTERVAL_MS);
1132
1148
  }
1133
1149
 
@@ -1138,7 +1154,7 @@ function stopTimer(state: State): void {
1138
1154
  state.debounceTimer = undefined;
1139
1155
  }
1140
1156
 
1141
- function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLike): void {
1157
+ function updateActiveAdapter(state: State, model: ModelLike): void {
1142
1158
  const nextAdapter = supportedAdapter(model);
1143
1159
  const adapterChanged = state.adapterId !== nextAdapter?.id;
1144
1160
 
@@ -1156,25 +1172,28 @@ function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLi
1156
1172
  if (!state.adapter) {
1157
1173
  stopTimer(state);
1158
1174
  }
1159
- renderSubscriptionLine(ctx, state);
1160
- if (state.adapter) startTimer(ctx, state);
1175
+ renderSubscriptionLine(state);
1176
+ if (state.adapter) startTimer(state);
1161
1177
  }
1162
1178
 
1163
- async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean): Promise<SubscriptionUsageSnapshot | undefined> {
1179
+ async function refreshUsage(state: State, force: boolean): Promise<SubscriptionUsageSnapshot | undefined> {
1164
1180
  const adapter = state.adapter;
1165
- if (!adapter) {
1166
- renderSubscriptionLine(ctx, state);
1181
+ // ponytail: resolve ctx at call time, never capture it across the fetch —
1182
+ // the session can be replaced while the promise is in flight.
1183
+ const ctx = state.ctx;
1184
+ if (!adapter || !ctx) {
1185
+ renderSubscriptionLine(state);
1167
1186
  return undefined;
1168
1187
  }
1169
1188
  if (!force && state.snapshot && Date.now() - state.lastRefreshAt < REFRESH_TTL_MS) return state.snapshot;
1170
1189
  if (state.inFlight) return state.inFlight;
1171
1190
  const generation = state.refreshGeneration;
1172
- renderSubscriptionLine(ctx, state);
1191
+ renderSubscriptionLine(state);
1173
1192
  state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
1174
1193
  if (state.refreshGeneration !== generation) return snapshot;
1175
1194
  state.snapshot = snapshot;
1176
1195
  state.lastRefreshAt = Date.now();
1177
- renderSubscriptionLine(ctx, state);
1196
+ renderSubscriptionLine(state);
1178
1197
  return snapshot;
1179
1198
  }).finally(() => {
1180
1199
  if (state.refreshGeneration === generation) {
@@ -1184,12 +1203,12 @@ async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean)
1184
1203
  return state.inFlight;
1185
1204
  }
1186
1205
 
1187
- function scheduleRefresh(ctx: ExtensionContext, state: State): void {
1206
+ export function scheduleRefresh(state: State): void {
1188
1207
  if (!state.adapter) return;
1189
1208
  if (state.debounceTimer) clearTimeout(state.debounceTimer);
1190
1209
  state.debounceTimer = setTimeout(() => {
1191
1210
  state.debounceTimer = undefined;
1192
- void refreshUsage(ctx, state, true);
1211
+ void refreshUsage(state, true);
1193
1212
  }, REFRESH_DEBOUNCE_MS);
1194
1213
  }
1195
1214
 
@@ -1274,20 +1293,24 @@ export default function (pi: ExtensionAPI) {
1274
1293
  const state: State = { lastRefreshAt: 0, refreshGeneration: 0, cumulativeOutput: 0, cumulativeDurationMs: 0, cumulativeCost: 0 };
1275
1294
 
1276
1295
  pi.on("session_start", async (_event, ctx) => {
1277
- updateActiveAdapter(ctx, state, ctx.model);
1278
- if (state.adapter) void refreshUsage(ctx, state, true);
1296
+ // Only session_start installs state.ctx: it fires (startup/new/fork/switch/
1297
+ // reload) before the session's other events, so mid-session handlers never
1298
+ // need to — and a late old-session event must not reinstall a stale ctx.
1299
+ state.ctx = ctx;
1300
+ updateActiveAdapter(state, ctx.model);
1301
+ if (state.adapter) void refreshUsage(state, true);
1279
1302
  });
1280
1303
 
1281
- pi.on("model_select", async (event, ctx) => {
1282
- updateActiveAdapter(ctx, state, event.model);
1283
- if (state.adapter) void refreshUsage(ctx, state, true);
1304
+ pi.on("model_select", async (event, _ctx) => {
1305
+ updateActiveAdapter(state, event.model);
1306
+ if (state.adapter) void refreshUsage(state, true);
1284
1307
  });
1285
1308
 
1286
1309
  pi.on("before_provider_request", async (_event, _ctx) => {
1287
1310
  state.responseStartTime = Date.now();
1288
1311
  });
1289
1312
 
1290
- pi.on("message_end", async (event, ctx) => {
1313
+ pi.on("message_end", async (event, _ctx) => {
1291
1314
  if (event.message.role === "assistant") {
1292
1315
  state.cumulativeCost += (event.message.usage as any)?.cost?.total ?? 0;
1293
1316
  if (state.responseStartTime) {
@@ -1304,18 +1327,24 @@ export default function (pi: ExtensionAPI) {
1304
1327
  state.cumulativeDurationMs += elapsed;
1305
1328
  }
1306
1329
  }
1307
- renderSubscriptionLine(ctx, state);
1330
+ renderSubscriptionLine(state);
1308
1331
  }
1309
1332
  });
1310
1333
 
1311
- pi.on("after_provider_response", async (event, ctx) => {
1334
+ pi.on("after_provider_response", async (event, _ctx) => {
1312
1335
  if (event.status >= 400) {
1313
1336
  state.responseStartTime = undefined;
1314
1337
  }
1315
- if (state.adapter) scheduleRefresh(ctx, state);
1338
+ if (state.adapter) scheduleRefresh(state);
1316
1339
  });
1317
1340
 
1318
1341
  pi.on("session_shutdown", async (_event, ctx) => {
1342
+ // Only tear down if this shutdown belongs to the installed session: a late
1343
+ // old-session shutdown (delivered after the next session_start) must not
1344
+ // stop the live refresh timer, drop the live in-flight fetch, or touch a
1345
+ // ctx that may already be invalidated. Normal flow: session_start installed
1346
+ // this ctx, so the identity always matches for the session being torn down.
1347
+ if (state.ctx !== ctx) return;
1319
1348
  stopTimer(state);
1320
1349
  // ponytail: session is being torn down (new/fork/switch/reload). Pi invalidates
1321
1350
  // this ctx next; no-op any in-flight fetch .then that captured it, and drop the
@@ -1323,6 +1352,7 @@ export default function (pi: ExtensionAPI) {
1323
1352
  state.inFlight = undefined;
1324
1353
  state.refreshGeneration++;
1325
1354
  ctx.ui.setStatus(STATUS_KEY, undefined);
1355
+ state.ctx = undefined;
1326
1356
  });
1327
1357
 
1328
1358
  pi.registerCommand("sub", {
@@ -1334,13 +1364,19 @@ export default function (pi: ExtensionAPI) {
1334
1364
  return items.length > 0 ? items : null;
1335
1365
  },
1336
1366
  handler: async (args, ctx) => {
1337
- updateActiveAdapter(ctx, state, ctx.model);
1367
+ updateActiveAdapter(state, ctx.model);
1338
1368
  const command = args.trim().toLowerCase();
1339
1369
  const force = command === "refresh";
1340
- const snapshot = state.adapter ? await refreshUsage(ctx, state, force || !state.snapshot) : undefined;
1370
+ const snapshot = state.adapter ? await refreshUsage(state, force || !state.snapshot) : undefined;
1341
1371
  const details = buildDetails(snapshot ?? state.snapshot, state);
1342
1372
  pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
1343
- if (force) ctx.ui.notify("Subscription usage refreshed", "info");
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");
1344
1377
  },
1345
1378
  });
1379
+
1380
+ // Returned for tests only — Pi ignores the extension setup return value.
1381
+ return state;
1346
1382
  }
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.37",
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
  }