@bacnh85/pi-sub 0.1.35 → 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,58 @@
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
+
38
+ ## 0.1.36 (2026-09-07)
39
+
40
+ ### Fixed
41
+
42
+ - **Removed the incorrect dead `tok-per-sec` module** (added in 0.1.35, never
43
+ wired in): its `withThinking` formula summed `reasoning` on top of `output`,
44
+ but Pi's `usage.output` already includes reasoning tokens (`reasoning` is a
45
+ subset), which would have inflated thinking-mode tok/s up to ~2×. The live
46
+ footer/footer tok/s math (`output / elapsed`) was and remains correct in both
47
+ thinking and normal mode.
48
+
49
+ ### Added
50
+
51
+ - **`/sub` thinking/answer split** — when the model reasoned, the details line
52
+ now reads e.g. `Last response: 46 tok/s (36 think + 10 answer)` instead of a
53
+ bare total, using the correct subset math (`answer = output − reasoning`).
54
+ Footer keeps the single total number.
55
+
3
56
  ## 0.1.35 (2026-09-05)
4
57
 
5
58
  ### Added
@@ -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;
@@ -127,6 +128,7 @@ interface State {
127
128
  debounceTimer?: NodeJS.Timeout;
128
129
  responseStartTime?: number;
129
130
  lastTokPerSec?: number;
131
+ lastTokPerSecLabel?: string;
130
132
  cumulativeOutput: number;
131
133
  cumulativeDurationMs: number;
132
134
  cumulativeCost: number;
@@ -462,6 +464,9 @@ if (process.env.PI_SUB_SELF_CHECK === "1") {
462
464
  assert(p.personalWeekly?.remaining === 90, "personal weekly 90");
463
465
  assert(p.session?.remaining === 47, "session 47");
464
466
  assert(p.providerWeekly?.remaining === 28, "provider weekly 28");
467
+ // tok/s split label: usage.reasoning ⊂ usage.output, never summed.
468
+ assert(tokPerSecLabel(3200, 2500, 70_000) === "46 tok/s (36 think + 10 answer)", "tok/s split label");
469
+ assert(tokPerSecLabel(200, 0, 10_000) === "20 tok/s", "tok/s plain label");
465
470
  assert(p.personalDaily?.resetLabel?.includes("15h") === true, "daily reset label");
466
471
  const disabled = parseOmniUsageText("Usage command is disabled for this API key.");
467
472
  assert(Object.keys(disabled).length === 0, "disabled text parses empty");
@@ -503,7 +508,11 @@ async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): P
503
508
  return parseUsageResponse(await response.json());
504
509
  }
505
510
 
