@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 +3 -3
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +5 -1
- package/README.md +29 -16
- package/index.ts +427 -222
- package/package.json +1 -1
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
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
|
18
|
-
- Keeps the last usable
|
|
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
|
-
-
|
|
21
|
-
- `
|
|
22
|
-
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
let
|
|
177
|
-
|
|
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 (!
|
|
197
|
-
const value = formatCodexUsageStatusValue(
|
|
417
|
+
if (!shown.report) return undefined;
|
|
418
|
+
const value = formatCodexUsageStatusValue(shown.report, activeModel);
|
|
198
419
|
return value
|
|
199
|
-
? { label:
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
|
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
|
-
|
|
448
|
-
|
|
706
|
+
isRefreshDue(state, instanceId, now) &&
|
|
707
|
+
now - lastAttemptAt >= MIN_ATTEMPT_GAP_MS
|
|
449
708
|
) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
|
|
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
|
-
|
|
463
|
-
|
|
715
|
+
state = loadState();
|
|
716
|
+
renderState(ctx, state, activeModel, false);
|
|
464
717
|
}
|
|
465
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
812
|
+
mayQuery: () => boolean,
|
|
813
|
+
): Promise<QueryUsageResult | undefined> {
|
|
593
814
|
const errors: UsageQueryError[] = [];
|
|
594
|
-
const sources =
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1498
|
+
_model?: CodexUsageModel,
|
|
1294
1499
|
): string {
|
|
1295
|
-
const label = ctx.ui.theme.fg("accent",
|
|
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
|
-
|
|
1508
|
+
_model?: CodexUsageModel,
|
|
1304
1509
|
): string {
|
|
1305
|
-
const label = ctx.ui.theme.fg("accent",
|
|
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
|
-
|
|
1342
|
-
|
|
1546
|
+
unavailable: boolean,
|
|
1547
|
+
_model?: CodexUsageModel,
|
|
1343
1548
|
): string {
|
|
1344
|
-
const label = ctx.ui.theme.fg("accent",
|
|
1345
|
-
const value =
|
|
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
|
-
|
|
1698
|
+
_model: CodexUsageModel | undefined,
|
|
1494
1699
|
): NormalizedRateLimitSnapshot | undefined {
|
|
1495
|
-
return selectUsageSnapshot(report,
|
|
1700
|
+
return selectUsageSnapshot(report, CODEX_USAGE_LIMIT_ID);
|
|
1496
1701
|
}
|
|
1497
1702
|
|
|
1498
1703
|
function selectUsageSnapshot(
|