agent-dag 1.23.2 → 1.24.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/dist/web/assets/index-Dw00LH5N.js +62 -0
- package/dist/web/assets/{index-BRoNoC-Z.css → index-m6XPuIrx.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/server/codex-auth.mjs +278 -0
- package/src/server/codex-quota.mjs +289 -79
- package/src/server/index.mjs +15 -5
- package/dist/web/assets/index-9KOSdAj7.js +0 -62
|
@@ -1,111 +1,321 @@
|
|
|
1
|
-
// Fetches Codex/ChatGPT quota
|
|
2
|
-
// Auth
|
|
3
|
-
//
|
|
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.
|
|
4
10
|
import { readFile } from "node:fs/promises";
|
|
5
11
|
import { join } from "node:path";
|
|
6
12
|
import { homedir } from "node:os";
|
|
13
|
+
import { getCodexAuth, forceCodexRefresh } from "./codex-auth.mjs";
|
|
7
14
|
|
|
8
|
-
const CODEX_HOME
|
|
9
|
-
const
|
|
10
|
-
const
|
|
15
|
+
const CODEX_HOME = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
16
|
+
const CONFIG_PATH = join(CODEX_HOME, "config.toml");
|
|
17
|
+
const DEFAULT_BASE = "https://chatgpt.com/backend-api";
|
|
11
18
|
|
|
12
|
-
let _cache
|
|
13
|
-
let _cacheAt
|
|
19
|
+
let _cache = null;
|
|
20
|
+
let _cacheAt = 0;
|
|
14
21
|
const CACHE_MS = 60_000;
|
|
15
22
|
|
|
16
|
-
|
|
23
|
+
// ── base URL ───────────────────────────────────────────────────────────────
|
|
24
|
+
// `chatgpt_base_url` in config.toml can point at a proxy, and the path style
|
|
25
|
+
// follows from its shape exactly as in the CLI: a /backend-api base speaks
|
|
26
|
+
// /wham/*, anything else speaks /api/codex/*.
|
|
27
|
+
async function readBaseUrl() {
|
|
28
|
+
let raw = null;
|
|
17
29
|
try {
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
const text = await readFile(CONFIG_PATH, "utf8");
|
|
31
|
+
for (const line of text.split("\n")) {
|
|
32
|
+
const m = line.replace(/#.*$/, "").match(/^\s*chatgpt_base_url\s*=\s*(.+?)\s*$/);
|
|
33
|
+
if (m) { raw = m[1].replace(/^["']|["']$/g, "").trim(); break; }
|
|
34
|
+
}
|
|
35
|
+
} catch { /* no config.toml — use the default */ }
|
|
36
|
+
|
|
37
|
+
let base = (raw || DEFAULT_BASE).replace(/\/+$/, "");
|
|
38
|
+
if (/^https:\/\/(chatgpt\.com|chat\.openai\.com)/.test(base) && !base.includes("/backend-api")) {
|
|
39
|
+
base += "/backend-api";
|
|
40
|
+
}
|
|
41
|
+
return base;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function usagePath(base) { return base.includes("/backend-api") ? "/wham/usage" : "/api/codex/usage"; }
|
|
45
|
+
function resetCreditsPath(base) { return base.includes("/backend-api") ? "/wham/rate-limit-reset-credits" : "/api/codex/rate-limit-reset-credits"; }
|
|
46
|
+
|
|
47
|
+
// ── lenient field readers ──────────────────────────────────────────────────
|
|
48
|
+
// Team and enterprise payloads send numbers as strings ("limit": "1000"), and
|
|
49
|
+
// reset timestamps answer to three different spellings depending on which
|
|
50
|
+
// sub-object you are in.
|
|
51
|
+
function num(v) {
|
|
52
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
53
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
54
|
+
const n = Number(v);
|
|
55
|
+
return Number.isFinite(n) ? n : null;
|
|
23
56
|
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function resetAt(o) {
|
|
60
|
+
return num(o?.resets_at) ?? num(o?.resetsAt) ?? num(o?.reset_at) ?? null;
|
|
24
61
|
}
|
|
25
62
|
|
|
26
|
-
|
|
63
|
+
/** "Jun 18, 4:09pm" — matches the Claude quota formatting so both read alike. */
|
|
27
64
|
function fmtReset(unixSec) {
|
|
28
65
|
if (!unixSec) return null;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
month: "short",
|
|
32
|
-
day: "numeric",
|
|
33
|
-
hour: "numeric",
|
|
34
|
-
minute: "2-digit",
|
|
35
|
-
hour12: true,
|
|
66
|
+
return new Date(unixSec * 1000).toLocaleString("en-US", {
|
|
67
|
+
month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true,
|
|
36
68
|
}).replace(",", "").toLowerCase().replace(/\s+am/, "am").replace(/\s+pm/, "pm");
|
|
37
69
|
}
|
|
38
70
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
71
|
+
// ── window classification ──────────────────────────────────────────────────
|
|
72
|
+
// Slot position is NOT the lane. Free plans return a weekly window in the
|
|
73
|
+
// primary slot, and a 30-day lane can arrive in either slot — labelling by
|
|
74
|
+
// slot is how a weekly cap ends up displayed as a 5-hour one. Duration is the
|
|
75
|
+
// only trustworthy signal.
|
|
76
|
+
const HOUR = 3600;
|
|
77
|
+
function laneFor(windowSec) {
|
|
78
|
+
if (windowSec == null || windowSec <= 0) return { key: "unknown", label: "Rate limit", rank: 9 };
|
|
79
|
+
if (windowSec <= 6 * HOUR) return { key: "session", label: `${Math.round(windowSec / HOUR)}-hour window`, rank: 0 };
|
|
80
|
+
if (windowSec <= 8 * 24 * HOUR) return { key: "weekly", label: "7-day window", rank: 1 };
|
|
81
|
+
return { key: "monthly", label: "30-day window", rank: 2 };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One rate-limit lane, or null when the window carries no usable reading. */
|
|
85
|
+
function toWindow(w, idFallback) {
|
|
86
|
+
const pct = num(w?.used_percent ?? w?.usedPercent);
|
|
87
|
+
if (pct == null) return null;
|
|
88
|
+
const windowSec = num(w?.limit_window_seconds ?? w?.limitWindowSeconds);
|
|
89
|
+
const lane = laneFor(windowSec);
|
|
90
|
+
const reset = resetAt(w);
|
|
91
|
+
return {
|
|
92
|
+
id: idFallback ? `${idFallback}-${lane.key}` : lane.key,
|
|
93
|
+
key: lane.key,
|
|
94
|
+
label: lane.label,
|
|
95
|
+
rank: lane.rank,
|
|
96
|
+
pct, // never clamped — over-quota is real information
|
|
97
|
+
windowSec: windowSec ?? null,
|
|
98
|
+
resetAt: reset,
|
|
99
|
+
reset: fmtReset(reset),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Both slots of a rate_limit object, ordered by lane rather than by slot. */
|
|
104
|
+
function windowsFrom(rl, idPrefix) {
|
|
105
|
+
return [toWindow(rl?.primary_window, idPrefix), toWindow(rl?.secondary_window, idPrefix)]
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.sort((a, b) => a.rank - b.rank);
|
|
108
|
+
}
|
|
42
109
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
110
|
+
// ── spend control / monthly credit limit ───────────────────────────────────
|
|
111
|
+
// Three places can carry it, in this precedence. Whichever answers first wins.
|
|
112
|
+
function creditLimitFrom(data) {
|
|
113
|
+
const src = data?.individual_limit
|
|
114
|
+
?? data?.rate_limit?.individual_limit
|
|
115
|
+
?? data?.spend_control?.individual_limit;
|
|
116
|
+
const limit = num(src?.limit);
|
|
117
|
+
if (!limit || limit <= 0) return null;
|
|
118
|
+
|
|
119
|
+
const remainingPct = num(src?.remaining_percent ?? src?.remainingPercent);
|
|
120
|
+
const used = num(src?.used) ?? (remainingPct != null ? limit * Math.max(0, Math.min(100, 100 - remainingPct)) / 100 : 0);
|
|
121
|
+
const pct = remainingPct != null ? Math.max(0, Math.min(100, 100 - remainingPct)) : (used / limit) * 100;
|
|
122
|
+
const reset = resetAt(src);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
limit,
|
|
126
|
+
used,
|
|
127
|
+
usedPct: pct,
|
|
128
|
+
remaining: num(src?.remaining) ?? Math.max(0, limit - used),
|
|
129
|
+
source: src?.source ?? null,
|
|
130
|
+
resetAt: reset,
|
|
131
|
+
reset: fmtReset(reset),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── plan labels ────────────────────────────────────────────────────────────
|
|
136
|
+
// OpenAI's marketing names, since "pro" alone tells the user nothing about
|
|
137
|
+
// which of the two Pro tiers they are on.
|
|
138
|
+
const PLAN_LABELS = { pro: "Pro 20x", prolite: "Pro 5x", pro_lite: "Pro 5x", "pro-lite": "Pro 5x" };
|
|
139
|
+
function planLabel(plan) {
|
|
140
|
+
if (!plan || typeof plan !== "string") return null;
|
|
141
|
+
const k = plan.toLowerCase().replace(/\s+/g, "_");
|
|
142
|
+
if (PLAN_LABELS[k]) return PLAN_LABELS[k];
|
|
143
|
+
if (k === "k12" || k === "cbp") return k.toUpperCase();
|
|
144
|
+
return k.split(/[_-]/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── extra limit families (Codex Spark and friends) ────────────────────────
|
|
148
|
+
// `additional_rate_limits` is an ARRAY of {limit_name, metered_feature,
|
|
149
|
+
// rate_limit}, not a map. Decoded element-wise so one malformed entry costs
|
|
150
|
+
// only itself.
|
|
151
|
+
function extraLimits(data) {
|
|
152
|
+
const arr = data?.additional_rate_limits;
|
|
153
|
+
if (!Array.isArray(arr)) return { extras: [], damaged: arr != null };
|
|
154
|
+
|
|
155
|
+
const extras = [];
|
|
156
|
+
let damaged = false;
|
|
157
|
+
for (const entry of arr) {
|
|
158
|
+
try {
|
|
159
|
+
const slug = String(entry?.metered_feature ?? entry?.limit_name ?? "extra")
|
|
160
|
+
.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
161
|
+
const title = entry?.limit_name ?? entry?.metered_feature ?? "Codex extra limit";
|
|
162
|
+
for (const w of windowsFrom(entry?.rate_limit, slug)) {
|
|
163
|
+
extras.push({ ...w, label: `${title} · ${w.label}`, family: slug });
|
|
164
|
+
}
|
|
165
|
+
} catch { damaged = true; }
|
|
48
166
|
}
|
|
167
|
+
return { extras, damaged };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── fetch ──────────────────────────────────────────────────────────────────
|
|
171
|
+
function authHeaders(auth, { accountIdHeader = "ChatGPT-Account-Id", extra = {} } = {}) {
|
|
172
|
+
const h = {
|
|
173
|
+
"Authorization": `Bearer ${auth.accessToken}`,
|
|
174
|
+
"Accept": "application/json",
|
|
175
|
+
"User-Agent": "codex-cli",
|
|
176
|
+
...extra,
|
|
177
|
+
};
|
|
178
|
+
if (auth.accountId) h[accountIdHeader] = auth.accountId;
|
|
179
|
+
if (auth.isFedramp) h["X-OpenAI-Fedramp"] = "true";
|
|
180
|
+
return h;
|
|
181
|
+
}
|
|
49
182
|
|
|
50
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Reset credits ("one free rate limit reset" grants). Best-effort and short-
|
|
185
|
+
* timeout: it is a nice-to-have next to the gauges, never a reason to fail
|
|
186
|
+
* the quota read. Read-only — we never redeem.
|
|
187
|
+
*/
|
|
188
|
+
async function fetchResetCredits(base, auth) {
|
|
51
189
|
try {
|
|
52
|
-
const res = await fetch(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
190
|
+
const res = await fetch(base + resetCreditsPath(base), {
|
|
191
|
+
// This endpoint alone wants the uppercase-ID spelling and the beta
|
|
192
|
+
// headers; sending the wham/usage set here returns nothing useful.
|
|
193
|
+
headers: authHeaders(auth, {
|
|
194
|
+
accountIdHeader: "ChatGPT-Account-ID",
|
|
195
|
+
extra: { "OpenAI-Beta": "codex-1", "originator": "Codex Desktop" },
|
|
196
|
+
}),
|
|
197
|
+
signal: AbortSignal.timeout(5_000),
|
|
59
198
|
});
|
|
199
|
+
if (!res.ok) return null;
|
|
200
|
+
const body = await res.json();
|
|
201
|
+
const count = num(body?.available_count);
|
|
202
|
+
if (count == null || count < 0) return null;
|
|
203
|
+
const next = (body?.credits ?? [])
|
|
204
|
+
.filter(c => c?.status === "available" && c?.expires_at)
|
|
205
|
+
.map(c => Date.parse(c.expires_at))
|
|
206
|
+
.filter(t => !isNaN(t))
|
|
207
|
+
.sort((a, b) => a - b)[0] ?? null;
|
|
208
|
+
return { availableCount: count, nextExpiryAt: next };
|
|
209
|
+
} catch { return null; }
|
|
210
|
+
}
|
|
60
211
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
212
|
+
async function requestUsage(base, auth) {
|
|
213
|
+
return fetch(base + usagePath(base), {
|
|
214
|
+
headers: authHeaders(auth),
|
|
215
|
+
// Quota is per-account state; a cached response is how one account's
|
|
216
|
+
// gauges end up shown for another.
|
|
217
|
+
cache: "no-store",
|
|
218
|
+
signal: AbortSignal.timeout(12_000),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// One outstanding fetch at a time. Several browser tabs mounting at once
|
|
223
|
+
// otherwise each force their own round trip — and each one is another chance
|
|
224
|
+
// to race over the single-use refresh token.
|
|
225
|
+
let _inflight = null;
|
|
226
|
+
|
|
227
|
+
export function fetchCodexQuota({ force = false } = {}) {
|
|
228
|
+
if (!force && _cache && Date.now() - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
|
|
229
|
+
_inflight ??= doFetchCodexQuota().finally(() => { _inflight = null; });
|
|
230
|
+
return _inflight;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function doFetchCodexQuota() {
|
|
234
|
+
const started = Date.now();
|
|
235
|
+
// Stamped at completion, not at entry: the two calls below can take up to
|
|
236
|
+
// 17s between them, and a cache entry that is already stale on arrival
|
|
237
|
+
// shortens the effective TTL for no reason.
|
|
238
|
+
const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
|
|
239
|
+
const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
|
|
240
|
+
|
|
241
|
+
let auth, base, res;
|
|
242
|
+
try {
|
|
243
|
+
auth = await getCodexAuth();
|
|
244
|
+
if (!auth.ok) return fail(auth.reason);
|
|
245
|
+
|
|
246
|
+
// An API key in auth.json is a platform credential, not a ChatGPT session —
|
|
247
|
+
// sending it here only produces a confusing 401.
|
|
248
|
+
if (auth.apiKeyMode) return fail("api_key_mode");
|
|
249
|
+
|
|
250
|
+
base = await readBaseUrl();
|
|
251
|
+
res = await requestUsage(base, auth);
|
|
252
|
+
|
|
253
|
+
// The JWT's own `exp` is not the last word: OpenAI revokes server-side, so
|
|
254
|
+
// a token that looks valid locally can still come back expired. One forced
|
|
255
|
+
// refresh + retry turns that from "bar goes dark" into a hiccup.
|
|
256
|
+
//
|
|
257
|
+
// 401 only. A 403 from chatgpt.com is usually a bot check or a blocked
|
|
258
|
+
// egress IP rather than a bad token, and rotating a single-use credential
|
|
259
|
+
// once a minute against a network-layer block is how a working login gets
|
|
260
|
+
// destroyed.
|
|
261
|
+
if (res.status === 401) {
|
|
262
|
+
const refreshed = await forceCodexRefresh(auth.accessToken);
|
|
263
|
+
if (!refreshed.ok) return fail(refreshed.reason);
|
|
264
|
+
auth = refreshed;
|
|
265
|
+
res = await requestUsage(base, auth);
|
|
65
266
|
}
|
|
66
267
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const pw = rl?.primary_window; // 5-hour session window
|
|
70
|
-
const sw = rl?.secondary_window; // 7-day weekly window
|
|
71
|
-
|
|
72
|
-
const creds = data?.credits;
|
|
73
|
-
result = {
|
|
74
|
-
ok: true,
|
|
75
|
-
limitReached: rl?.limit_reached ?? false,
|
|
76
|
-
session5hPct: pw?.used_percent ?? null,
|
|
77
|
-
session5hReset: pw?.reset_at ? fmtReset(pw.reset_at) : null,
|
|
78
|
-
session5hResetAt: pw?.reset_at ?? null, // unix seconds
|
|
79
|
-
session5hWindowSec: pw?.limit_window_seconds ?? 18000,
|
|
80
|
-
week7dPct: sw?.used_percent ?? null,
|
|
81
|
-
week7dReset: sw?.reset_at ? fmtReset(sw.reset_at) : null,
|
|
82
|
-
week7dResetAt: sw?.reset_at ?? null, // unix seconds
|
|
83
|
-
week7dWindowSec: sw?.limit_window_seconds ?? 604800,
|
|
84
|
-
// credits (ChatGPT Plus top-up credits if any)
|
|
85
|
-
creditsBalance: creds?.has_credits ? creds.balance : null,
|
|
86
|
-
creditsUnlimited: creds?.unlimited ?? false,
|
|
87
|
-
planType: data?.plan_type ?? null,
|
|
88
|
-
fetchedAt: now,
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
// Additional model-specific limits (e.g. Codex Spark)
|
|
92
|
-
const extra = rl?.additional_rate_limits;
|
|
93
|
-
if (extra && typeof extra === "object") {
|
|
94
|
-
for (const [key, win] of Object.entries(extra)) {
|
|
95
|
-
if (win?.used_percent != null) {
|
|
96
|
-
result[`extra_${key}_pct`] = win.used_percent;
|
|
97
|
-
if (win.reset_at) result[`extra_${key}_reset`] = fmtReset(win.reset_at);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
268
|
+
if (!res.ok) {
|
|
269
|
+
return fail(res.status === 401 ? "refresh_rejected" : `http_${res.status}`);
|
|
100
270
|
}
|
|
101
271
|
} catch (err) {
|
|
102
272
|
console.error("agents-deck codex-quota: fetch failed:", err?.message ?? err);
|
|
103
|
-
|
|
273
|
+
return fail("fetch_error");
|
|
104
274
|
}
|
|
105
275
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return
|
|
276
|
+
let data;
|
|
277
|
+
try { data = await res.json(); }
|
|
278
|
+
catch { return fail("decode_error"); }
|
|
279
|
+
|
|
280
|
+
const rl = data?.rate_limit;
|
|
281
|
+
const windows = windowsFrom(rl);
|
|
282
|
+
const { extras, damaged } = extraLimits(data);
|
|
283
|
+
const creditsRaw = data?.credits;
|
|
284
|
+
const balance = num(creditsRaw?.balance);
|
|
285
|
+
|
|
286
|
+
const result = {
|
|
287
|
+
ok: true,
|
|
288
|
+
limitReached: rl?.limit_reached ?? false,
|
|
289
|
+
allowed: rl?.allowed ?? true,
|
|
290
|
+
|
|
291
|
+
// Lanes, already ordered session → weekly → monthly and labelled by the
|
|
292
|
+
// window duration the API actually reported.
|
|
293
|
+
windows,
|
|
294
|
+
extraWindows: extras,
|
|
295
|
+
|
|
296
|
+
plan: data?.plan_type ?? auth.planType ?? null,
|
|
297
|
+
planLabel: planLabel(data?.plan_type ?? auth.planType),
|
|
298
|
+
email: data?.email ?? auth.email ?? null,
|
|
299
|
+
|
|
300
|
+
creditsBalance: balance != null && balance > 0 ? String(creditsRaw.balance) : null,
|
|
301
|
+
creditsUnlimited: creditsRaw?.unlimited === true,
|
|
302
|
+
overageReached: creditsRaw?.overage_limit_reached === true,
|
|
303
|
+
creditLimit: creditLimitFrom(data),
|
|
304
|
+
|
|
305
|
+
spendControlReached: data?.spend_control?.reached === true,
|
|
306
|
+
reachedType: data?.rate_limit_reached_type?.type ?? data?.rate_limit_reached_type ?? null,
|
|
307
|
+
promo: data?.promo?.message ?? null,
|
|
308
|
+
|
|
309
|
+
// True when something in the payload did not decode — the UI says "partial"
|
|
310
|
+
// rather than pretending the missing lanes do not exist.
|
|
311
|
+
partial: damaged || windows.length === 0,
|
|
312
|
+
refreshed: auth.refreshed === true,
|
|
313
|
+
fetchedAt: started,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
result.resetCredits = await fetchResetCredits(base, auth);
|
|
317
|
+
|
|
318
|
+
return finish(result);
|
|
109
319
|
}
|
|
110
320
|
|
|
111
321
|
export function invalidateCodexQuotaCache() {
|
package/src/server/index.mjs
CHANGED
|
@@ -1095,16 +1095,26 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1095
1095
|
// already. Just keep the buffer + seq counter primed.
|
|
1096
1096
|
}
|
|
1097
1097
|
}
|
|
1098
|
+
// The async handlers below are dispatched as floating promises. Node's
|
|
1099
|
+
// default for an unhandled rejection is to kill the process, which would
|
|
1100
|
+
// take the whole deck down — SSE stream, hook ingest and all — because one
|
|
1101
|
+
// background quota poll hit a network error. Answer the request instead.
|
|
1102
|
+
const guard = (p, res) => Promise.resolve(p).catch(err => {
|
|
1103
|
+
console.error("agents-deck: request handler failed:", err?.message ?? err);
|
|
1104
|
+
if (!res.headersSent) send(res, 500, { error: "internal error" });
|
|
1105
|
+
else res.end();
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1098
1108
|
const server = createServer((req, res) => {
|
|
1099
1109
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
|
|
1100
1110
|
|
|
1101
|
-
if (req.method === "POST" && url.pathname === "/api/event") return handleEventIngest(req, res);
|
|
1111
|
+
if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res), res);
|
|
1102
1112
|
if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
|
|
1103
1113
|
if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
|
|
1104
|
-
if (req.method === "GET" && url.pathname === "/api/quota") return handleQuota(req, res);
|
|
1105
|
-
if (req.method === "GET" && url.pathname === "/api/codex-usage") return handleCodexUsage(req, res);
|
|
1106
|
-
if (req.method === "GET" && url.pathname === "/api/codex-quota") return handleCodexQuota(req, res);
|
|
1107
|
-
if (req.method === "GET" && url.pathname === "/api/ccusage") return handleCcusage(req, res);
|
|
1114
|
+
if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
|
|
1115
|
+
if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
|
|
1116
|
+
if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
|
|
1117
|
+
if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
|
|
1108
1118
|
|
|
1109
1119
|
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
1110
1120
|
const since = Number(url.searchParams.get("since") ?? 0);
|