@bacnh85/pi-sub 0.1.37 → 0.1.39

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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.39 (2026-09-12)
4
+
5
+ ### Fixed
6
+
7
+ - **Guarded footer render against an uninitialized theme proxy** (pi-budget
8
+ parity): `renderSubscriptionLine` dereferenced `ctx.ui.theme.fg` unguarded —
9
+ if the theme isn't ready yet the throw escapes as a rejected promise and can
10
+ exit pi (the same unhandledRejection class 0.1.37/0.1.38 fixed elsewhere).
11
+ The footer is now best-effort: skipped when the theme isn't available.
12
+ - **Finite-cost guard on `message_end` accumulation** (pi-budget parity): a
13
+ string or NaN `cost.total` previously hit `+=` directly — a string cost
14
+ concatenated onto the accumulator and garbled every subsequent footer.
15
+ Costs are now coerced with `Number()` and only finite positive values
16
+ accumulate.
17
+ - README intro: Router (pi-router) listed among supported providers.
18
+
19
+ ## 0.1.38 (2026-09-07)
20
+
21
+ ### Fixed
22
+
23
+ - **Crash (pi exits) — third arm of the stale-ctx class**: pi 0.85.1 can
24
+ invalidate the extension ctx without ever delivering a matching
25
+ `session_shutdown` (orphaned/replaced runtime teardown; extension instances
26
+ are shared across sessions), so the 60s usage-refresh interval can fire with
27
+ `state.ctx` still installed but stale — the 0.1.18/0.1.37 guards (fire-time
28
+ ctx resolution, identity-guarded shutdown) never see it. `refreshUsage` is
29
+ async, so its throw became a rejected promise discarded by `void` →
30
+ `unhandledRejection` → pi's `uncaughtException` handler → exit. All deferred
31
+ refresh call sites now go through `deferRefresh`, which catches the
32
+ rejection and self-disarms (stops timers, drops in-flight state and the
33
+ stale ctx); `session_start` re-arms with the fresh ctx. Same crash class as
34
+ pi-messenger#25. The `/sub` command path disarms the same way when run
35
+ against an orphaned stale ctx, and regression tests now cover the 60s
36
+ interval arm, the command arm, and matcher independence from pi's exact
37
+ error wording.
38
+
3
39
  ## 0.1.37 (2026-09-07)
4
40
 
5
41
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Pi extension that shows subscription usage for the currently selected supported model provider.
4
4
 
5
- Supports OpenAI Codex (`openai-codex`) with live usage windows from ChatGPT's usage endpoint, OpenCode Go (`opencode-go`) with session cost tracking, and Z.ai GLM Coding Plan — both the international (`zai`) and China (`zai-coding-cn`, `open.bigmodel.cn`) endpoints — with quota monitoring. Also tracks Command Code (`commandcode`) 5-hour/weekly windows and monthly credit balance. Displays a subscription footer status after Pi's built-in status/token usage line.
5
+ Supports OpenAI Codex (`openai-codex`) with live usage windows from ChatGPT's usage endpoint, OpenCode Go (`opencode-go`) with session cost tracking, and Z.ai GLM Coding Plan — both the international (`zai`) and China (`zai-coding-cn`, `open.bigmodel.cn`) endpoints — with quota monitoring. Also tracks Router (pi-router, `router` provider) with response-speed tracking and optional OmniRoute quota windows, and Command Code (`commandcode`) 5-hour/weekly windows and monthly credit balance. Displays a subscription footer status after Pi's built-in status/token usage line.
6
6
 
7
7
  ## Install
8
8
 
