@llblab/pi-codex-usage 0.10.1 → 0.11.0

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/AGENTS.md CHANGED
@@ -3,15 +3,15 @@
3
3
  - `Statusline-first scope`: Keep this extension zero-configuration and focused on compact status surfaces.
4
4
  - Trigger: Considering commands, menus, persisted settings, or notification output.
5
5
  - Action: Prefer deleting the surface unless it is required for the optimistic TUI status widget or the optional `pi-telegram` `/start` status-line mirror.
6
-
7
6
  - `Optimistic refresh`: Preserve the last good statusline bar during refresh and transient failures.
8
7
  - Trigger: Updating quota polling or error handling.
9
8
  - Action: Do not collapse the bar while a request is in flight; only show `n/a` or `error` after repeated failures or no usable quota.
10
-
11
9
  - `Adaptive compact status`: Match the status representation to the server-provided quota windows.
12
10
  - Trigger: Changing statusline formatting.
13
11
  - Action: When both windows exist, keep the classic dual bar with 20 top steps for the 5-hour window and 20 bottom steps for the weekly window. When only one weekly window exists, show its rounded remaining percentage directly instead of using a bar.
14
-
15
12
  - `Weekly reset countdown`: Append the weekly reset countdown whenever the available weekly window exposes a reset time.
16
13
  - Trigger: Changing reset-time normalization or statusline refresh cadence.
17
14
  - Action: Treat the secondary window as weekly in dual-window responses and the sole window as weekly in single-window responses. Keep `d` labels rounded upward in 144-minute day-tenth steps above 24h, show 24h..1h labels in upward-rounded 6-minute hour-tenth steps, keep `m`/`s` labels floored, and hold `0s` until a successful quota refresh reports the next window.
15
+ - `Shared refresh`: Instances must not poll independently.
16
+ - Trigger: Changing refresh cadence, retries, locking, or adding fetch paths.
17
+ - Action: Keep the single shared-state protocol in the `Shared Refresh` section of `index.ts` (one shared `~/.pi/agent/tmp/pi-codex-usage/usage.json` for all Codex models; leader refreshes every minute, takeover after 90 seconds by claiming leadership before fetching, a non-waiting OS-backed SQLite mutex around claiming and fenced publication, atomic writes, shared failure backoff). Always re-read the file for request authorization (`owner` + `claimId` + lease) and publication; do not use an in-memory ownership fallback. Lock failures and failed claim writes must deny requests. `mutex.sqlite` stores no quota or leadership data: never unlink/replace it while instances run or evict a paused holder; close or process death releases the mutex. Keep network calls outside critical sections. Instances otherwise only read the file. Ignore additional quota buckets; do not restore model-specific labels, source priority, or cache directories. Keep the coordination behavior in sync with `pi-claude-usage`, which has its own independent state directory.
package/BACKLOG.md CHANGED
@@ -1,3 +1,3 @@
1
1
  # Backlog
2
2
 
3
- No open work.
3
+ No open items.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.11.0: Shared Quota Refresh
4
+
5
+ - Coordinated polling across Pi instances through one `~/.pi/agent/tmp/pi-codex-usage/usage.json`: the leader refreshes every 60 seconds, followers normally read every 30 seconds, and leadership becomes eligible for takeover after 90 seconds without a touch. Failure backoff is shared, the last successful report stays visible for up to an hour, and provisional full-availability confirmation runs only in the refreshing instance.
6
+ - Made the JSON authoritative for request admission and fenced publication using the on-disk owner, claim generation, and lease. A non-waiting OS-backed SQLite mutex serializes claiming and publication; lock or claim-write failures deny requests, and late results cannot overwrite a successor. **Upgrade:** close all old Pi instances before starting updated ones; do not mix locking protocols or remove the mutex file while instances run.
7
+ - **Breaking:** Removed Spark-specific quotas, labels, and source priority. All Codex models now use the primary `codex` quota; additional buckets and legacy per-bucket caches are ignored without migration. Dual-bar rendering, loading animation, reset countdowns, Business credit usage, Pi auth, and the Codex app-server fallback are preserved.
4
8
 
5
9
  ## 0.10.1: Softer Quota Bar
6
10
 
package/README.md CHANGED
@@ -14,18 +14,18 @@ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-co
14
14
 
15
15
  ## Features
16
16
 
17
- - Shows two counter-moving half-height markers in the statusline bar while the active Codex/Spark quota bucket is loading, then refreshes every 60 seconds
18
- - Keeps the last usable active-bucket bar visible during ordinary refreshes instead of replacing known quota with a loading state
17
+ - Shows two counter-moving half-height markers in the statusline bar while Codex usage is loading, then keeps the bar fresh (the countdown ticks locally)
18
+ - Keeps the last usable bar visible during ordinary refreshes instead of replacing known quota with a loading state
19
19
  - Statusline output adapts to the response: weekly-only limits show an explicit remaining percentage and reset countdown, while dual-window limits keep the compact themed bar
20
- - Regular Codex subscription models show the primary `codex` quota bucket
21
- - `GPT-5.3-Codex-Spark` shows the parallel Spark quota bucket with the `spark` label
22
- - When `pi-telegram` is available, the same compact value appears as `codex: <value>` or `spark: <value>` in the `/start` menu status text for active OpenAI Codex subscription models
23
- - Additional returned buckets unrelated to the active Codex/Spark model are ignored
20
+ - All Codex subscription models share the primary `codex` quota and status label
21
+ - When `pi-telegram` is available, the same compact value appears as `codex: <value>` in the `/start` menu status text for active OpenAI Codex subscription models
22
+ - Additional returned quota buckets are ignored
24
23
  - Pi OpenAI Codex provider auth is used first
25
24
  - Codex CLI app-server remains available as a fallback
26
25
  - Missing auth, subscription, plan, or quota windows are shown as `n/a`, not as an error
27
26
  - Successful updates briefly redraw the bar only when a 5% segment changes
