agent-dag 3.22.0 → 3.22.3
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/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-3FWd7g_W.css +0 -1
- package/dist/web/assets/index-BOwtoP02.js +0 -266
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -392
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/src/server/codex-dir.mjs
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
// Where OpenAI Codex lives on this machine: its home directory, its rollout
|
|
2
|
-
// tree, and the walk over that tree — the Codex-side mirror of claude-dir.mjs.
|
|
3
|
-
//
|
|
4
|
-
// CODEX_HOME relocates ~/.codex wholesale, exactly as CLAUDE_CONFIG_DIR does on
|
|
5
|
-
// the Claude side, and five modules used to answer the question for themselves
|
|
6
|
-
// in three different spellings (#375):
|
|
7
|
-
//
|
|
8
|
-
// index.mjs CODEX_HOME ? resolve(CODEX_HOME) : join(homedir(), ".codex")
|
|
9
|
-
// installer.mjs the same
|
|
10
|
-
// codex-usage.mjs CODEX_HOME ? CODEX_HOME : join(homedir(), ".codex")
|
|
11
|
-
// codex-auth.mjs CODEX_HOME ?? join(homedir(), ".codex")
|
|
12
|
-
// codex-quota.mjs the same
|
|
13
|
-
//
|
|
14
|
-
// The three spellings disagree on two inputs that a shell profile produces by
|
|
15
|
-
// accident rather than by intent, and both disagreements are silent:
|
|
16
|
-
//
|
|
17
|
-
// CODEX_HOME="" — what `export CODEX_HOME=$SOME_UNSET_VAR` leaves behind.
|
|
18
|
-
// `??` only falls back on null and undefined, so the two modules spelled
|
|
19
|
-
// that way kept the empty string and then joined onto it: join("",
|
|
20
|
-
// "auth.json") is "auth.json", a CWD-RELATIVE path. codex-auth.mjs is the
|
|
21
|
-
// module that writes the rotated OpenAI refresh token back to disk, and
|
|
22
|
-
// OpenAI rotates that token single-use — so writing it to whatever
|
|
23
|
-
// directory the deck happened to be started from does not merely lose a
|
|
24
|
-
// read, it burns the credential and costs the user a `codex login`.
|
|
25
|
-
//
|
|
26
|
-
// CODEX_HOME=./relative — kept verbatim by three of the five, so the tree
|
|
27
|
-
// they read was resolved against the CWD at the moment of each readdir()
|
|
28
|
-
// rather than once at startup. Two modules therefore read a different
|
|
29
|
-
// directory than the other three whenever the deck was started from
|
|
30
|
-
// anywhere but the parent of that relative path.
|
|
31
|
-
//
|
|
32
|
-
// This module is the single answer to all of it, and every reader now imports
|
|
33
|
-
// it. The rule is the `resolve()` + truthiness form index.mjs and installer.mjs
|
|
34
|
-
// already used, because it is the one the installer writes against and the one
|
|
35
|
-
// that treats an empty value as "not set" — see codexHome() for why that is the
|
|
36
|
-
// right way round rather than merely the majority.
|
|
37
|
-
import { readdir } from "node:fs/promises";
|
|
38
|
-
import { homedir } from "node:os";
|
|
39
|
-
import { join, posix as posixPath, win32 as winPath } from "node:path";
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Absolute path of the Codex home directory: $CODEX_HOME or ~/.codex.
|
|
43
|
-
*
|
|
44
|
-
* WHY AN EMPTY VALUE FALLS BACK. An empty environment variable is not a path.
|
|
45
|
-
* It is what a shell leaves behind when the variable it was assigned from does
|
|
46
|
-
* not exist, and treating it as "the current directory" turns a typo in a
|
|
47
|
-
* profile into a deck that reads and WRITES Codex state in whatever directory
|
|
48
|
-
* it was launched from. Falling back to ~/.codex is the only reading that can
|
|
49
|
-
* be right by accident; there is no user who means "put my Codex credentials in
|
|
50
|
-
* $PWD" and spells it by leaving the variable empty.
|
|
51
|
-
*
|
|
52
|
-
* WHY IT IS TRIMMED. Same argument one step further: `CODEX_HOME=" "` is the
|
|
53
|
-
* same accident with a stray space in the profile line, and claudeConfigDir()
|
|
54
|
-
* has trimmed for exactly this reason since it was written. Trimming only ever
|
|
55
|
-
* removes leading and trailing whitespace, so a real path that CONTAINS spaces
|
|
56
|
-
* — `/Users/me/Library/Application Support/codex` — is untouched, which is the
|
|
57
|
-
* case worth protecting on macOS and Windows both.
|
|
58
|
-
*
|
|
59
|
-
* WHY resolve() AND NOT realpath(). resolve() makes a relative value absolute
|
|
60
|
-
* once, here, instead of leaving every later readdir() to resolve it against
|
|
61
|
-
* whatever the CWD is by then. It deliberately does NOT follow symlinks:
|
|
62
|
-
* ~/.codex is often a link into a dotfiles repo or an encrypted volume, and
|
|
63
|
-
* canonicalising it would (a) require the directory to already exist, which it
|
|
64
|
-
* does not before the first `codex login`, and (b) make the deck rename over
|
|
65
|
-
* the link's target instead of through the link — the very thing codex-auth.mjs
|
|
66
|
-
* resolves symlinks at WRITE time to avoid. A symlinked CODEX_HOME therefore
|
|
67
|
-
* stays spelled the way the user spelled it, on purpose.
|
|
68
|
-
*
|
|
69
|
-
* The environment, home directory and platform are parameters purely so this
|
|
70
|
-
* rule can be checked for a machine the author is not sitting at: the Windows
|
|
71
|
-
* answer — drive letters, backslashes, a trailing `\` — is only ever verifiable
|
|
72
|
-
* from a POSIX box if the path flavour follows the argument rather than the
|
|
73
|
-
* host, the same trick exec.mjs and claudeCliCandidates() already use. Every
|
|
74
|
-
* caller in the deck passes nothing and gets the real machine's answer.
|
|
75
|
-
*/
|
|
76
|
-
export function codexHome(env = process.env, home = homedir(), platform = process.platform) {
|
|
77
|
-
// The path flavour follows the PLATFORM ARGUMENT, not the host, so that a
|
|
78
|
-
// Windows value is normalised by Windows rules even when the check runs on a
|
|
79
|
-
// Mac. On a real machine `platform` is `process.platform`, which makes this
|
|
80
|
-
// the same `join`/`resolve` the module would have imported anyway.
|
|
81
|
-
const path = platform === "win32" ? winPath : posixPath;
|
|
82
|
-
const override = env.CODEX_HOME?.trim();
|
|
83
|
-
return override ? path.resolve(override) : path.join(home, ".codex");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Absolute path of the rollout tree: $CODEX_HOME/sessions.
|
|
88
|
-
*
|
|
89
|
-
* Codex writes one append-only JSONL file per session under
|
|
90
|
-
* sessions/YYYY/MM/DD/, and this directory is the root of every walk below. It
|
|
91
|
-
* lives here rather than being spelled `join(CODEX_HOME, "sessions")` at each
|
|
92
|
-
* of the three walkers, because that spelling was already duplicated verbatim
|
|
93
|
-
* in index.mjs and codex-usage.mjs and had no owner to drift away from.
|
|
94
|
-
*/
|
|
95
|
-
export function codexSessionsDir(env = process.env, home = homedir(), platform = process.platform) {
|
|
96
|
-
const path = platform === "win32" ? winPath : posixPath;
|
|
97
|
-
return path.join(codexHome(env, home, platform), "sessions");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* The answers for THIS process, resolved once at load.
|
|
102
|
-
*
|
|
103
|
-
* Every consumer imports these constants rather than calling the functions,
|
|
104
|
-
* which is what makes it impossible for two modules to disagree: there is now a
|
|
105
|
-
* single evaluation in the whole process instead of one per importing module.
|
|
106
|
-
* The functions above stay exported because the constants cannot be re-derived
|
|
107
|
-
* for a hypothetical environment, and the rule they encode is the thing worth
|
|
108
|
-
* pinning against every shape a CODEX_HOME can take.
|
|
109
|
-
*/
|
|
110
|
-
export const CODEX_HOME = codexHome();
|
|
111
|
-
export const CODEX_SESSIONS_DIR = codexSessionsDir();
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Returned from a walkRolloutDays visitor to end the walk immediately.
|
|
115
|
-
*
|
|
116
|
-
* A symbol rather than `true` because two of the three callers build their
|
|
117
|
-
* result by pushing into an array inside the visitor, and `Array.prototype.push`
|
|
118
|
-
* returns a number — a truthy-return protocol would have made "I collected two
|
|
119
|
-
* files" indistinguishable from "stop now".
|
|
120
|
-
*/
|
|
121
|
-
export const STOP = Symbol("stop-rollout-walk");
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Walk $CODEX_HOME/sessions/YYYY/MM/DD newest-first, handing each day directory
|
|
125
|
-
* and the names inside it to `onDay(dir, files)`.
|
|
126
|
-
*
|
|
127
|
-
* Three callers walked this tree with the same four nested readdir-and-continue
|
|
128
|
-
* blocks and differed only in the last few lines: find the file carrying a given
|
|
129
|
-
* session id (index.mjs), collect everything in the newest two day directories
|
|
130
|
-
* (index.mjs again, for the watcher), and collect everything whose filename
|
|
131
|
-
* timestamp falls inside a rolling window (codex-usage.mjs). The walk is the
|
|
132
|
-
* part that has to agree — a deck that tails one set of files and reports usage
|
|
133
|
-
* from another is reporting on a session it is not showing.
|
|
134
|
-
*
|
|
135
|
-
* EVERY LEVEL SWALLOWS ITS OWN ERROR, which is deliberate rather than lazy. The
|
|
136
|
-
* tree is written by another process while this one reads it: a day directory
|
|
137
|
-
* can be created between the listing of its month and the listing of itself, a
|
|
138
|
-
* year directory can be a broken symlink on a restored backup, and none of that
|
|
139
|
-
* is a reason to stop reading the other 364 days. A missing sessions/ directory
|
|
140
|
-
* is not an error at all — it is simply a machine where Codex has not run yet.
|
|
141
|
-
*
|
|
142
|
-
* `onYear` exists for the one caller that can rule out a whole year without
|
|
143
|
-
* opening it. Years arrive newest-first, so returning STOP from it ends the
|
|
144
|
-
* walk rather than skipping a year, which is what the caller wants: once the
|
|
145
|
-
* years are older than the window, so is everything after them.
|
|
146
|
-
*/
|
|
147
|
-
export async function walkRolloutDays(onDay, { sessionsDir = CODEX_SESSIONS_DIR, onYear = null } = {}) {
|
|
148
|
-
let years;
|
|
149
|
-
// Only four-digit names are Codex's own. The filter also keeps a stray
|
|
150
|
-
// `.DS_Store` or a `latest` symlink from costing a readdir that would fail.
|
|
151
|
-
try { years = (await readdir(sessionsDir)).filter(d => /^\d{4}$/.test(d)).sort().reverse(); }
|
|
152
|
-
catch { return; }
|
|
153
|
-
for (const year of years) {
|
|
154
|
-
if (onYear && (await onYear(year)) === STOP) return;
|
|
155
|
-
let months;
|
|
156
|
-
try { months = (await readdir(join(sessionsDir, year))).sort().reverse(); }
|
|
157
|
-
catch { continue; }
|
|
158
|
-
for (const month of months) {
|
|
159
|
-
let days;
|
|
160
|
-
try { days = (await readdir(join(sessionsDir, year, month))).sort().reverse(); }
|
|
161
|
-
catch { continue; }
|
|
162
|
-
for (const day of days) {
|
|
163
|
-
const dir = join(sessionsDir, year, month, day);
|
|
164
|
-
let files;
|
|
165
|
-
try { files = await readdir(dir); }
|
|
166
|
-
catch { continue; }
|
|
167
|
-
if ((await onDay(dir, files)) === STOP) return;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
@@ -1,449 +0,0 @@
|
|
|
1
|
-
// Fetches Codex/ChatGPT quota from the same endpoint the Codex CLI uses.
|
|
2
|
-
// Auth (including token refresh) lives in codex-auth.mjs.
|
|
3
|
-
//
|
|
4
|
-
// The response is deliberately parsed defensively: OpenAI ships new plan
|
|
5
|
-
// types, new limit families and new numeric encodings without warning, and a
|
|
6
|
-
// menu-bar-style readout is worth more when it degrades to "some lanes" than
|
|
7
|
-
// when one unrecognised field blanks the whole panel. So every section is
|
|
8
|
-
// optional, unknown values pass through verbatim, and a `partial` flag tells
|
|
9
|
-
// the UI when something was dropped instead of silently showing less.
|
|
10
|
-
import { readFile } from "node:fs/promises";
|
|
11
|
-
// One clamp for both quota readers: see the note on cooldownFromHeader.
|
|
12
|
-
import { cooldownFromHeader } from "./quota.mjs";
|
|
13
|
-
import { join } from "node:path";
|
|
14
|
-
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
15
|
-
import { getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
|
|
16
|
-
import { PRODUCT } from "./brand.mjs";
|
|
17
|
-
import { resetLabel } from "./reset-label.mjs";
|
|
18
|
-
|
|
19
|
-
// Resolved by codex-dir.mjs rather than here. This file used to spell it
|
|
20
|
-
// `process.env.CODEX_HOME ?? join(homedir(), ".codex")`, which keeps an empty
|
|
21
|
-
// CODEX_HOME instead of falling back — and then read a CWD-relative
|
|
22
|
-
// "config.toml" for the base URL every credential below is sent to (#375).
|
|
23
|
-
const CONFIG_PATH = join(CODEX_HOME, "config.toml");
|
|
24
|
-
const DEFAULT_BASE = "https://chatgpt.com/backend-api";
|
|
25
|
-
|
|
26
|
-
let _cache = null;
|
|
27
|
-
let _cacheAt = 0;
|
|
28
|
-
const CACHE_MS = 60_000;
|
|
29
|
-
|
|
30
|
-
// The last base URL we refused, so the refusal is said once rather than once a
|
|
31
|
-
// minute for as long as the config stays that way.
|
|
32
|
-
let _warnedBase = null;
|
|
33
|
-
|
|
34
|
-
// ── base URL ───────────────────────────────────────────────────────────────
|
|
35
|
-
// `chatgpt_base_url` in config.toml can point at a proxy, and the path style
|
|
36
|
-
// follows from its shape exactly as in the CLI: a /backend-api base speaks
|
|
37
|
-
// /wham/*, anything else speaks /api/codex/*.
|
|
38
|
-
//
|
|
39
|
-
// Whatever it says, the request below carries `Authorization: Bearer
|
|
40
|
-
// <accessToken>` — a live ChatGPT session — so the value is not just a routing
|
|
41
|
-
// preference, it is the answer to "who gets the credential". It was taken
|
|
42
|
-
// verbatim: a line regex, quotes stripped, straight into fetch(), which meant
|
|
43
|
-
// anything able to write that TOML (or to set $CODEX_HOME and point it at its
|
|
44
|
-
// own) could redirect the token to a host of its choosing, over plaintext http
|
|
45
|
-
// if it liked. isCredentialHost is where the two rules live.
|
|
46
|
-
async function readBaseUrl() {
|
|
47
|
-
let raw = null;
|
|
48
|
-
try {
|
|
49
|
-
const text = await readFile(CONFIG_PATH, "utf8");
|
|
50
|
-
for (const line of text.split("\n")) {
|
|
51
|
-
const m = line.replace(/#.*$/, "").match(/^\s*chatgpt_base_url\s*=\s*(.+?)\s*$/);
|
|
52
|
-
if (m) { raw = m[1].replace(/^["']|["']$/g, "").trim(); break; }
|
|
53
|
-
}
|
|
54
|
-
} catch { /* no config.toml — use the default */ }
|
|
55
|
-
|
|
56
|
-
let base = (raw || DEFAULT_BASE).replace(/\/+$/, "");
|
|
57
|
-
if (/^https:\/\/(chatgpt\.com|chat\.openai\.com)/.test(base) && !base.includes("/backend-api")) {
|
|
58
|
-
base += "/backend-api";
|
|
59
|
-
}
|
|
60
|
-
return base;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function usagePath(base) { return base.includes("/backend-api") ? "/wham/usage" : "/api/codex/usage"; }
|
|
64
|
-
function resetCreditsPath(base) { return base.includes("/backend-api") ? "/wham/rate-limit-reset-credits" : "/api/codex/rate-limit-reset-credits"; }
|
|
65
|
-
|
|
66
|
-
// ── lenient field readers ──────────────────────────────────────────────────
|
|
67
|
-
// Team and enterprise payloads send numbers as strings ("limit": "1000"), and
|
|
68
|
-
// reset timestamps answer to three different spellings depending on which
|
|
69
|
-
// sub-object you are in.
|
|
70
|
-
function num(v) {
|
|
71
|
-
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
72
|
-
if (typeof v === "string" && v.trim() !== "") {
|
|
73
|
-
const n = Number(v);
|
|
74
|
-
return Number.isFinite(n) ? n : null;
|
|
75
|
-
}
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
function resetAt(o) {
|
|
79
|
-
return num(o?.resets_at) ?? num(o?.resetsAt) ?? num(o?.reset_at) ?? null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** "Jun 18, 4:09pm" — matches the Claude quota formatting so both read alike.
|
|
83
|
-
*
|
|
84
|
-
* It did not, and the sentence above is why #374 called this one out: this
|
|
85
|
-
* copy passed the same options to `toLocaleString` and then stripped the comma
|
|
86
|
-
* and lower-cased the whole string, so it printed "jun 18 4:09pm" where the
|
|
87
|
-
* Claude lane one row up printed "Jun 18, 4:09pm". Swept over 2,794 instants
|
|
88
|
-
* the two disagreed on every one. Both read from reset-label.mjs now, and the
|
|
89
|
-
* claim above is true for the first time. */
|
|
90
|
-
const fmtReset = resetLabel;
|
|
91
|
-
|
|
92
|
-
// ── window classification ──────────────────────────────────────────────────
|
|
93
|
-
// Slot position is NOT the lane. Free plans return a weekly window in the
|
|
94
|
-
// primary slot, and a 30-day lane can arrive in either slot — labelling by
|
|
95
|
-
// slot is how a weekly cap ends up displayed as a 5-hour one. Duration is the
|
|
96
|
-
// only trustworthy signal.
|
|
97
|
-
const HOUR = 3600;
|
|
98
|
-
function laneFor(windowSec) {
|
|
99
|
-
if (windowSec == null || windowSec <= 0) return { key: "unknown", label: "Rate limit", rank: 9 };
|
|
100
|
-
if (windowSec <= 6 * HOUR) return { key: "session", label: `${Math.round(windowSec / HOUR)}-hour window`, rank: 0 };
|
|
101
|
-
if (windowSec <= 8 * 24 * HOUR) return { key: "weekly", label: "7-day window", rank: 1 };
|
|
102
|
-
return { key: "monthly", label: "30-day window", rank: 2 };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** One rate-limit lane, or null when the window carries no usable reading. */
|
|
106
|
-
function toWindow(w, idFallback) {
|
|
107
|
-
const pct = num(w?.used_percent ?? w?.usedPercent);
|
|
108
|
-
if (pct == null) return null;
|
|
109
|
-
const windowSec = num(w?.limit_window_seconds ?? w?.limitWindowSeconds);
|
|
110
|
-
const lane = laneFor(windowSec);
|
|
111
|
-
const reset = resetAt(w);
|
|
112
|
-
return {
|
|
113
|
-
id: idFallback ? `${idFallback}-${lane.key}` : lane.key,
|
|
114
|
-
key: lane.key,
|
|
115
|
-
label: lane.label,
|
|
116
|
-
rank: lane.rank,
|
|
117
|
-
pct, // never clamped — over-quota is real information
|
|
118
|
-
windowSec: windowSec ?? null,
|
|
119
|
-
resetAt: reset,
|
|
120
|
-
reset: fmtReset(reset),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Both slots of a rate_limit object, ordered by lane rather than by slot. */
|
|
125
|
-
function windowsFrom(rl, idPrefix) {
|
|
126
|
-
return [toWindow(rl?.primary_window, idPrefix), toWindow(rl?.secondary_window, idPrefix)]
|
|
127
|
-
.filter(Boolean)
|
|
128
|
-
.sort((a, b) => a.rank - b.rank);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ── spend control / monthly credit limit ───────────────────────────────────
|
|
132
|
-
// Three places can carry it, in this precedence. Whichever answers first wins.
|
|
133
|
-
function creditLimitFrom(data) {
|
|
134
|
-
const src = data?.individual_limit
|
|
135
|
-
?? data?.rate_limit?.individual_limit
|
|
136
|
-
?? data?.spend_control?.individual_limit;
|
|
137
|
-
const limit = num(src?.limit);
|
|
138
|
-
if (!limit || limit <= 0) return null;
|
|
139
|
-
|
|
140
|
-
const remainingPct = num(src?.remaining_percent ?? src?.remainingPercent);
|
|
141
|
-
const used = num(src?.used) ?? (remainingPct != null ? limit * Math.max(0, Math.min(100, 100 - remainingPct)) / 100 : 0);
|
|
142
|
-
const pct = remainingPct != null ? Math.max(0, Math.min(100, 100 - remainingPct)) : (used / limit) * 100;
|
|
143
|
-
const reset = resetAt(src);
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
limit,
|
|
147
|
-
used,
|
|
148
|
-
usedPct: pct,
|
|
149
|
-
remaining: num(src?.remaining) ?? Math.max(0, limit - used),
|
|
150
|
-
source: src?.source ?? null,
|
|
151
|
-
resetAt: reset,
|
|
152
|
-
reset: fmtReset(reset),
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ── plan labels ────────────────────────────────────────────────────────────
|
|
157
|
-
// OpenAI's marketing names, since "pro" alone tells the user nothing about
|
|
158
|
-
// which of the two Pro tiers they are on.
|
|
159
|
-
const PLAN_LABELS = { pro: "Pro 20x", prolite: "Pro 5x", pro_lite: "Pro 5x", "pro-lite": "Pro 5x" };
|
|
160
|
-
function planLabel(plan) {
|
|
161
|
-
if (!plan || typeof plan !== "string") return null;
|
|
162
|
-
const k = plan.toLowerCase().replace(/\s+/g, "_");
|
|
163
|
-
if (PLAN_LABELS[k]) return PLAN_LABELS[k];
|
|
164
|
-
if (k === "k12" || k === "cbp") return k.toUpperCase();
|
|
165
|
-
return k.split(/[_-]/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ── extra limit families (Codex Spark and friends) ────────────────────────
|
|
169
|
-
// `additional_rate_limits` is an ARRAY of {limit_name, metered_feature,
|
|
170
|
-
// rate_limit}, not a map. Decoded element-wise so one malformed entry costs
|
|
171
|
-
// only itself.
|
|
172
|
-
function extraLimits(data) {
|
|
173
|
-
const arr = data?.additional_rate_limits;
|
|
174
|
-
if (!Array.isArray(arr)) return { extras: [], damaged: arr != null };
|
|
175
|
-
|
|
176
|
-
const extras = [];
|
|
177
|
-
let damaged = false;
|
|
178
|
-
for (const entry of arr) {
|
|
179
|
-
try {
|
|
180
|
-
const slug = String(entry?.metered_feature ?? entry?.limit_name ?? "extra")
|
|
181
|
-
.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
182
|
-
const title = entry?.limit_name ?? entry?.metered_feature ?? "Codex extra limit";
|
|
183
|
-
for (const w of windowsFrom(entry?.rate_limit, slug)) {
|
|
184
|
-
extras.push({ ...w, label: `${title} · ${w.label}`, family: slug });
|
|
185
|
-
}
|
|
186
|
-
} catch { damaged = true; }
|
|
187
|
-
}
|
|
188
|
-
return { extras, damaged };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ── fetch ──────────────────────────────────────────────────────────────────
|
|
192
|
-
function authHeaders(auth, { accountIdHeader = "ChatGPT-Account-Id", extra = {} } = {}) {
|
|
193
|
-
const h = {
|
|
194
|
-
"Authorization": `Bearer ${auth.accessToken}`,
|
|
195
|
-
"Accept": "application/json",
|
|
196
|
-
"User-Agent": "codex-cli",
|
|
197
|
-
...extra,
|
|
198
|
-
};
|
|
199
|
-
if (auth.accountId) h[accountIdHeader] = auth.accountId;
|
|
200
|
-
if (auth.isFedramp) h["X-OpenAI-Fedramp"] = "true";
|
|
201
|
-
return h;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Reset credits ("one free rate limit reset" grants). Best-effort and short-
|
|
206
|
-
* timeout: it is a nice-to-have next to the gauges, never a reason to fail
|
|
207
|
-
* the quota read. Read-only — we never redeem.
|
|
208
|
-
*/
|
|
209
|
-
async function fetchResetCredits(base, auth) {
|
|
210
|
-
try {
|
|
211
|
-
const res = await fetch(base + resetCreditsPath(base), {
|
|
212
|
-
// This endpoint alone wants the uppercase-ID spelling and the beta
|
|
213
|
-
// headers; sending the wham/usage set here returns nothing useful.
|
|
214
|
-
headers: authHeaders(auth, {
|
|
215
|
-
accountIdHeader: "ChatGPT-Account-ID",
|
|
216
|
-
extra: { "OpenAI-Beta": "codex-1", "originator": "Codex Desktop" },
|
|
217
|
-
}),
|
|
218
|
-
signal: AbortSignal.timeout(5_000),
|
|
219
|
-
});
|
|
220
|
-
if (!res.ok) return null;
|
|
221
|
-
const body = await res.json();
|
|
222
|
-
const count = num(body?.available_count);
|
|
223
|
-
if (count == null || count < 0) return null;
|
|
224
|
-
const next = (body?.credits ?? [])
|
|
225
|
-
.filter(c => c?.status === "available" && c?.expires_at)
|
|
226
|
-
.map(c => Date.parse(c.expires_at))
|
|
227
|
-
.filter(t => !isNaN(t))
|
|
228
|
-
.sort((a, b) => a - b)[0] ?? null;
|
|
229
|
-
return { availableCount: count, nextExpiryAt: next };
|
|
230
|
-
} catch { return null; }
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function requestUsage(base, auth) {
|
|
234
|
-
return fetch(base + usagePath(base), {
|
|
235
|
-
headers: authHeaders(auth),
|
|
236
|
-
// Quota is per-account state; a cached response is how one account's
|
|
237
|
-
// gauges end up shown for another.
|
|
238
|
-
cache: "no-store",
|
|
239
|
-
signal: AbortSignal.timeout(12_000),
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// One outstanding fetch at a time. Several browser tabs mounting at once
|
|
244
|
-
// otherwise each force their own round trip — and each one is another chance
|
|
245
|
-
// to race over the single-use refresh token.
|
|
246
|
-
let _inflight = null;
|
|
247
|
-
|
|
248
|
-
// ── what a forced read may cost ────────────────────────────────────────────
|
|
249
|
-
// The floor between two reads WE pay for, and it is the same number and the
|
|
250
|
-
// same rule quota.mjs gives the Claude half — see FORCE_POLL_MS and maySelfPoll
|
|
251
|
-
// there. The two routes are four lines apart in the router and had no business
|
|
252
|
-
// disagreeing about what `?refresh=1` costs.
|
|
253
|
-
//
|
|
254
|
-
// `force` used to mean "skip the cache", and the cache was the ONLY thing
|
|
255
|
-
// between a caller and chatgpt.com. `_inflight` deduplicates callers that
|
|
256
|
-
// overlap and nothing else, so a caller that waits for one fetch to settle and
|
|
257
|
-
// then asks again got a fresh round trip every time — two authenticated HTTPS
|
|
258
|
-
// GETs carrying the user's live ChatGPT session, as fast as the round trip
|
|
259
|
-
// allows, from any page the user happens to have open (#580). Reads on this
|
|
260
|
-
// server are deliberately open (isTrustedRead), so "any page" is the real
|
|
261
|
-
// threat model rather than a hypothetical one.
|
|
262
|
-
//
|
|
263
|
-
// The sharper half is the credential rather than the traffic. On a 401
|
|
264
|
-
// doFetchCodexQuota spends the SINGLE-USE refresh token via forceCodexRefresh,
|
|
265
|
-
// and `staleAccessToken` only stops that happening twice for the same rejected
|
|
266
|
-
// token — every turn re-reads auth.json and sees the token the previous turn
|
|
267
|
-
// rotated to, so a backend that keeps answering 401 rotated a fresh credential
|
|
268
|
-
// once per request, racing the Codex CLI for each one. codex-auth.mjs's own
|
|
269
|
-
// EXPIRY_SKEW_MS comment says what losing that race costs the user: a
|
|
270
|
-
// `refresh_token_reused` that reads as "your login is broken", recoverable only
|
|
271
|
-
// with `codex login`.
|
|
272
|
-
const FORCE_POLL_MS = 60_000;
|
|
273
|
-
|
|
274
|
-
// Set from a 429 or a rejected refresh: a backend that is refusing us must not
|
|
275
|
-
// be asked once per request, whoever is asking. Same shape as quota.mjs's
|
|
276
|
-
// _rateLimitedUntil, which is likewise never beaten by force.
|
|
277
|
-
let _rateLimitedUntil = 0;
|
|
278
|
-
const COOLDOWN_MS = 5 * 60_000;
|
|
279
|
-
|
|
280
|
-
// Stamped when a fetch STARTS rather than when it lands: what the floor is
|
|
281
|
-
// rationing is the round trip, and one that is still in flight has already been
|
|
282
|
-
// paid for.
|
|
283
|
-
let _lastFetchAt = 0;
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Whether we may spend a request of the user's ChatGPT session right now.
|
|
287
|
-
*
|
|
288
|
-
* Exported for tests, for the same reason quota.mjs exports maySelfPoll: this
|
|
289
|
-
* is the rule, it is pure, and it is worth pinning down away from the fetch it
|
|
290
|
-
* guards.
|
|
291
|
-
*/
|
|
292
|
-
export function mayFetchQuota({ now, lastFetchAt, rateLimitedUntil }) {
|
|
293
|
-
if (now < rateLimitedUntil) return false;
|
|
294
|
-
return now - lastFetchAt >= FORCE_POLL_MS;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* The answer to a read the floor refused.
|
|
299
|
-
*
|
|
300
|
-
* A reading, not an error — a user who clicks ↻ twice in a second must get the
|
|
301
|
-
* numbers they already have rather than a red hint, which is exactly what
|
|
302
|
-
* quota.mjs does with `{ ...held, stale: true }`. The timestamp stays the one
|
|
303
|
-
* the data was fetched at, so the panel's age label keeps telling the truth
|
|
304
|
-
* instead of vouching for a reading it did not take.
|
|
305
|
-
*/
|
|
306
|
-
function heldReading(now) {
|
|
307
|
-
if (_cache) return { ..._cache, stale: true };
|
|
308
|
-
// Only reachable before the first fetch has ever landed — `finish` caches
|
|
309
|
-
// every outcome, failures included — and spelled the way the Claude side
|
|
310
|
-
// spells the same two states.
|
|
311
|
-
return { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
export function fetchCodexQuota({ force = false } = {}) {
|
|
315
|
-
const now = Date.now();
|
|
316
|
-
if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
|
|
317
|
-
// Joining a run already in flight costs nothing, so it is offered before the
|
|
318
|
-
// floor: what refresh asks for is a reading newer than the cache, and a fetch
|
|
319
|
-
// that has not landed yet is one.
|
|
320
|
-
if (_inflight) return _inflight;
|
|
321
|
-
if (!mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: _rateLimitedUntil })) {
|
|
322
|
-
return Promise.resolve(heldReading(now));
|
|
323
|
-
}
|
|
324
|
-
_lastFetchAt = now;
|
|
325
|
-
_inflight = doFetchCodexQuota().finally(() => { _inflight = null; });
|
|
326
|
-
return _inflight;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
async function doFetchCodexQuota() {
|
|
330
|
-
const started = Date.now();
|
|
331
|
-
// Stamped at completion, not at entry: the two calls below can take up to
|
|
332
|
-
// 17s between them, and a cache entry that is already stale on arrival
|
|
333
|
-
// shortens the effective TTL for no reason.
|
|
334
|
-
const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
|
|
335
|
-
const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
|
|
336
|
-
// A refusal we were told about, rather than one we inferred: back off further
|
|
337
|
-
// than the ordinary floor before asking again. `retry-after` is honoured when
|
|
338
|
-
// the backend sends one, because it knows better than the constant does.
|
|
339
|
-
const cooldown = (res) => {
|
|
340
|
-
// Clamped, for the reason quota.mjs states at cooldownFromHeader: a `0`
|
|
341
|
-
// defeats the cooldown a 429 exists to impose, and a day freezes this
|
|
342
|
-
// poller for the life of the process.
|
|
343
|
-
_rateLimitedUntil = Date.now() + cooldownFromHeader(res?.headers?.get?.("retry-after"), COOLDOWN_MS);
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
let auth, base, res;
|
|
347
|
-
try {
|
|
348
|
-
auth = await getCodexAuth();
|
|
349
|
-
if (!auth.ok) return fail(auth.reason);
|
|
350
|
-
|
|
351
|
-
// An API key in auth.json is a platform credential, not a ChatGPT session —
|
|
352
|
-
// sending it here only produces a confusing 401.
|
|
353
|
-
if (auth.apiKeyMode) return fail("api_key_mode");
|
|
354
|
-
|
|
355
|
-
base = await readBaseUrl();
|
|
356
|
-
// Refused before the first byte goes out, and reported rather than
|
|
357
|
-
// swallowed: a panel that says "Codex quota is off because the configured
|
|
358
|
-
// base URL is not an OpenAI one" is a bug report the user can act on, where
|
|
359
|
-
// a silently empty gauge is a mystery. Logged once per distinct value so a
|
|
360
|
-
// 60-second poll does not turn a misconfiguration into a log flood.
|
|
361
|
-
if (!isCredentialHost(base)) {
|
|
362
|
-
if (_warnedBase !== base) {
|
|
363
|
-
_warnedBase = base;
|
|
364
|
-
console.error(
|
|
365
|
-
`${PRODUCT} codex-quota: not sending the ChatGPT token to ${base} — ` +
|
|
366
|
-
`chatgpt_base_url must be an https OpenAI host`,
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
return fail("untrusted_base_url");
|
|
370
|
-
}
|
|
371
|
-
res = await requestUsage(base, auth);
|
|
372
|
-
|
|
373
|
-
// The JWT's own `exp` is not the last word: OpenAI revokes server-side, so
|
|
374
|
-
// a token that looks valid locally can still come back expired. One forced
|
|
375
|
-
// refresh + retry turns that from "bar goes dark" into a hiccup.
|
|
376
|
-
//
|
|
377
|
-
// 401 only. A 403 from chatgpt.com is usually a bot check or a blocked
|
|
378
|
-
// egress IP rather than a bad token, and rotating a single-use credential
|
|
379
|
-
// once a minute against a network-layer block is how a working login gets
|
|
380
|
-
// destroyed.
|
|
381
|
-
if (res.status === 401) {
|
|
382
|
-
const refreshed = await forceCodexRefresh(auth.accessToken);
|
|
383
|
-
if (!refreshed.ok) {
|
|
384
|
-
// The credential is gone and only `codex login` brings it back, so
|
|
385
|
-
// rotating another single-use token at the next request would burn the
|
|
386
|
-
// one the CLI is still holding. Wait.
|
|
387
|
-
if (refreshed.reason === "refresh_rejected") cooldown(null);
|
|
388
|
-
return fail(refreshed.reason);
|
|
389
|
-
}
|
|
390
|
-
auth = refreshed;
|
|
391
|
-
res = await requestUsage(base, auth);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (!res.ok) {
|
|
395
|
-
// A second 401 means the token we just rotated to was rejected as well —
|
|
396
|
-
// the case that turned into one rotation per request. 429 is the backend
|
|
397
|
-
// saying the same thing in the ordinary way.
|
|
398
|
-
if (res.status === 401 || res.status === 429) cooldown(res);
|
|
399
|
-
return fail(res.status === 401 ? "refresh_rejected" : `http_${res.status}`);
|
|
400
|
-
}
|
|
401
|
-
} catch (err) {
|
|
402
|
-
console.error(`${PRODUCT} codex-quota: fetch failed:`, err?.message ?? err);
|
|
403
|
-
return fail("fetch_error");
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
let data;
|
|
407
|
-
try { data = await res.json(); }
|
|
408
|
-
catch { return fail("decode_error"); }
|
|
409
|
-
|
|
410
|
-
const rl = data?.rate_limit;
|
|
411
|
-
const windows = windowsFrom(rl);
|
|
412
|
-
const { extras, damaged } = extraLimits(data);
|
|
413
|
-
const creditsRaw = data?.credits;
|
|
414
|
-
const balance = num(creditsRaw?.balance);
|
|
415
|
-
|
|
416
|
-
const result = {
|
|
417
|
-
ok: true,
|
|
418
|
-
limitReached: rl?.limit_reached ?? false,
|
|
419
|
-
allowed: rl?.allowed ?? true,
|
|
420
|
-
|
|
421
|
-
// Lanes, already ordered session → weekly → monthly and labelled by the
|
|
422
|
-
// window duration the API actually reported.
|
|
423
|
-
windows,
|
|
424
|
-
extraWindows: extras,
|
|
425
|
-
|
|
426
|
-
plan: data?.plan_type ?? auth.planType ?? null,
|
|
427
|
-
planLabel: planLabel(data?.plan_type ?? auth.planType),
|
|
428
|
-
email: data?.email ?? auth.email ?? null,
|
|
429
|
-
|
|
430
|
-
creditsBalance: balance != null && balance > 0 ? String(creditsRaw.balance) : null,
|
|
431
|
-
creditsUnlimited: creditsRaw?.unlimited === true,
|
|
432
|
-
overageReached: creditsRaw?.overage_limit_reached === true,
|
|
433
|
-
creditLimit: creditLimitFrom(data),
|
|
434
|
-
|
|
435
|
-
spendControlReached: data?.spend_control?.reached === true,
|
|
436
|
-
reachedType: data?.rate_limit_reached_type?.type ?? data?.rate_limit_reached_type ?? null,
|
|
437
|
-
promo: data?.promo?.message ?? null,
|
|
438
|
-
|
|
439
|
-
// True when something in the payload did not decode — the UI says "partial"
|
|
440
|
-
// rather than pretending the missing lanes do not exist.
|
|
441
|
-
partial: damaged || windows.length === 0,
|
|
442
|
-
refreshed: auth.refreshed === true,
|
|
443
|
-
fetchedAt: started,
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
result.resetCredits = await fetchResetCredits(base, auth);
|
|
447
|
-
|
|
448
|
-
return finish(result);
|
|
449
|
-
}
|