@@ -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";
@@ -1103,6 +1103,9 @@ export function renderSubscriptionLine(state: State): void {
1103
1103
  const ctx = state.ctx;
1104
1104
  if (!ctx) return;
1105
1105
  const theme = ctx.ui.theme;
1106
+ // pi-budget parity: the theme proxy may not be initialized yet — dereferencing
1107
+ // theme.fg throws (unhandledRejection → pi exits). Best-effort footer: skip.
1108
+ if (!theme?.fg) return;
1106
1109
  if (!state.adapter) {
1107
1110
  // Unsupported provider (e.g. Ollama): still show the last response speed.
1108
1111
  ctx.ui.setStatus(STATUS_KEY, state.lastTokPerSec !== undefined ? theme.fg("dim", `${state.lastTokPerSec} tok/s`) : undefined);
@@ -1140,10 +1143,38 @@ export function renderSubscriptionLine(state: State): void {
1140
1143
  ctx.ui.setStatus(STATUS_KEY, theme.fg(color, line));
1141
1144
  }
1142
1145
 
1146
+ function isStaleCtxError(error: unknown): boolean {
1147
+ // pi 0.85.1's wording is "This extension ctx is stale after session
1148
+ // replacement or reload." (verified in the host bundle); "invalidated" is
1149
+ // matched too so wording drift degrades to a harmless extra disarm instead
1150
+ // of silently disabling recovery.
1151
+ return error instanceof Error && /\bctx is stale\b|invalidated/i.test(error.message);
1152
+ }
1153
+
1154
+ function selfDisarm(state: State): void {
1155
+ stopTimer(state);
1156
+ state.inFlight = undefined;
1157
+ state.refreshGeneration++;
1158
+ state.ctx = undefined;
1159
+ }
1160
+
1161
+ /** pi can invalidate state.ctx without ever delivering a matching
1162
+ * session_shutdown (pi 0.85.1 orphaned-runtime teardown; instances are shared
1163
+ * across sessions), so a deferred refresh can hit a stale ctx. refreshUsage
1164
+ * is async — its throw becomes a rejected promise, and a void-discarded
1165
+ * rejection exits pi (unhandledRejection -> uncaughtException). Catch it and
1166
+ * self-disarm; session_start re-arms with the fresh ctx. Non-stale
1167
+ * rejections are swallowed: adapters already resolve error snapshots. */
1168
+ function deferRefresh(state: State, force: boolean): void {
1169
+ refreshUsage(state, force).catch((error) => {
1170
+ if (isStaleCtxError(error)) selfDisarm(state);
1171
+ });
1172
+ }
1173
+
1143
1174
  function startTimer(state: State): void {
1144
1175
  if (state.refreshTimer || !state.adapter) return;
1145
1176
  state.refreshTimer = setInterval(() => {
1146
- void refreshUsage(state, false);
1177
+ deferRefresh(state, false);
1147
1178
  }, REFRESH_INTERVAL_MS);
1148
1179
  }
1149
1180
 
@@ -1208,7 +1239,7 @@ export function scheduleRefresh(state: State): void {
1208
1239
  if (state.debounceTimer) clearTimeout(state.debounceTimer);
1209
1240
  state.debounceTimer = setTimeout(() => {
1210
1241
  state.debounceTimer = undefined;
1211
- void refreshUsage(state, true);
1242
+ deferRefresh(state, true);
1212
1243
  }, REFRESH_DEBOUNCE_MS);
1213
1244
  }
1214
1245
 
@@ -1298,12 +1329,12 @@ export default function (pi: ExtensionAPI) {
1298
1329
  // need to — and a late old-session event must not reinstall a stale ctx.
1299
1330
  state.ctx = ctx;
1300
1331
  updateActiveAdapter(state, ctx.model);
1301
- if (state.adapter) void refreshUsage(state, true);
1332
+ if (state.adapter) deferRefresh(state, true);
1302
1333
  });
1303
1334
 
1304
1335
  pi.on("model_select", async (event, _ctx) => {
1305
1336
  updateActiveAdapter(state, event.model);
1306
- if (state.adapter) void refreshUsage(state, true);
1337
+ if (state.adapter) deferRefresh(state, true);
1307
1338
  });
1308
1339
 
1309
1340
  pi.on("before_provider_request", async (_event, _ctx) => {
@@ -1312,7 +1343,11 @@ export default function (pi: ExtensionAPI) {
1312
1343
 
1313
1344
  pi.on("message_end", async (event, _ctx) => {
1314
1345
  if (event.message.role === "assistant") {
1315
- state.cumulativeCost += (event.message.usage as any)?.cost?.total ?? 0;
1346
+ // pi-budget parity: coerce + finite guard so a string/NaN cost.total can
1347
+ // never poison the accumulator (string concat garbles every subsequent
1348
+ // footer).
1349
+ const cost = Number((event.message.usage as any)?.cost?.total);
1350
+ if (Number.isFinite(cost) && cost > 0) state.cumulativeCost += cost;
1316
1351
  if (state.responseStartTime) {
1317
1352
  // usage.output already includes reasoning tokens (Pi SDK contract) —
1318
1353
  // this is total tok/s in both thinking and normal mode.
@@ -1364,16 +1399,23 @@ export default function (pi: ExtensionAPI) {
1364
1399
  return items.length > 0 ? items : null;
1365
1400
  },
1366
1401
  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");
1402
+ try {
1403
+ updateActiveAdapter(state, ctx.model);
1404
+ const command = args.trim().toLowerCase();
1405
+ const force = command === "refresh";
1406
+ const snapshot = state.adapter ? await refreshUsage(state, force || !state.snapshot) : undefined;
1407
+ const details = buildDetails(snapshot ?? state.snapshot, state);
1408
+ pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
1409
+ // state.ctx (not captured ctx): the session could be replaced during the
1410
+ // await above; if it was, skip the notification instead of touching a
1411
+ // stale ctx.
1412
+ if (force) state.ctx?.ui.notify("Subscription usage refreshed", "info");
1413
+ } catch (error) {
1414
+ // Orphaned stale ctx (invalidated without shutdown): disarm like the
1415
+ // deferred paths instead of throwing into pi's dispatcher.
1416
+ if (!isStaleCtxError(error)) throw error;
1417
+ selfDisarm(state);
1418
+ }
1377
1419
  },
1378
1420
  });
1379
1421
 
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.39",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",