28
- - Network/provider failures keep the last good bar briefly, then show `error`
27
+ - Network/provider failures keep the last good bar (up to an hour), then show `error`
28
+ - Any number of Pi instances share one request stream, see [Shared Refresh](#shared-refresh)
29
29
  - No commands or configuration are required
30
30
 
31
31
  ## Install
@@ -50,12 +50,6 @@ Regular Codex usage:
50
50
  codex ██████▀▀▀▀ 6d
51
51
  ```
52
52
 
53
- Spark model usage:
54
-
55
- ```text
56
- spark ██████████ 7d
57
- ```
58
-
59
53
  When OpenAI returns only the weekly window, the status shows the exact rounded remaining percentage and reset countdown directly:
60
54
 
61
55
  ```text
@@ -64,7 +58,7 @@ codex 67% 7d
64
58
 
65
59
  When both windows exist, the ten-character bar encodes two twenty-step limits at once: the top quadrants show the 5-hour limit and the bottom quadrants show the weekly limit, with each step representing 5%. If either quota window is exhausted, the bar keeps its shape but switches to the error background color.
66
60
 
67
- Before the first usable report for the active quota bucket arrives, two half-height markers move through the same fixed-width themed bar. The upper 5-hour marker travels opposite the lower weekly marker; both reverse smoothly at the ends, and each loader run randomly starts from one of the two mirrored endpoint phases. Their motion distinguishes loading from 100% remaining quota while preserving the normal bar background. A first report claiming both windows are completely unused is treated as provisional for 15 seconds and retried every second, because providers can briefly emit zeroed windows while initializing. Once a usable report exists, refresh requests preserve that last good bar.
61
+ Before the first usable Codex report arrives, two half-height markers move through the same fixed-width themed bar. The upper 5-hour marker travels opposite the lower weekly marker; both reverse smoothly at the ends, and each loader run randomly starts from one of the two mirrored endpoint phases. Their motion distinguishes loading from 100% remaining quota while preserving the normal bar background. A first report claiming both windows are completely unused is treated as provisional for 15 seconds and retried every second by the refreshing instance before it is published, because providers can briefly emit zeroed windows while initializing. Once a usable report exists, refresh requests preserve that last good bar.
68
62
 
69
63
  When the weekly reset time is available, it follows either the single-window percentage or the dual-window bar. More than a day remains is shown in 144-minute day-tenth steps such as `7d`, `6.9d`, `6.6d`, `5.1d`, `5d`, `3.7d`, `3d`, `2d`, `1.9d`, `1.5d`, and `1.1d`, rounded upward to the next tenth. At 24 hours and below it switches to upward-rounded 6-minute hour-tenth steps such as `24h`, `23.7h`, `20.1h`, `20h`, `19.9h`, `1.4h`, `1.3h`, `1.2h`, `1.1h`, and `1h`. Under an hour it switches to floored minutes, and under a minute to seconds. After the reset timestamp passes, `0s` is held until the next successful quota refresh reports the new weekly window.
70
64
 
@@ -94,16 +88,35 @@ Runtime failure, such as a network or provider error:
94
88
  codex error
95
89
  ```
96
90
 
91
+ ## Shared Refresh
92
+
93
+ The coordination code lives in the `Shared Refresh` section of [`index.ts`](./index.ts); the extension ships as a single TypeScript source file.
94
+
95
+ All Codex models and instances coordinate through `~/.pi/agent/tmp/pi-codex-usage/usage.json` (quota percentages and timestamps only, no tokens). The Claude extension uses its own independent file at `~/.pi/agent/tmp/pi-claude-usage/usage.json`.
96
+
97
+ Previous per-bucket cache directories are no longer read or written; there is no migration. Restart all previously running instances when upgrading to stop them writing the old layout.
98
+
99
+ - The instance that last updated the file is the leader and refreshes it every minute
100
+ - Every other instance only reads the file (re-checking every ≤30s) and redraws when it changes
101
+ - Leadership is never cached in memory: before each usage request (including retries and fallbacks), the instance re-reads the file and checks its `owner`, unique `claimId`, and 90s lease. Publication rechecks that claim under the lock; a superseded success or failure is discarded. Failed locks or unwritten claims never authorize a request
102
+ - If the file is 90 seconds old (leader is closed, busy, or asleep), the first follower that obtains the mutex takes over: it re-reads the JSON, writes itself as the leader with a fresh timestamp, releases the mutex, then fetches from the server and publishes under the mutex after rechecking its claim. Its next refresh is one minute later; JSON writes remain atomic renames
103
+ - Claiming and publication use a non-waiting transaction in `mutex.sqlite`, through Node's built-in `node:sqlite` (Node ≥22.19.0, the existing package minimum). It stores no quota or leadership records and needs no extra package or service. The OS releases the lock when the connection closes or the process dies; there is no timeout-based lock stealing
104
+ - Failures are written to the file with an exponential backoff (1 to 5 minutes; 5 to 30 minutes on HTTP 429) that applies to all instances
105
+ - Instances without data yet show the loading bar and re-check every second until the leader publishes
106
+
107
+ Coordination assumes a local filesystem and cooperating instances on the same machine. Never delete or replace `mutex.sqlite` while instances are running. Network requests do not hold the mutex; a process paused inside a short critical section keeps it until it resumes or exits, so other writers retry later without blocking the TUI. This preserves exclusion instead of stealing a live lock.
108
+
109
+ **Upgrade:** Close all old instances before starting updated ones. The legacy `lock` files are ignored; old and new locking protocols must not run together. Cached `usage.json` data needs no migration.
110
+
97
111
  ## Telegram Status Menu
98
112
 
99
113
  If `@llblab/pi-telegram` is loaded with the public status-line provider API, this extension registers an optional `/start` menu status row. The row is shown only while the active model uses the OpenAI Codex subscription provider:
100
114
 
101
115
  ```text
102
116
  codex: ██████▀▀▀▀ 6d
103
- spark: ██████████ 7d
104
117
  ```
105
118
 
106
- The value is the same compact quota bar plus weekly reset countdown used by the terminal statusline, and the label follows the active Codex/Spark model. If `pi-telegram` is absent, older, or the active model is not a Codex subscription model, no Telegram row is added.
119
+ The value is the same compact quota bar plus weekly reset countdown used by the terminal statusline, always with the `codex` label. If `pi-telegram` is absent, older, or the active model is not a Codex subscription model, no Telegram row is added.
107
120
 
108
121
  ## Auth
109
122
 
package/index.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
2
5
  import { createInterface } from "node:readline";
6
+ import { DatabaseSync } from "node:sqlite";
3
7
 
4
- import type {
5
- ExtensionAPI,
6
- ExtensionContext,
8
+ import {
9
+ type ExtensionAPI,
10
+ type ExtensionContext,
11
+ getAgentDir,
7
12
  } from "@earendil-works/pi-coding-agent";
8
13
 
9
14
  const CODEX_PROVIDER_ID = "openai-codex";
@@ -16,7 +21,12 @@ const HOUR_MS = 60 * MINUTE_MS;
16
21
  const HOUR_TENTH_MS = 6 * MINUTE_MS;
17
22
  const DAY_MS = 24 * HOUR_MS;
18
23
  const DAY_TENTH_MS = 144 * MINUTE_MS;
19
- const REFRESH_INTERVAL_MS = 60 * SECOND_MS;
24
+ /** How often an instance re-reads the shared file to redraw fresh data. */
25
+ const MAX_TICK_MS = 30 * SECOND_MS;
26
+ const MIN_TICK_MS = SECOND_MS;
27
+ const TAKEOVER_JITTER_MS = 2 * SECOND_MS;
28
+ /** A report older than this is no longer shown as current. */
29
+ const STALE_REPORT_MAX_AGE_MS = HOUR_MS;
20
30
  const PROVISIONAL_RETRY_MS = SECOND_MS;
21
31
  const FULL_AVAILABILITY_CONFIRMATION_MS = 15 * SECOND_MS;
22
32
  const LOADING_FRAME_MS = 30;
@@ -24,10 +34,7 @@ const REDRAW_BLINK_MS = 150;
24
34
  const STATUS_KEY = "aa-codex-usage";
25
35
  const MAX_ERROR_BODY_CHARS = 600;
26
36
  const DEFAULT_STATUS_LABEL_TEXT = "codex";
27
- const SPARK_STATUS_LABEL_TEXT = "spark";
28
37
  const CODEX_USAGE_LIMIT_ID = "codex";
29
- const SPARK_USAGE_LIMIT_ID = "spark";
30
- const SPARK_MODEL_KEY = "gpt-5.3-codex-spark";
31
38
  const DUAL_BAR_WIDTH = 10;
32
39
  const TELEGRAM_STATUS_IMPORT_SPECIFIERS = [
33
40
  "@llblab/pi-telegram/status",
@@ -73,15 +80,12 @@ type QueryUsageOptions = {
73
80
  timeoutMs: number;
74
81
  };
75
82
 
76
- type CachedReport = {
77
- createdAt: number;
78
- report: CodexUsageReport;
79
- };
80
-
81
83
  type QueryUsageResult =
82
84
  | { ok: true; report: CodexUsageReport }
83
85
  | { ok: false; errors: UsageQueryError[] };
84
86
 
87
+ type CodexSharedState = SharedState<CodexUsageReport>;
88
+
85
89
  export type UsageQueryError = {
86
90
  source: UsageSource;
87
91
  message: string;
@@ -116,12 +120,6 @@ type RateLimitStatusPayload = {
116
120
  spend_control?: unknown;
117
121
  };
118
122
 
119
- type BackendAdditionalRateLimit = {
120
- limit_name?: unknown;
121
- metered_feature?: unknown;
122
- rate_limit?: unknown;
123
- };
124
-
125
123
  type BackendRateLimitDetails = {
126
124
  primary_window?: unknown;
127
125
  secondary_window?: unknown;
@@ -170,33 +168,256 @@ type PendingRpc = {
170
168
  reject: (error: Error) => void;
171
169
  };
172
170
 
171
+ // --- Shared Refresh ---
172
+
173
+ /**
174
+ * Cross-instance coordination for quota polling. Every Pi instance reads one
175
+ * JSON file; a single "leader"
176
+ * refreshes it every `LEADER_INTERVAL_MS`. Any other instance may take over once
177
+ * the file is `TAKEOVER_AFTER_MS` old: it first claims leadership (owner and
178
+ * timestamp) so nobody else is due, then fetches, then stamps the result. The
179
+ * file is the only authority for requests and fenced publication. Writes are
180
+ * atomic renames; short critical sections use an OS-backed SQLite mutex.
181
+ */
182
+
183
+ export const LEADER_INTERVAL_MS = 60_000;
184
+ /** The leader is considered gone after missing its slot by 30 seconds. */
185
+ export const TAKEOVER_AFTER_MS = LEADER_INTERVAL_MS + 30_000;
186
+ /** Minimum pause between two fetch attempts of the same instance. */
187
+ export const MIN_ATTEMPT_GAP_MS = 60_000;
188
+
189
+ const STATE_FILE = "usage.json";
190
+ const LOCK_FILE = "mutex.sqlite";
191
+
192
+ export type SharedState<Report = unknown> = {
193
+ report?: Report;
194
+ /** When `report` was last fetched successfully. */
195
+ updatedAt?: number;
196
+ /**
197
+ * When the leader last touched the file: written when it claims a refresh,
198
+ * before fetching, and again when the fetch finishes.
199
+ */
200
+ claimedAt?: number;
201
+ /** Instance id of the current leader (the last instance to claim). */
202
+ owner?: string;
203
+ /** Unique generation for this refresh, including renewals by the same owner. */
204
+ claimId?: string;
205
+ /** Last failure message; cleared by the next success. */
206
+ error?: string;
207
+ /** The failure means "no quota available" (n/a), not a runtime error. */
208
+ unavailable?: boolean;
209
+ failures?: number;
210
+ /** No instance should fetch before this time. */
211
+ retryNotBefore?: number;
212
+ };
213
+
214
+ export function readState<Report>(
215
+ dir: string,
216
+ ): SharedState<Report> | undefined {
217
+ try {
218
+ const parsed = JSON.parse(
219
+ readFileSync(join(dir, STATE_FILE), "utf8"),
220
+ ) as unknown;
221
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
222
+ ? (parsed as SharedState<Report>)
223
+ : undefined;
224
+ } catch {
225
+ return undefined;
226
+ }
227
+ }
228
+
229
+ export function writeState<Report>(
230
+ dir: string,
231
+ state: SharedState<Report>,
232
+ ): boolean {
233
+ try {
234
+ mkdirSync(dir, { recursive: true });
235
+ const temp = join(dir, `${STATE_FILE}.${process.pid}.tmp`);
236
+ writeFileSync(temp, JSON.stringify(state), { mode: 0o600 });
237
+ renameSync(temp, join(dir, STATE_FILE));
238
+ return true;
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+
244
+ export type RefreshClaim = { owner: string; claimId: string };
245
+
246
+ export type RefreshOutcome<Report> =
247
+ | { ok: true; report: Report }
248
+ | {
249
+ ok: false;
250
+ error: string;
251
+ unavailable?: boolean;
252
+ rateLimited: boolean;
253
+ retryAfterMs?: number;
254
+ };
255
+
256
+ /** Read, decide and claim under the lock; never authorize an unwritten claim. */
257
+ export function claimRefresh(
258
+ dir: string,
259
+ owner: string,
260
+ now?: number,
261
+ ): RefreshClaim | undefined {
262
+ const release = tryAcquireLock(dir);
263
+ if (!release) return undefined;
264
+ try {
265
+ const current = readState(dir);
266
+ const at = now ?? Date.now();
267
+ if (!isRefreshDue(current, owner, at)) return undefined;
268
+ const claim = { owner, claimId: randomUUID() };
269
+ return writeState(dir, { ...current, ...claim, claimedAt: at })
270
+ ? claim
271
+ : undefined;
272
+ } finally {
273
+ release();
274
+ }
275
+ }
276
+
277
+ function matchesClaim(
278
+ state: SharedState | undefined,
279
+ claim: RefreshClaim,
280
+ now: number,
281
+ ): boolean {
282
+ return (
283
+ state?.owner === claim.owner &&
284
+ state.claimId === claim.claimId &&
285
+ typeof state.claimedAt === "number" &&
286
+ now - state.claimedAt < TAKEOVER_AFTER_MS
287
+ );
288
+ }
289
+
290
+ /** Each admission check reads the file, not a remembered leadership flag. */
291
+ export function ownsRefreshClaim(
292
+ dir: string,
293
+ claim: RefreshClaim,
294
+ now?: number,
295
+ ): boolean {
296
+ const current = readState(dir);
297
+ return matchesClaim(current, claim, now ?? Date.now());
298
+ }
299
+
300
+ /** A late success OR failure must not overwrite a successor's state. */
301
+ export function publishRefresh<Report>(
302
+ dir: string,
303
+ claim: RefreshClaim,
304
+ outcome: RefreshOutcome<Report>,
305
+ now?: number,
306
+ ): boolean {
307
+ const release = tryAcquireLock(dir);
308
+ if (!release) return false;
309
+ try {
310
+ const current = readState<Report>(dir);
311
+ const at = now ?? Date.now();
312
+ if (!matchesClaim(current, claim, at)) return false;
313
+ if (outcome.ok) {
314
+ return writeState(dir, {
315
+ ...claim,
316
+ claimedAt: at,
317
+ updatedAt: at,
318
+ report: outcome.report,
319
+ });
320
+ }
321
+ const failures = (current?.failures ?? 0) + 1;
322
+ return writeState(dir, {
323
+ ...current,
324
+ ...claim,
325
+ claimedAt: at,
326
+ error: outcome.error,
327
+ unavailable: outcome.unavailable,
328
+ failures,
329
+ retryNotBefore: at + failureBackoffMs(failures, outcome),
330
+ });
331
+ } finally {
332
+ release();
333
+ }
334
+ }
335
+
336
+ /** Earliest time at which `owner` should try to refresh the state. */
337
+ export function nextRefreshAt(
338
+ state: SharedState<unknown> | undefined,
339
+ owner: string,
340
+ now: number,
341
+ ): number {
342
+ const hold = state?.retryNotBefore ?? 0;
343
+ const touchedAt = Math.max(
344
+ state?.updatedAt ?? -Infinity,
345
+ state?.claimedAt ?? -Infinity,
346
+ );
347
+ if (touchedAt === -Infinity) return Math.max(now, hold);
348
+ const interval =
349
+ state?.owner === owner ? LEADER_INTERVAL_MS : TAKEOVER_AFTER_MS;
350
+ return Math.max(touchedAt + interval, hold);
351
+ }
352
+
353
+ export function isRefreshDue(
354
+ state: SharedState<unknown> | undefined,
355
+ owner: string,
356
+ now: number,
357
+ ): boolean {
358
+ return nextRefreshAt(state, owner, now) <= now;
359
+ }
360
+
361
+ /** Exponential failure backoff; HTTP 429 gets a longer base and cap. */
362
+ export function failureBackoffMs(
363
+ failures: number,
364
+ options: { rateLimited: boolean; retryAfterMs?: number },
365
+ ): number {
366
+ const base = options.rateLimited ? 5 * 60_000 : 60_000;
367
+ const cap = options.rateLimited ? 30 * 60_000 : 5 * 60_000;
368
+ const exponential = Math.min(cap, base * 2 ** Math.max(0, failures - 1));
369
+ return Math.max(options.retryAfterMs ?? 0, exponential);
370
+ }
371
+
372
+ /**
373
+ * An empty SQLite transaction is a non-waiting, OS-backed mutex, not storage
374
+ * for quota or leadership. Close or process death releases it; a paused live
375
+ * holder cannot be evicted. Never unlink/replace mutex.sqlite while in use.
376
+ */
377
+ export function tryAcquireLock(dir: string): (() => void) | undefined {
378
+ let database: DatabaseSync | undefined;
379
+ try {
380
+ mkdirSync(dir, { recursive: true });
381
+ database = new DatabaseSync(join(dir, LOCK_FILE));
382
+ // Contention must not wait on Pi's TUI thread; PRAGMA also works on Node 22.
383
+ database.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE");
384
+ } catch {
385
+ database?.close();
386
+ return undefined;
387
+ }
388
+ return () => {
389
+ const held = database;
390
+ database = undefined;
391
+ held?.close();
392
+ };
393
+ }
394
+
173
395
  export default function codexUsage(pi: ExtensionAPI) {
174
- let cache: CachedReport | undefined;
175
- let failedRefreshes = 0;
176
- let inFlightUsageQuery:
177
- { limitId: string; promise: Promise<QueryUsageResult> } | undefined;
396
+ const instanceId = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
397
+ const stateDir = join(getAgentDir(), "tmp", "pi-codex-usage");
398
+ let lastAttemptAt = 0;
399
+ let inFlightRefresh: Promise<void> | undefined;
400
+ let shown: { key?: string; report?: CodexUsageReport; updatedAt?: number } = {};
178
401
  let statuslineBlinkTimer: TimeoutHandle | undefined;
179
- let statuslineClearTimer: TimeoutHandle | undefined;
180
402
  let statuslineCountdownTimer: TimeoutHandle | undefined;
181
403
  let statuslineLoadingTimer: TimeoutHandle | undefined;
182
404
  let statuslineRefreshTimer: TimeoutHandle | undefined;
183
405
  let statuslineLoadingFrame = 0;
184
- let statuslineLoadingLimitId: string | undefined;
185
406
  let statuslineRequestId = 0;
186
- let provisionalFullReport:
187
- { limitId: string; firstSeenAt: number } | undefined;
188
407
  let unregisterTelegramStatusLine: (() => void) | undefined;
189
408
  let telegramStatusLineRegistration: Promise<void> | undefined;
190
409
 
410
+ const loadState = () => readState<CodexUsageReport>(stateDir);
411
+
191
412
  const ensureTelegramStatusLineRegistered = () => {
192
413
  if (unregisterTelegramStatusLine || telegramStatusLineRegistration) return;
193
414
  telegramStatusLineRegistration = registerCodexUsageTelegramStatusLine(
194
415
  ({ activeModel }) => {
195
416
  if (!isOpenAICodexModel(activeModel)) return undefined;
196
- if (!cache) return undefined;
197
- const value = formatCodexUsageStatusValue(cache.report, activeModel);
417
+ if (!shown.report) return undefined;
418
+ const value = formatCodexUsageStatusValue(shown.report, activeModel);
198
419
  return value
199
- ? { label: activeUsageLabel(activeModel), value }
420
+ ? { label: DEFAULT_STATUS_LABEL_TEXT, value }
200
421
  : undefined;
201
422
  },
202
423
  )
@@ -210,33 +431,26 @@ export default function codexUsage(pi: ExtensionAPI) {
210
431
 
211
432
  const clearStatuslineTimers = () => {
212
433
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
213
- if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
214
434
  if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
215
435
  if (statuslineLoadingTimer) clearTimeout(statuslineLoadingTimer);
216
436
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
217
437
  statuslineBlinkTimer = undefined;
218
- statuslineClearTimer = undefined;
219
438
  statuslineCountdownTimer = undefined;
220
439
  statuslineLoadingTimer = undefined;
221
- statuslineLoadingLimitId = undefined;
222
440
  statuslineRefreshTimer = undefined;
223
441
  };
224
442
 
225
443
  const stopStatuslineLoading = () => {
226
444
  if (statuslineLoadingTimer) clearTimeout(statuslineLoadingTimer);
227
445
  statuslineLoadingTimer = undefined;
228
- statuslineLoadingLimitId = undefined;
229
446
  };
230
447
 
231
448
  const startStatuslineLoading = (
232
449
  ctx: ExtensionContext,
233
450
  model: CodexUsageModel | undefined,
234
451
  ) => {
235
- const limitId = activeUsageLimitId(model);
236
- if (statuslineLoadingTimer && statuslineLoadingLimitId === limitId) return;
237
- stopStatuslineLoading();
452
+ if (statuslineLoadingTimer) return;
238
453
  statuslineLoadingFrame = Math.random() < 0.5 ? 0 : DUAL_BAR_WIDTH * 2 - 1;
239
- statuslineLoadingLimitId = limitId;
240
454
  const drawNextFrame = () => {
241
455
  try {
242
456
  ctx.ui.setStatus(
@@ -260,29 +474,14 @@ export default function codexUsage(pi: ExtensionAPI) {
260
474
  const clearUsageStatusline = (ctx: ExtensionContext) => {
261
475
  statuslineRequestId += 1;
262
476
  clearStatuslineTimers();
477
+ shown = {};
263
478
  ctx.ui.setStatus(STATUS_KEY, undefined);
264
479
  };
265
480
 
266
- const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
267
- if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
268
- statuslineClearTimer = setTimeout(() => {
269
- try {
270
- ctx.ui.setStatus(STATUS_KEY, undefined);
271
- statuslineClearTimer = undefined;
272
- } catch (error) {
273
- handleTimerError(error);
274
- }
275
- }, REFRESH_INTERVAL_MS) as TimeoutHandle;
276
- statuslineClearTimer.unref?.();
277
- };
278
-
279
- const scheduleStatuslineRefresh = (
280
- ctx: ExtensionContext,
281
- delayMs = REFRESH_INTERVAL_MS,
282
- ) => {
481
+ const scheduleStatuslineRefresh = (ctx: ExtensionContext, delayMs: number) => {
283
482
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
284
483
  statuslineRefreshTimer = setTimeout(() => {
285
- void refreshCurrentCodexUsageStatusline(ctx, true).catch(
484
+ void refreshCurrentCodexUsageStatusline(ctx, false).catch(
286
485
  handleAsyncTimerError,
287
486
  );
288
487
  }, delayMs) as TimeoutHandle;
@@ -319,18 +518,12 @@ export default function codexUsage(pi: ExtensionAPI) {
319
518
  const setUsageStatusline = (
320
519
  ctx: ExtensionContext,
321
520
  report: CodexUsageReport,
322
- options: {
323
- autoRefresh: boolean;
324
- blink: boolean;
325
- model: CodexUsageModel | undefined;
326
- },
521
+ options: { blink: boolean; model: CodexUsageModel | undefined },
327
522
  ) => {
328
523
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
329
- if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
330
524
  if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
331
525
  stopStatuslineLoading();
332
526
  statuslineBlinkTimer = undefined;
333
- statuslineClearTimer = undefined;
334
527
  statuslineCountdownTimer = undefined;
335
528
  const text = formatCodexUsageStatusline(report, ctx, options.model);
336
529
  if (options.blink) {
@@ -352,32 +545,148 @@ export default function codexUsage(pi: ExtensionAPI) {
352
545
  ctx.ui.setStatus(STATUS_KEY, text);
353
546
  scheduleStatuslineCountdown(ctx, report, options.model);
354
547
  }
355
- if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
356
- else scheduleTemporaryStatuslineClear(ctx);
357
548
  };
358
549
 
359
- const queryCurrentUsage = (
550
+ /** Draws the shared state; skips redraws when nothing changed. */
551
+ const renderState = (
360
552
  ctx: ExtensionContext,
553
+ state: CodexSharedState | undefined,
361
554
  model: CodexUsageModel | undefined,
555
+ force: boolean,
362
556
  ) => {
363
- const limitId = activeUsageLimitId(model);
364
- if (inFlightUsageQuery?.limitId === limitId)
365
- return inFlightUsageQuery.promise;
366
- const promise = queryUsage(
557
+ // Cached display data is not permission to query when the file is unreadable.
558
+ if (!state && shown.report && Date.now() - (shown.updatedAt ?? 0) < STALE_REPORT_MAX_AGE_MS) {
559
+ if (force) setUsageStatusline(ctx, shown.report, { blink: false, model });
560
+ return;
561
+ }
562
+ const usable =
563
+ state?.report &&
564
+ state.updatedAt !== undefined &&
565
+ Date.now() - state.updatedAt < STALE_REPORT_MAX_AGE_MS &&
566
+ canReuseCachedReport(state.report, model)
567
+ ? state.report
568
+ : undefined;
569
+ if (usable) {
570
+ const key = `report:${state?.updatedAt}`;
571
+ if (!force && shown.key === key) return;
572
+ const blink = shown.report
573
+ ? formatReportBar(shown.report, model) !== formatReportBar(usable, model)
574
+ : false;
575
+ shown = { key, report: usable, updatedAt: state?.updatedAt };
576
+ setUsageStatusline(ctx, usable, { blink, model });
577
+ } else if (state?.error) {
578
+ const key = `error:${state.error}`;
579
+ if (!force && shown.key === key) return;
580
+ shown = { key };
581
+ clearStatuslineTimers();
582
+ ctx.ui.setStatus(
583
+ STATUS_KEY,
584
+ formatStatuslineProblem(ctx, state.unavailable === true, model),
585
+ );
586
+ } else {
587
+ shown = { key: "loading" };
588
+ startStatuslineLoading(ctx, model);
589
+ }
590
+ };
591
+
592
+ /**
593
+ * When this instance is due (leader after 1 minute, anyone after 90 seconds) it
594
+ * claims leadership under the lock (owner and timestamp, so no other
595
+ * instance is due), then fetches, then stamps the outcome. Failures are
596
+ * published with a backoff so other instances do not pile on.
597
+ */
598
+ const refreshSharedState = (
599
+ ctx: ExtensionContext,
600
+ model: CodexUsageModel | undefined,
601
+ ) => {
602
+ if (inFlightRefresh) return inFlightRefresh;
603
+ const promise = (async () => {
604
+ const claim = claimRefresh(stateDir, instanceId);
605
+ if (!claim) return;
606
+ lastAttemptAt = Date.now();
607
+ const result = await queryConfirmedUsage(
608
+ ctx,
609
+ model,
610
+ loadState()?.report,
611
+ () => ownsRefreshClaim(stateDir, claim),
612
+ );
613
+ if (!result) return;
614
+ if (result.ok) {
615
+ publishRefresh(stateDir, claim, result);
616
+ return;
617
+ }
618
+ if (result.errors.some((error) => isStaleExtensionContextError(error.cause)))
619
+ return;
620
+ publishRefresh(stateDir, claim, {
621
+ ok: false,
622
+ error: result.errors.map((error) => error.message).join("; "),
623
+ unavailable: isUsageUnavailable(result.errors),
624
+ rateLimited: result.errors.some((error) =>
625
+ error.message.includes("returned 429"),
626
+ ),
627
+ });
628
+ })().finally(() => {
629
+ if (inFlightRefresh === promise) inFlightRefresh = undefined;
630
+ });
631
+ inFlightRefresh = promise;
632
+ return promise;
633
+ };
634
+
635
+ /**
636
+ * Queries the usage. A first report claiming every window is completely
637
+ * unused is provisional (providers can briefly emit zeroed windows while
638
+ * initializing), so the claiming leader re-queries it every second for up to
639
+ * 15 seconds before publishing.
640
+ */
641
+ const queryConfirmedUsage = async (
642
+ ctx: ExtensionContext,
643
+ model: CodexUsageModel | undefined,
644
+ previous: CodexUsageReport | undefined,
645
+ mayQuery: () => boolean,
646
+ ): Promise<QueryUsageResult | undefined> => {
647
+ const startedAt = Date.now();
648
+ let result = await queryUsage(
367
649
  ctx,
368
650
  { timeoutMs: DEFAULT_TIMEOUT_MS },
369
651
  model,
370
- ).finally(() => {
371
- if (inFlightUsageQuery?.promise === promise)
372
- inFlightUsageQuery = undefined;
373
- });
374
- inFlightUsageQuery = { limitId, promise };
375
- return promise;
652
+ mayQuery,
653
+ );
654
+ if (previous && isFullyAvailableReport(previous, model)) return result;
655
+ while (
656
+ result?.ok &&
657
+ isFullyAvailableReport(result.report, model) &&
658
+ Date.now() - startedAt < FULL_AVAILABILITY_CONFIRMATION_MS
659
+ ) {
660
+ await new Promise((resolve) => setTimeout(resolve, PROVISIONAL_RETRY_MS));
661
+ const retry = await queryUsage(
662
+ ctx,
663
+ { timeoutMs: DEFAULT_TIMEOUT_MS },
664
+ model,
665
+ mayQuery,
666
+ );
667
+ if (!retry) return undefined;
668
+ if (!retry.ok) break;
669
+ result = retry;
670
+ }
671
+ return result;
672
+ };
673
+
674
+ const nextTickDelayMs = (state: CodexSharedState | undefined) => {
675
+ // Nothing to show yet (e.g. a claimed fetch is in flight): poll quickly.
676
+ if (shown.key === "loading") return MIN_TICK_MS;
677
+ const now = Date.now();
678
+ const dueAt = Math.max(
679
+ nextRefreshAt(state, instanceId, now),
680
+ lastAttemptAt + MIN_ATTEMPT_GAP_MS,
681
+ );
682
+ const jitter =
683
+ state?.owner === instanceId ? 0 : Math.random() * TAKEOVER_JITTER_MS;
684
+ return Math.min(MAX_TICK_MS, Math.max(MIN_TICK_MS, dueAt - now + jitter));
376
685
  };
377
686
 
378
687
  const refreshCurrentCodexUsageStatusline = async (
379
688
  ctx: ExtensionContext,
380
- force: boolean,
689
+ forceRender: boolean,
381
690
  model?: CodexUsageModel,
382
691
  ) => {
383
692
  try {
@@ -387,93 +696,26 @@ export default function codexUsage(pi: ExtensionAPI) {
387
696
  return;
388
697
  }
389
698
 
390
- const usableCache =
391
- cache && canReuseCachedReport(cache.report, activeModel)
392
- ? cache
393
- : undefined;
394
- if (usableCache) {
395
- setUsageStatusline(ctx, usableCache.report, {
396
- autoRefresh: true,
397
- blink: false,
398
- model: activeModel,
399
- });
400
- } else {
401
- startStatuslineLoading(ctx, activeModel);
402
- }
403
699
  const requestId = statuslineRequestId + 1;
404
700
  statuslineRequestId = requestId;
405
- const freshCache =
406
- usableCache && Date.now() - usableCache.createdAt < REFRESH_INTERVAL_MS
407
- ? usableCache
408
- : undefined;
409
- if (freshCache && !force) {
410
- setUsageStatusline(ctx, freshCache.report, {
411
- autoRefresh: true,
412
- blink: false,
413
- model: activeModel,
414
- });
415
- return;
416
- }
701
+ let state = loadState();
702
+ renderState(ctx, state, activeModel, forceRender);
417
703
 
418
- const result = await queryCurrentUsage(ctx, activeModel);
419
- if (requestId !== statuslineRequestId) return;
420
- if (!isOpenAICodexModel(ctx.model)) {
421
- clearUsageStatusline(ctx);
422
- return;
423
- }
424
-
425
- if (!result.ok) {
426
- failedRefreshes += 1;
427
- const activeCache =
428
- cache && canReuseCachedReport(cache.report, activeModel)
429
- ? cache
430
- : undefined;
431
- if (!activeCache || failedRefreshes >= 5) {
432
- stopStatuslineLoading();
433
- ctx.ui.setStatus(
434
- STATUS_KEY,
435
- formatStatuslineProblem(ctx, result.errors, activeModel),
436
- );
437
- }
438
- scheduleStatuslineRefresh(ctx);
439
- return;
440
- }
441
-
442
- const previousReport = cache?.report;
443
- const previousWasFullyAvailable = previousReport
444
- ? isFullyAvailableReport(previousReport, activeModel)
445
- : false;
704
+ const now = Date.now();
446
705
  if (
447
- isFullyAvailableReport(result.report, activeModel) &&
448
- !previousWasFullyAvailable
706
+ isRefreshDue(state, instanceId, now) &&
707
+ now - lastAttemptAt >= MIN_ATTEMPT_GAP_MS
449
708
  ) {
450
- const now = Date.now();
451
- const limitId = activeUsageLimitId(activeModel);
452
- if (provisionalFullReport?.limitId !== limitId) {
453
- provisionalFullReport = { limitId, firstSeenAt: now };
454
- }
455
- if (
456
- now - provisionalFullReport.firstSeenAt <
457
- FULL_AVAILABILITY_CONFIRMATION_MS
458
- ) {
459
- scheduleStatuslineRefresh(ctx, PROVISIONAL_RETRY_MS);
709
+ await refreshSharedState(ctx, activeModel);
710
+ if (requestId !== statuslineRequestId) return;
711
+ if (!isOpenAICodexModel(ctx.model)) {
712
+ clearUsageStatusline(ctx);
460
713
  return;
461
714
  }
462
- } else {
463
- provisionalFullReport = undefined;
715
+ state = loadState();
716
+ renderState(ctx, state, activeModel, false);
464
717
  }
465
- const blink = previousReport
466
- ? formatReportBar(previousReport, activeModel) !==
467
- formatReportBar(result.report, activeModel)
468
- : false;
469
- failedRefreshes = 0;
470
- provisionalFullReport = undefined;
471
- cache = { createdAt: Date.now(), report: result.report };
472
- setUsageStatusline(ctx, result.report, {
473
- autoRefresh: true,
474
- blink,
475
- model: activeModel,
476
- });
718
+ scheduleStatuslineRefresh(ctx, nextTickDelayMs(state));
477
719
  } catch (error) {
478
720
  if (isStaleExtensionContextError(error)) {
479
721
  clearStatuslineTimers();
@@ -488,7 +730,7 @@ export default function codexUsage(pi: ExtensionAPI) {
488
730
  pi.on("session_start", (_event, ctx) => {
489
731
  ensureTelegramStatusLineRegistered();
490
732
  if (isOpenAICodexModel(ctx.model))
491
- void refreshCurrentCodexUsageStatusline(ctx, false).catch(
733
+ void refreshCurrentCodexUsageStatusline(ctx, true).catch(
492
734
  handleAsyncTimerError,
493
735
  );
494
736
  else clearUsageStatusline(ctx);
@@ -496,7 +738,7 @@ export default function codexUsage(pi: ExtensionAPI) {
496
738
 
497
739
  pi.on("session_tree", (_event, ctx) => {
498
740
  if (isOpenAICodexModel(ctx.model))
499
- void refreshCurrentCodexUsageStatusline(ctx, false).catch(
741
+ void refreshCurrentCodexUsageStatusline(ctx, true).catch(
500
742
  handleAsyncTimerError,
501
743
  );
502
744
  else clearUsageStatusline(ctx);
@@ -504,7 +746,7 @@ export default function codexUsage(pi: ExtensionAPI) {
504
746
 
505
747
  pi.on("model_select", (event, ctx) => {
506
748
  if (isOpenAICodexModel(event.model)) {
507
- void refreshCurrentCodexUsageStatusline(ctx, false, event.model).catch(
749
+ void refreshCurrentCodexUsageStatusline(ctx, true, event.model).catch(
508
750
  handleAsyncTimerError,
509
751
  );
510
752
  } else {
@@ -538,28 +780,6 @@ function isOpenAICodexModel(
538
780
  return model?.provider === CODEX_PROVIDER_ID;
539
781
  }
540
782
 
541
- function isSparkCodexModel(
542
- model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
543
- ): boolean {
544
- if (!isOpenAICodexModel(model)) return false;
545
- const key = `${model?.id ?? ""} ${model?.name ?? ""}`.toLowerCase();
546
- return key.includes(SPARK_MODEL_KEY);
547
- }
548
-
549
- function activeUsageLimitId(
550
- model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
551
- ): string {
552
- return isSparkCodexModel(model) ? SPARK_USAGE_LIMIT_ID : CODEX_USAGE_LIMIT_ID;
553
- }
554
-
555
- function activeUsageLabel(
556
- model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
557
- ): string {
558
- return isSparkCodexModel(model)
559
- ? SPARK_STATUS_LABEL_TEXT
560
- : DEFAULT_STATUS_LABEL_TEXT;
561
- }
562
-
563
783
  async function importTelegramStatusLineModule(): Promise<
564
784
  TelegramStatusLineModule | undefined
565
785
  > {
@@ -589,27 +809,28 @@ async function queryUsage(
589
809
  ctx: ExtensionContext,
590
810
  options: Pick<QueryUsageOptions, "timeoutMs">,
591
811
  model: CodexUsageModel | undefined,
592
- ): Promise<QueryUsageResult> {
812
+ mayQuery: () => boolean,
813
+ ): Promise<QueryUsageResult | undefined> {
593
814
  const errors: UsageQueryError[] = [];
594
- const sources = isSparkCodexModel(model)
595
- ? (["codex-app-server", "pi-auth"] as const)
596
- : (["pi-auth", "codex-app-server"] as const);
815
+ const sources = ["pi-auth", "codex-app-server"] as const;
597
816
 
598
817
  for (const source of sources) {
818
+ if (!mayQuery()) return undefined;
599
819
  try {
600
820
  const report =
601
821
  source === "pi-auth"
602
- ? await queryViaPiAuth(ctx, options.timeoutMs)
603
- : await queryViaCodexAppServer(options.timeoutMs);
822
+ ? await queryViaPiAuth(ctx, options.timeoutMs, mayQuery)
823
+ : await queryViaCodexAppServer(options.timeoutMs, mayQuery);
824
+ if (!report) return undefined;
604
825
  if (
605
- selectUsageSnapshot(report, activeUsageLimitId(model)) ||
826
+ selectUsageSnapshot(report, CODEX_USAGE_LIMIT_ID) ||
606
827
  report.credits
607
828
  ) {
608
829
  return { ok: true, report };
609
830
  }
610
831
  errors.push({
611
832
  source,
612
- message: `${source} returned no displayable ${activeUsageLabel(model)} rate-limit windows`,
833
+ message: `${source} returned no displayable codex rate-limit windows`,
613
834
  });
614
835
  } catch (cause) {
615
836
  errors.push({ source, message: errorMessage(cause), cause });
@@ -622,7 +843,8 @@ async function queryUsage(
622
843
  async function queryViaPiAuth(
623
844
  ctx: ExtensionContext,
624
845
  timeoutMs: number,
625
- ): Promise<CodexUsageReport> {
846
+ mayQuery: () => boolean,
847
+ ): Promise<CodexUsageReport | undefined> {
626
848
  const auth = await resolvePiCodexAuth(ctx);
627
849
  if (!auth) {
628
850
  throw new Error(
@@ -630,6 +852,7 @@ async function queryViaPiAuth(
630
852
  );
631
853
  }
632
854
 
855
+ if (!mayQuery()) return undefined;
633
856
  const response = await fetchWithTimeout(
634
857
  CODEX_USAGE_URL,
635
858
  { headers: auth.headers },
@@ -724,10 +947,12 @@ async function fetchWithTimeout(
724
947
 
725
948
  async function queryViaCodexAppServer(
726
949
  timeoutMs: number,
727
- ): Promise<CodexUsageReport> {
950
+ mayQuery: () => boolean,
951
+ ): Promise<CodexUsageReport | undefined> {
728
952
  const client = new CodexAppServerClient(timeoutMs);
729
953
  try {
730
954
  await client.start();
955
+ if (!mayQuery()) return undefined;
731
956
  await client.request("initialize", {
732
957
  clientInfo: {
733
958
  name: "pi_codex_usage",
@@ -741,6 +966,7 @@ async function queryViaCodexAppServer(
741
966
  },
742
967
  });
743
968
  client.notify("initialized");
969
+ if (!mayQuery()) return undefined;
744
970
  const result = await client.request("account/rateLimits/read", undefined);
745
971
  return normalizeAppServerResponse(
746
972
  assertObject(
@@ -916,21 +1142,6 @@ export function normalizeBackendPayload(
916
1142
  );
917
1143
  if (primarySnapshot) snapshots.push(primarySnapshot);
918
1144
 
919
- if (Array.isArray(payload.additional_rate_limits)) {
920
- for (const item of payload.additional_rate_limits) {
921
- const additional = assertObject(
922
- item,
923
- "additional rate limit",
924
- ) as BackendAdditionalRateLimit;
925
- const snapshot = normalizeBackendSnapshot(
926
- backendAdditionalLimitId(additional),
927
- additional.rate_limit,
928
- _capturedAt,
929
- );
930
- if (snapshot) snapshots.push(snapshot);
931
- }
932
- }
933
-
934
1145
  const credits = normalizeBackendCredits(payload, _capturedAt);
935
1146
  if (snapshots.length === 0 && !credits) {
936
1147
  throw new Error(
@@ -983,13 +1194,6 @@ function normalizeBackendCredits(
983
1194
  : { remainingPercent, resetAt };
984
1195
  }
985
1196
 
986
- function backendAdditionalLimitId(limit: BackendAdditionalRateLimit): string {
987
- const raw = `${asString(limit.limit_name) ?? ""} ${asString(limit.metered_feature) ?? ""}`;
988
- return raw.toLowerCase().includes("spark")
989
- ? SPARK_USAGE_LIMIT_ID
990
- : (normalizedUsageKey(raw) ?? CODEX_USAGE_LIMIT_ID);
991
- }
992
-
993
1197
  function normalizeBackendSnapshot(
994
1198
  limitId: string,
995
1199
  rateLimit: unknown,
@@ -1079,6 +1283,7 @@ function normalizeAppServerSnapshot(
1079
1283
  "app-server rate-limit snapshot",
1080
1284
  ) as AppServerRateLimitSnapshot;
1081
1285
  const limitId = asString(snapshot.limitId) ?? fallbackId;
1286
+ if (normalizedUsageKey(limitId) !== CODEX_USAGE_LIMIT_ID) return undefined;
1082
1287
  const primary = normalizeAppServerWindow(snapshot.primary, capturedAt);
1083
1288
  const secondary = normalizeAppServerWindow(snapshot.secondary, capturedAt);
1084
1289
  if (!primary && !secondary) return undefined;
@@ -1290,9 +1495,9 @@ function formatReportBar(
1290
1495
  function formatStatuslineText(
1291
1496
  ctx: ExtensionContext,
1292
1497
  value: string,
1293
- model?: CodexUsageModel,
1498
+ _model?: CodexUsageModel,
1294
1499
  ): string {
1295
- const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
1500
+ const label = ctx.ui.theme.fg("accent", DEFAULT_STATUS_LABEL_TEXT);
1296
1501
  return `${label} ${ctx.ui.theme.fg("dim", value)}`;
1297
1502
  }
1298
1503
 
@@ -1300,9 +1505,9 @@ function formatStatuslineBarText(
1300
1505
  ctx: ExtensionContext,
1301
1506
  bar: string,
1302
1507
  background: "userMessageBg" | "toolErrorBg",
1303
- model?: CodexUsageModel,
1508
+ _model?: CodexUsageModel,
1304
1509
  ): string {
1305
- const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
1510
+ const label = ctx.ui.theme.fg("accent", DEFAULT_STATUS_LABEL_TEXT);
1306
1511
  const value = ctx.ui.theme.bg(background, ctx.ui.theme.fg("dim", bar));
1307
1512
  return `${label} ${value}`;
1308
1513
  }
@@ -1338,11 +1543,11 @@ function formatStatuslineLoading(
1338
1543
 
1339
1544
  function formatStatuslineProblem(
1340
1545
  ctx: ExtensionContext,
1341
- errors: UsageQueryError[],
1342
- model?: CodexUsageModel,
1546
+ unavailable: boolean,
1547
+ _model?: CodexUsageModel,
1343
1548
  ): string {
1344
- const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
1345
- const value = isUsageUnavailable(errors)
1549
+ const label = ctx.ui.theme.fg("accent", DEFAULT_STATUS_LABEL_TEXT);
1550
+ const value = unavailable
1346
1551
  ? ctx.ui.theme.fg("muted", "n/a")
1347
1552
  : ctx.ui.theme.fg("error", "error");
1348
1553
  return `${label} ${value}`;
@@ -1490,9 +1695,9 @@ function weeklyWindow(
1490
1695
 
1491
1696
  function selectActiveUsageSnapshot(
1492
1697
  report: CodexUsageReport,
1493
- model: CodexUsageModel | undefined,
1698
+ _model: CodexUsageModel | undefined,
1494
1699
  ): NormalizedRateLimitSnapshot | undefined {
1495
- return selectUsageSnapshot(report, activeUsageLimitId(model));
1700
+ return selectUsageSnapshot(report, CODEX_USAGE_LIMIT_ID);
1496
1701
  }
1497
1702
 
1498
1703
  function selectUsageSnapshot(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits",
6
6
  "keywords": [