506
- 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 {
507
516
  const message = error instanceof Error ? error.message : String(error || "Unknown error");
508
517
  if (/ENOENT|no such file/i.test(message)) return "Pi auth not found";
509
518
  if (/missing openai-codex/i.test(message)) return "openai-codex auth not found";
@@ -512,7 +521,14 @@ function redactedError(error: unknown, provider = "Codex"): string {
512
521
  if (/missing commandcode/i.test(message)) return "commandcode auth not found";
513
522
  if (/timed out|timeout|aborted/i.test(message)) return `${provider} usage refresh timed out`;
514
523
  if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return `${provider} auth unavailable`;
515
- 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`;
516
532
  }
517
533
 
518
534
  async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
@@ -1081,7 +1097,11 @@ function windowSegments(account: SubscriptionAccountSnapshot | undefined): strin
1081
1097
  return segments;
1082
1098
  }
1083
1099
 
1084
- 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;
1085
1105
  const theme = ctx.ui.theme;
1086
1106
  if (!state.adapter) {
1087
1107
  // Unsupported provider (e.g. Ollama): still show the last response speed.
@@ -1120,10 +1140,10 @@ function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
1120
1140
  ctx.ui.setStatus(STATUS_KEY, theme.fg(color, line));
1121
1141
  }
1122
1142
 
1123
- function startTimer(ctx: ExtensionContext, state: State): void {
1143
+ function startTimer(state: State): void {
1124
1144
  if (state.refreshTimer || !state.adapter) return;
1125
1145
  state.refreshTimer = setInterval(() => {
1126
- void refreshUsage(ctx, state, false);
1146
+ void refreshUsage(state, false);
1127
1147
  }, REFRESH_INTERVAL_MS);
1128
1148
  }
1129
1149
 
@@ -1134,7 +1154,7 @@ function stopTimer(state: State): void {
1134
1154
  state.debounceTimer = undefined;
1135
1155
  }
1136
1156
 
1137
- function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLike): void {
1157
+ function updateActiveAdapter(state: State, model: ModelLike): void {
1138
1158
  const nextAdapter = supportedAdapter(model);
1139
1159
  const adapterChanged = state.adapterId !== nextAdapter?.id;
1140
1160
 
@@ -1152,25 +1172,28 @@ function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLi
1152
1172
  if (!state.adapter) {
1153
1173
  stopTimer(state);
1154
1174
  }
1155
- renderSubscriptionLine(ctx, state);
1156
- if (state.adapter) startTimer(ctx, state);
1175
+ renderSubscriptionLine(state);
1176
+ if (state.adapter) startTimer(state);
1157
1177
  }
1158
1178
 
1159
- async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean): Promise<SubscriptionUsageSnapshot | undefined> {
1179
+ async function refreshUsage(state: State, force: boolean): Promise<SubscriptionUsageSnapshot | undefined> {
1160
1180
  const adapter = state.adapter;
1161
- if (!adapter) {
1162
- 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);
1163
1186
  return undefined;
1164
1187
  }
1165
1188
  if (!force && state.snapshot && Date.now() - state.lastRefreshAt < REFRESH_TTL_MS) return state.snapshot;
1166
1189
  if (state.inFlight) return state.inFlight;
1167
1190
  const generation = state.refreshGeneration;
1168
- renderSubscriptionLine(ctx, state);
1191
+ renderSubscriptionLine(state);
1169
1192
  state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
1170
1193
  if (state.refreshGeneration !== generation) return snapshot;
1171
1194
  state.snapshot = snapshot;
1172
1195
  state.lastRefreshAt = Date.now();
1173
- renderSubscriptionLine(ctx, state);
1196
+ renderSubscriptionLine(state);
1174
1197
  return snapshot;
1175
1198
  }).finally(() => {
1176
1199
  if (state.refreshGeneration === generation) {
@@ -1180,12 +1203,12 @@ async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean)
1180
1203
  return state.inFlight;
1181
1204
  }
1182
1205
 
1183
- function scheduleRefresh(ctx: ExtensionContext, state: State): void {
1206
+ export function scheduleRefresh(state: State): void {
1184
1207
  if (!state.adapter) return;
1185
1208
  if (state.debounceTimer) clearTimeout(state.debounceTimer);
1186
1209
  state.debounceTimer = setTimeout(() => {
1187
1210
  state.debounceTimer = undefined;
1188
- void refreshUsage(ctx, state, true);
1211
+ void refreshUsage(state, true);
1189
1212
  }, REFRESH_DEBOUNCE_MS);
1190
1213
  }
1191
1214
 
@@ -1193,11 +1216,21 @@ function pad(value: string, width: number): string {
1193
1216
  return value.length >= width ? value : value + " ".repeat(width - value.length);
1194
1217
  }
1195
1218
 
1219
+ /** "46 tok/s (36 think + 10 answer)" — split shown only when the model
1220
+ * reasoned. usage.reasoning is a subset of usage.output (Pi SDK contract),
1221
+ * so answer speed = (output − reasoning)/s, never output + reasoning. */
1222
+ function tokPerSecLabel(output: number, thinking: number, elapsedMs: number): string {
1223
+ const total = Math.round(output / (elapsedMs / 1000));
1224
+ if (thinking <= 0) return `${total} tok/s`;
1225
+ const secs = elapsedMs / 1000;
1226
+ return `${total} tok/s (${Math.round(thinking / secs)} think + ${Math.round((output - thinking) / secs)} answer)`;
1227
+ }
1228
+
1196
1229
  function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: State): string {
1197
1230
  if (!state.adapter) {
1198
1231
  const header = `Provider: ${state.model?.provider ?? "unknown"}${state.model?.id ? ` · Model: ${state.model.id}` : ""}`;
1199
1232
  if (state.lastTokPerSec === undefined) return `${header}\nSubscription tracking inactive for this provider.`;
1200
- const tokPerSecLine = `Last response: ${state.lastTokPerSec} tok/s` +
1233
+ const tokPerSecLine = `Last response: ${state.lastTokPerSecLabel}` +
1201
1234
  (state.cumulativeDurationMs > 0
1202
1235
  ? ` · Session avg: ${Math.round(state.cumulativeOutput / (state.cumulativeDurationMs / 1000))} tok/s`
1203
1236
  : "");
@@ -1240,7 +1273,7 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
1240
1273
 
1241
1274
  const costLine = state.cumulativeCost > 0 ? `\nSession cost: $${state.cumulativeCost.toFixed(2)}` : "";
1242
1275
  const tokPerSecLine = state.lastTokPerSec !== undefined
1243
- ? `\nLast response: ${state.lastTokPerSec} tok/s` +
1276
+ ? `\nLast response: ${state.lastTokPerSecLabel}` +
1244
1277
  (state.cumulativeDurationMs > 0
1245
1278
  ? ` · Session avg: ${Math.round(state.cumulativeOutput / (state.cumulativeDurationMs / 1000))} tok/s`
1246
1279
  : "")
@@ -1260,44 +1293,58 @@ export default function (pi: ExtensionAPI) {
1260
1293
  const state: State = { lastRefreshAt: 0, refreshGeneration: 0, cumulativeOutput: 0, cumulativeDurationMs: 0, cumulativeCost: 0 };
1261
1294
 
1262
1295
  pi.on("session_start", async (_event, ctx) => {
1263
- updateActiveAdapter(ctx, state, ctx.model);
1264
- 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);
1265
1302
  });
1266
1303
 
1267
- pi.on("model_select", async (event, ctx) => {
1268
- updateActiveAdapter(ctx, state, event.model);
1269
- 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);
1270
1307
  });
1271
1308
 
1272
1309
  pi.on("before_provider_request", async (_event, _ctx) => {
1273
1310
  state.responseStartTime = Date.now();
1274
1311
  });
1275
1312
 
1276
- pi.on("message_end", async (event, ctx) => {
1313
+ pi.on("message_end", async (event, _ctx) => {
1277
1314
  if (event.message.role === "assistant") {
1278
1315
  state.cumulativeCost += (event.message.usage as any)?.cost?.total ?? 0;
1279
1316
  if (state.responseStartTime) {
1317
+ // usage.output already includes reasoning tokens (Pi SDK contract) —
1318
+ // this is total tok/s in both thinking and normal mode.
1280
1319
  const output = (event.message.usage as any)?.output ?? 0;
1320
+ const reasoning = (event.message.usage as any)?.reasoning ?? 0;
1281
1321
  const elapsed = Date.now() - state.responseStartTime;
1282
1322
  state.responseStartTime = undefined;
1283
1323
  if (elapsed > 0 && output > 0) {
1284
1324
  state.lastTokPerSec = Math.round(output / (elapsed / 1000));
1325
+ state.lastTokPerSecLabel = tokPerSecLabel(output, reasoning, elapsed);
1285
1326
  state.cumulativeOutput += output;
1286
1327
  state.cumulativeDurationMs += elapsed;
1287
1328
  }
1288
1329
  }
1289
- renderSubscriptionLine(ctx, state);
1330
+ renderSubscriptionLine(state);
1290
1331
  }
1291
1332
  });
1292
1333
 
1293
- pi.on("after_provider_response", async (event, ctx) => {
1334
+ pi.on("after_provider_response", async (event, _ctx) => {
1294
1335
  if (event.status >= 400) {
1295
1336
  state.responseStartTime = undefined;
1296
1337
  }
1297
- if (state.adapter) scheduleRefresh(ctx, state);
1338
+ if (state.adapter) scheduleRefresh(state);
1298
1339
  });
1299
1340
 
1300
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;
1301
1348
  stopTimer(state);
1302
1349
  // ponytail: session is being torn down (new/fork/switch/reload). Pi invalidates
1303
1350
  // this ctx next; no-op any in-flight fetch .then that captured it, and drop the
@@ -1305,6 +1352,7 @@ export default function (pi: ExtensionAPI) {
1305
1352
  state.inFlight = undefined;
1306
1353
  state.refreshGeneration++;
1307
1354
  ctx.ui.setStatus(STATUS_KEY, undefined);
1355
+ state.ctx = undefined;
1308
1356
  });
1309
1357
 
1310
1358
  pi.registerCommand("sub", {
@@ -1316,13 +1364,19 @@ export default function (pi: ExtensionAPI) {
1316
1364
  return items.length > 0 ? items : null;
1317
1365
  },
1318
1366
  handler: async (args, ctx) => {
1319
- updateActiveAdapter(ctx, state, ctx.model);
1367
+ updateActiveAdapter(state, ctx.model);
1320
1368
  const command = args.trim().toLowerCase();
1321
1369
  const force = command === "refresh";
1322
- const snapshot = state.adapter ? await refreshUsage(ctx, state, force || !state.snapshot) : undefined;
1370
+ const snapshot = state.adapter ? await refreshUsage(state, force || !state.snapshot) : undefined;
1323
1371
  const details = buildDetails(snapshot ?? state.snapshot, state);
1324
1372
  pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
1325
- 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");
1326
1377
  },
1327
1378
  });
1379
+
1380
+ // Returned for tests only — Pi ignores the extension setup return value.
1381
+ return state;
1328
1382
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.35",
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,6 +42,9 @@
42
42
  "@earendil-works/pi-coding-agent": ">=0.80.8 <0.86.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@earendil-works/pi-server": "^0.85.1"
45
+ "tsx": "^4.23.13"
46
+ },
47
+ "scripts": {
48
+ "test": "node --import tsx --test extensions/test/*.test.ts"
46
49
  }
47
50
  }