@bitkyc08/opencodex 2.7.43 → 2.8.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/bin/ocx.mjs +34 -8
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/kiro.ts +15 -1
- package/src/claude/alias.ts +94 -14
- package/src/claude/outbound.ts +6 -3
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +32 -7
- package/src/cli/doctor.ts +48 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/interactive-confirm.ts +5 -1
- package/src/cli/star-prompt.ts +26 -4
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-store.ts +2 -0
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +26 -1
- package/src/codex/catalog/provider-fetch.ts +240 -82
- package/src/codex/catalog/sync.ts +27 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/features.ts +524 -5
- package/src/codex/quota.ts +77 -2
- package/src/codex/runtime.ts +10 -1
- package/src/config.ts +8 -0
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/destination-policy.ts +10 -3
- package/src/lib/provider-outbound.ts +5 -2
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/oauth/index.ts +29 -5
- package/src/oauth/key-providers.ts +21 -2
- package/src/oauth/kiro-credentials.ts +57 -8
- package/src/oauth/kiro.ts +2 -1
- package/src/oauth/login-cli.ts +1 -1
- package/src/oauth/store.ts +2 -0
- package/src/providers/derive.ts +2 -2
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/registry.ts +114 -0
- package/src/router.ts +5 -3
- package/src/server/auth-cors.ts +4 -2
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +78 -4
- package/src/server/management/config-routes.ts +19 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/provider-routes.ts +44 -9
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/responses/core.ts +31 -20
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/service.ts +237 -19
- package/src/storage/policy-job.ts +26 -5
- package/src/storage/restore-job.ts +16 -5
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +32 -4
- package/src/types.ts +11 -0
- package/src/update/badge.ts +72 -0
- package/src/update/job.ts +8 -4
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +8 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-Czw-jpTU.css +0 -1
- package/gui/dist/assets/index-cmds12BG.js +0 -67
package/src/codex/features.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* features.ts — codex feature-flag view for $CODEX_HOME/config.toml.
|
|
3
3
|
*
|
|
4
|
+
* Scope boundary: this module mirrors ONLY `multi_agent_v2`, because opencodex has
|
|
5
|
+
* to migrate its concurrency value across the v1/v2 boundary and expose the
|
|
6
|
+
* multi-agent config surface. Every other upstream feature flag is delegated to
|
|
7
|
+
* the native `codex features` command (see src/cli/v2.ts) and must not be
|
|
8
|
+
* hardcoded here.
|
|
9
|
+
*
|
|
10
|
+
* Upstream reshapes flags freely: in the 1f0566d3f..5a1097ed2 range alone,
|
|
11
|
+
* `code_mode_host` changed from a boolean to a table (it is Stage::Stable and
|
|
12
|
+
* default-enabled upstream), `enable_fanout` and `item_ids` were retired to
|
|
13
|
+
* Stage::Removed ("useless but kept for backward compatibility"), and several
|
|
14
|
+
* under-development flags were added. Delegation is what keeps opencodex out of
|
|
15
|
+
* that churn.
|
|
16
|
+
*
|
|
4
17
|
* Used by the catalog v2-gated-ultra policy (devlog/260709_v2_gated_ultra) and the
|
|
5
18
|
* `ocx v2` toggle surface. The FLAG itself is never written here — toggling goes
|
|
6
19
|
* through the official `codex features enable|disable` CLI (format-preserving).
|
|
@@ -140,6 +153,40 @@ export function getAgentsMaxThreads(configPath?: string): number | null {
|
|
|
140
153
|
return Number.isInteger(value) && value >= 1 ? value : null;
|
|
141
154
|
}
|
|
142
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Current `[agents] enabled`. Upstream defaults this to true and lets an enabled
|
|
158
|
+
* `features.multi_agent_v2` override it entirely (codex-rs core/src/config/mod.rs
|
|
159
|
+
* multi_agent_version_override returns V2 first at :1521-1523; `enabled = false`
|
|
160
|
+
* only takes effect with V2 off), so `null` means "unset, upstream default applies"
|
|
161
|
+
* and is NOT the same as `true`.
|
|
162
|
+
*/
|
|
163
|
+
export function getAgentsEnabled(configPath?: string): boolean | null {
|
|
164
|
+
const content = readConfigText(configPath);
|
|
165
|
+
if (content === null) return null;
|
|
166
|
+
const agents = tomlTableBody(content, "agents");
|
|
167
|
+
if (agents === null) return null;
|
|
168
|
+
const m = agents.match(/^\s*enabled\s*=\s*(true|false)\s*(?:#.*)?$/m);
|
|
169
|
+
return m ? m[1] === "true" : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Current `[agents] max_depth`. Upstream applies this to V1 agent threads only and
|
|
174
|
+
* ignores it under V2 (config_toml.rs: "Maximum nesting depth for V1 agent threads.
|
|
175
|
+
* Ignored by V2."). The upstream type is `Option<i32>` with no minimum, so a
|
|
176
|
+
* negative value is valid config that effectively disables V1 child spawning —
|
|
177
|
+
* do not "correct" it, and do not present this as an effective V2 limit.
|
|
178
|
+
*/
|
|
179
|
+
export function getAgentsMaxDepth(configPath?: string): number | null {
|
|
180
|
+
const content = readConfigText(configPath);
|
|
181
|
+
if (content === null) return null;
|
|
182
|
+
const agents = tomlTableBody(content, "agents");
|
|
183
|
+
if (agents === null) return null;
|
|
184
|
+
const m = agents.match(/^\s*max_depth\s*=\s*(-?\d+)\s*(?:#.*)?$/m);
|
|
185
|
+
if (!m) return null;
|
|
186
|
+
const value = Number(m[1]);
|
|
187
|
+
return Number.isInteger(value) && value >= -2_147_483_648 && value <= 2_147_483_647 ? value : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
143
190
|
/**
|
|
144
191
|
* Current `features.multi_agent_v2.max_concurrent_threads_per_session`, from
|
|
145
192
|
* either the dedicated or inline-table form; null means the Codex default.
|
|
@@ -157,6 +204,47 @@ export function getMaxConcurrentThreads(configPath?: string): number | null {
|
|
|
157
204
|
return Number.isFinite(value) && value >= 1 ? value : null;
|
|
158
205
|
}
|
|
159
206
|
|
|
207
|
+
/** Largest V1 child limit we translate. Well below Number.MAX_SAFE_INTEGER and far
|
|
208
|
+
* above any real concurrency setting; upstream's usize saturates, ours would silently
|
|
209
|
+
* lose precision. */
|
|
210
|
+
const MAX_TRANSLATABLE_V1_CHILD_LIMIT = 1_000_000;
|
|
211
|
+
/** The V2 side is one larger by construction: it counts the root agent's own slot, so
|
|
212
|
+
* the image of the maximum V1 value must itself be translatable back. */
|
|
213
|
+
const MAX_TRANSLATABLE_V2_TOTAL_LIMIT = MAX_TRANSLATABLE_V1_CHILD_LIMIT + 1;
|
|
214
|
+
|
|
215
|
+
export function isTranslatableV1ChildLimit(limit: number): boolean {
|
|
216
|
+
return Number.isInteger(limit) && limit >= 1 && limit <= MAX_TRANSLATABLE_V1_CHILD_LIMIT;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function isTranslatableV2TotalLimit(limit: number): boolean {
|
|
220
|
+
return Number.isInteger(limit) && limit >= 1 && limit <= MAX_TRANSLATABLE_V2_TOTAL_LIMIT;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Upstream counts the root agent inside the V2 thread limit but not inside the legacy
|
|
225
|
+
* `[agents]` limit (codex-rs core/src/config/mod.rs resolve_multi_agent_v2_config applies
|
|
226
|
+
* saturating_add(1) to the [agents] value; the inverse saturating_sub(1) appears at
|
|
227
|
+
* mod.rs:1555). These helpers keep our migrations on the same side of that boundary.
|
|
228
|
+
*/
|
|
229
|
+
export function v1ChildLimitToV2TotalLimit(childLimit: number): number {
|
|
230
|
+
if (!isTranslatableV1ChildLimit(childLimit)) {
|
|
231
|
+
throw new RangeError(`v1 child limit out of translatable range: ${childLimit}`);
|
|
232
|
+
}
|
|
233
|
+
return childLimit + 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Inverse of `v1ChildLimitToV2TotalLimit`. A V2 total of 1 means "root only, no
|
|
238
|
+
* children", which has no representable legacy child count >= 1, so it clamps to 1
|
|
239
|
+
* rather than writing 0 and tripping upstream's `>= 1` validation.
|
|
240
|
+
*/
|
|
241
|
+
export function v2TotalLimitToV1ChildLimit(totalLimit: number): number {
|
|
242
|
+
if (!isTranslatableV2TotalLimit(totalLimit)) {
|
|
243
|
+
throw new RangeError(`v2 total limit out of translatable range: ${totalLimit}`);
|
|
244
|
+
}
|
|
245
|
+
return Math.max(1, totalLimit - 1);
|
|
246
|
+
}
|
|
247
|
+
|
|
160
248
|
/**
|
|
161
249
|
* Persist `features.multi_agent_v2.max_concurrent_threads_per_session = value`.
|
|
162
250
|
* Scoped edit in either the dedicated table or `[features]` boolean/inline form.
|
|
@@ -224,6 +312,378 @@ export function setMaxConcurrentThreads(value: number, configPath?: string, migr
|
|
|
224
312
|
|
|
225
313
|
type ConfigEditResult = { ok: true; changed: boolean } | { ok: false; error: string };
|
|
226
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Encode a string as a TOML single-line basic string.
|
|
317
|
+
*
|
|
318
|
+
* Character-by-character on purpose. A chained-replace implementation
|
|
319
|
+
* (`.replace(/\\/g, "\\\\").replace(/\t/g, "\\t")`) corrupts input: the backslash
|
|
320
|
+
* pass runs first, then later passes insert NEW backslashes the first pass can no
|
|
321
|
+
* longer protect. Single-line basic strings handle every case including embedded
|
|
322
|
+
* `"""`, which a multi-line `"""..."""` form cannot.
|
|
323
|
+
*/
|
|
324
|
+
function encodeTomlBasicString(value: string): string {
|
|
325
|
+
let out = '"';
|
|
326
|
+
for (const ch of value) {
|
|
327
|
+
switch (ch) {
|
|
328
|
+
case "\\": out += "\\\\"; break;
|
|
329
|
+
case '"': out += '\\"'; break;
|
|
330
|
+
case "\n": out += "\\n"; break;
|
|
331
|
+
case "\r": out += "\\r"; break;
|
|
332
|
+
case "\t": out += "\\t"; break;
|
|
333
|
+
case "\b": out += "\\b"; break;
|
|
334
|
+
case "\f": out += "\\f"; break;
|
|
335
|
+
default: {
|
|
336
|
+
const code = ch.codePointAt(0)!;
|
|
337
|
+
out += code < 0x20 || code === 0x7f
|
|
338
|
+
? `\\u${code.toString(16).padStart(4, "0")}`
|
|
339
|
+
: ch;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return out + '"';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Decode one TOML string token INCLUDING its quotes. Basic strings (`"..."`)
|
|
348
|
+
* unescape; literal strings (`'...'`) are verbatim — a backslash is not special
|
|
349
|
+
* there. Returns null for anything that is not a string token.
|
|
350
|
+
*/
|
|
351
|
+
function decodeTomlStringToken(token: string): string | null {
|
|
352
|
+
if (token.length < 2) return null;
|
|
353
|
+
if (token.startsWith("'")) {
|
|
354
|
+
return token.endsWith("'") ? token.slice(1, -1) : null;
|
|
355
|
+
}
|
|
356
|
+
if (!token.startsWith('"') || !token.endsWith('"')) return null;
|
|
357
|
+
const body = token.slice(1, -1);
|
|
358
|
+
let out = "";
|
|
359
|
+
for (let i = 0; i < body.length; i++) {
|
|
360
|
+
const ch = body[i];
|
|
361
|
+
if (ch !== "\\") { out += ch; continue; }
|
|
362
|
+
const esc = body[++i];
|
|
363
|
+
switch (esc) {
|
|
364
|
+
case "\\": out += "\\"; break;
|
|
365
|
+
case '"': out += '"'; break;
|
|
366
|
+
case "n": out += "\n"; break;
|
|
367
|
+
case "r": out += "\r"; break;
|
|
368
|
+
case "t": out += "\t"; break;
|
|
369
|
+
case "b": out += "\b"; break;
|
|
370
|
+
case "f": out += "\f"; break;
|
|
371
|
+
case "u": {
|
|
372
|
+
const code = parseInt(body.slice(i + 1, i + 5), 16);
|
|
373
|
+
if (Number.isNaN(code)) return null;
|
|
374
|
+
out += String.fromCodePoint(code);
|
|
375
|
+
i += 4;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
case "U": {
|
|
379
|
+
const code = parseInt(body.slice(i + 1, i + 9), 16);
|
|
380
|
+
if (Number.isNaN(code)) return null;
|
|
381
|
+
out += String.fromCodePoint(code);
|
|
382
|
+
i += 8;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
default: return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* End index (exclusive) of the TOML value starting at or after `start` in `text`.
|
|
393
|
+
* String-aware: basic strings honor backslash escapes, literal strings do not.
|
|
394
|
+
* Inline tables and arrays nest and are scanned with the same awareness.
|
|
395
|
+
*/
|
|
396
|
+
function scanTomlValueEnd(text: string, start: number): number {
|
|
397
|
+
let i = start;
|
|
398
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t")) i++;
|
|
399
|
+
const first = text[i];
|
|
400
|
+
if (first === '"') {
|
|
401
|
+
i++;
|
|
402
|
+
while (i < text.length) {
|
|
403
|
+
if (text[i] === "\\") { i += 2; continue; }
|
|
404
|
+
if (text[i] === '"') return i + 1;
|
|
405
|
+
i++;
|
|
406
|
+
}
|
|
407
|
+
return text.length;
|
|
408
|
+
}
|
|
409
|
+
if (first === "'") {
|
|
410
|
+
const close = text.indexOf("'", i + 1);
|
|
411
|
+
return close === -1 ? text.length : close + 1;
|
|
412
|
+
}
|
|
413
|
+
if (first === "{") {
|
|
414
|
+
const close = findInlineTableEnd(text, i);
|
|
415
|
+
return close === -1 ? text.length : close + 1;
|
|
416
|
+
}
|
|
417
|
+
if (first === "[") {
|
|
418
|
+
let depth = 0;
|
|
419
|
+
while (i < text.length) {
|
|
420
|
+
const c = text[i];
|
|
421
|
+
if (c === '"' || c === "'") { i = scanTomlValueEnd(text, i); continue; }
|
|
422
|
+
if (c === "[") depth++;
|
|
423
|
+
else if (c === "]") { depth--; if (depth === 0) return i + 1; }
|
|
424
|
+
i++;
|
|
425
|
+
}
|
|
426
|
+
return text.length;
|
|
427
|
+
}
|
|
428
|
+
while (i < text.length && !/[\s,}\]#]/.test(text[i])) i++;
|
|
429
|
+
return i;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Index of the `}` matching the `{` at `openIdx`, string-aware, or -1. */
|
|
433
|
+
function findInlineTableEnd(text: string, openIdx: number): number {
|
|
434
|
+
let depth = 0;
|
|
435
|
+
let i = openIdx;
|
|
436
|
+
while (i < text.length) {
|
|
437
|
+
const c = text[i];
|
|
438
|
+
if (c === '"' || c === "'") { i = scanTomlValueEnd(text, i); continue; }
|
|
439
|
+
if (c === "{") depth++;
|
|
440
|
+
else if (c === "}") { depth--; if (depth === 0) return i; }
|
|
441
|
+
i++;
|
|
442
|
+
}
|
|
443
|
+
return -1;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
interface InlineEntry { keyStart: number; valueStart: number; valueEnd: number }
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Locate `key = value` inside the inline-table body spanning [bodyStart, bodyEnd)
|
|
450
|
+
* (exclusive of the braces), string-aware on both keys and values, or null.
|
|
451
|
+
*/
|
|
452
|
+
function findInlineEntry(text: string, bodyStart: number, bodyEnd: number, key: string): InlineEntry | null {
|
|
453
|
+
let i = bodyStart;
|
|
454
|
+
while (i < bodyEnd) {
|
|
455
|
+
while (i < bodyEnd && /[\s,]/.test(text[i])) i++;
|
|
456
|
+
if (i >= bodyEnd) break;
|
|
457
|
+
const entryStart = i;
|
|
458
|
+
let keyText: string;
|
|
459
|
+
if (text[i] === '"' || text[i] === "'") {
|
|
460
|
+
const keyEnd = scanTomlValueEnd(text, i);
|
|
461
|
+
keyText = decodeTomlStringToken(text.slice(i, keyEnd)) ?? "";
|
|
462
|
+
i = keyEnd;
|
|
463
|
+
} else {
|
|
464
|
+
const m = /^[A-Za-z0-9_-]+/.exec(text.slice(i, bodyEnd));
|
|
465
|
+
if (!m) break;
|
|
466
|
+
keyText = m[0];
|
|
467
|
+
i += m[0].length;
|
|
468
|
+
}
|
|
469
|
+
while (i < bodyEnd && /\s/.test(text[i])) i++;
|
|
470
|
+
if (text[i] !== "=") { i = entryStart + 1; continue; }
|
|
471
|
+
i++;
|
|
472
|
+
while (i < bodyEnd && /\s/.test(text[i])) i++;
|
|
473
|
+
const valueStart = i;
|
|
474
|
+
const valueEnd = Math.min(scanTomlValueEnd(text, valueStart), bodyEnd);
|
|
475
|
+
if (keyText === key) return { keyStart: entryStart, valueStart, valueEnd };
|
|
476
|
+
i = valueEnd;
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Set or remove one scalar key inside a top-level TOML table, preserving every
|
|
483
|
+
* other line byte-for-byte — including the existing value's trailing comment,
|
|
484
|
+
* which is kept verbatim. `encoded` is the already-serialized RHS
|
|
485
|
+
* (`encodeTomlBasicString` for strings, `String(n)` for numbers, `"true"`/`"false"`
|
|
486
|
+
* for booleans); null removes the key. Creates the table when absent. Returns the
|
|
487
|
+
* new content; returning the input unchanged means no-op.
|
|
488
|
+
*/
|
|
489
|
+
function editScalarInTable(content: string, table: string, key: string, encoded: string | null): string {
|
|
490
|
+
const eol = dominantEol(content);
|
|
491
|
+
const lines = content.split(/\r?\n/);
|
|
492
|
+
const headerRe = new RegExp(`^\\s*\\[${table.replace(/\./g, "\\.")}\\]\\s*(?:#.*)?$`);
|
|
493
|
+
const headerIdx = lines.findIndex(l => headerRe.test(l));
|
|
494
|
+
if (headerIdx === -1) {
|
|
495
|
+
if (encoded === null) return content;
|
|
496
|
+
const separator = lines.length > 0 && lines[lines.length - 1] !== "" ? [""] : [];
|
|
497
|
+
lines.push(...separator, `[${table}]`, `${key} = ${encoded}`);
|
|
498
|
+
return applyEol(lines.join("\n"), eol);
|
|
499
|
+
}
|
|
500
|
+
let end = lines.length;
|
|
501
|
+
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|
502
|
+
if (/^\s*\[/.test(lines[i])) { end = i; break; }
|
|
503
|
+
}
|
|
504
|
+
const keyRe = new RegExp(`^(\\s*)${key}\\s*=\\s*`);
|
|
505
|
+
for (let i = headerIdx + 1; i < end; i++) {
|
|
506
|
+
const m = lines[i].match(keyRe);
|
|
507
|
+
if (!m) continue;
|
|
508
|
+
// The existing value may itself contain '#', so scan the value token
|
|
509
|
+
// string-aware instead of splitting on '#'.
|
|
510
|
+
const line = lines[i];
|
|
511
|
+
const valueStart = m[0].length;
|
|
512
|
+
const valueEnd = scanTomlValueEnd(line, valueStart);
|
|
513
|
+
const trailing = line.slice(valueEnd);
|
|
514
|
+
if (encoded === null) {
|
|
515
|
+
lines.splice(i, 1);
|
|
516
|
+
return applyEol(lines.join("\n"), eol);
|
|
517
|
+
}
|
|
518
|
+
if (line.slice(valueStart, valueEnd).trim() === encoded) return content;
|
|
519
|
+
lines[i] = `${m[1]}${key} = ${encoded}${trailing}`;
|
|
520
|
+
return applyEol(lines.join("\n"), eol);
|
|
521
|
+
}
|
|
522
|
+
if (encoded === null) return content;
|
|
523
|
+
lines.splice(headerIdx + 1, 0, `${key} = ${encoded}`);
|
|
524
|
+
return applyEol(lines.join("\n"), eol);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Persist `[agents] enabled = value`, or remove the key when `value` is null. */
|
|
528
|
+
export function setAgentsEnabled(value: boolean | null, configPath?: string): ConfigEditResult {
|
|
529
|
+
const path = configPath ?? activeCodexConfigPath();
|
|
530
|
+
const content = readConfigText(path);
|
|
531
|
+
if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
|
|
532
|
+
const next = editScalarInTable(content, "agents", "enabled", value === null ? null : String(value));
|
|
533
|
+
if (next === content) return { ok: true, changed: false };
|
|
534
|
+
atomicWriteFile(path, next);
|
|
535
|
+
return { ok: true, changed: true };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Persist `[agents] max_depth = value`, or remove the key when `value` is null.
|
|
540
|
+
* Validation is exactly the upstream contract: `Option<i32>` with no minimum, so
|
|
541
|
+
* any integer in signed-i32 range is accepted — writing anything wider would
|
|
542
|
+
* produce a config upstream cannot deserialize, a hard parse failure for the
|
|
543
|
+
* user's Codex.
|
|
544
|
+
*/
|
|
545
|
+
export function setAgentsMaxDepth(value: number | null, configPath?: string): ConfigEditResult {
|
|
546
|
+
if (value !== null && (!Number.isInteger(value) || value < -2_147_483_648 || value > 2_147_483_647)) {
|
|
547
|
+
return { ok: false, error: "max_depth must be an integer within signed i32 range" };
|
|
548
|
+
}
|
|
549
|
+
const path = configPath ?? activeCodexConfigPath();
|
|
550
|
+
const content = readConfigText(path);
|
|
551
|
+
if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
|
|
552
|
+
const next = editScalarInTable(content, "agents", "max_depth", value === null ? null : String(value));
|
|
553
|
+
if (next === content) return { ok: true, changed: false };
|
|
554
|
+
atomicWriteFile(path, next);
|
|
555
|
+
return { ok: true, changed: true };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Current `features.multi_agent_v2.subagent_developer_instructions`.
|
|
560
|
+
*
|
|
561
|
+
* Upstream tri-state (codex-rs core/src/config/mod.rs resolve_multi_agent_v2_config):
|
|
562
|
+
* unset -> the child inherits the parent's developer instructions
|
|
563
|
+
* non-empty -> replaces the inherited parent fragment
|
|
564
|
+
* empty string -> clears the inherited fragment
|
|
565
|
+
*
|
|
566
|
+
* So `null` and `""` are DIFFERENT values and both must round-trip. Upstream
|
|
567
|
+
* `.trim()`s the configured text, so whitespace-only values are effectively `""`.
|
|
568
|
+
* Reads both the dedicated-table and inline forms; dedicated wins, mirroring
|
|
569
|
+
* `getMaxConcurrentThreads` precedence.
|
|
570
|
+
*/
|
|
571
|
+
export function getSubagentDeveloperInstructions(configPath?: string): string | null {
|
|
572
|
+
const content = readConfigText(configPath);
|
|
573
|
+
if (content === null) return null;
|
|
574
|
+
const table = tomlTableBody(content, "features.multi_agent_v2");
|
|
575
|
+
if (table !== null) {
|
|
576
|
+
const m = table.match(/^\s*subagent_developer_instructions\s*=\s*/m);
|
|
577
|
+
if (m) {
|
|
578
|
+
const valueStart = m.index! + m[0].length;
|
|
579
|
+
const token = table.slice(valueStart, scanTomlValueEnd(table, valueStart)).trim();
|
|
580
|
+
return decodeTomlStringToken(token);
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const features = tomlTableBody(content, "features");
|
|
585
|
+
if (features === null) return null;
|
|
586
|
+
const m = features.match(/multi_agent_v2\s*=\s*\{/);
|
|
587
|
+
if (!m) return null;
|
|
588
|
+
const openIdx = m.index! + m[0].length - 1;
|
|
589
|
+
const closeIdx = findInlineTableEnd(features, openIdx);
|
|
590
|
+
if (closeIdx === -1) return null;
|
|
591
|
+
const entry = findInlineEntry(features, openIdx + 1, closeIdx, "subagent_developer_instructions");
|
|
592
|
+
if (!entry) return null;
|
|
593
|
+
return decodeTomlStringToken(features.slice(entry.valueStart, entry.valueEnd).trim());
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Persist `features.multi_agent_v2.subagent_developer_instructions`, or remove the
|
|
598
|
+
* key when `value` is null. Handles all three existing encodings: the dedicated
|
|
599
|
+
* table (scalar edit), the inline table (string-aware edit inside the braces — a
|
|
600
|
+
* regex over `[^}]*` would corrupt any value containing `}`), and the bare boolean
|
|
601
|
+
* form (upgraded in place to an inline table, mirroring `setMaxConcurrentThreads`).
|
|
602
|
+
* With no existing v2 config, creates the dedicated table carrying only this key.
|
|
603
|
+
* The key name must match upstream character-for-character: the upstream struct
|
|
604
|
+
* carries `#[serde(deny_unknown_fields)]`, so a misspelling is not ignored — it is
|
|
605
|
+
* a hard config-parse failure for the user's Codex.
|
|
606
|
+
*/
|
|
607
|
+
export function setSubagentDeveloperInstructions(value: string | null, configPath?: string): ConfigEditResult {
|
|
608
|
+
const path = configPath ?? activeCodexConfigPath();
|
|
609
|
+
const content = readConfigText(path);
|
|
610
|
+
if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
|
|
611
|
+
const encoded = value === null ? null : encodeTomlBasicString(value);
|
|
612
|
+
|
|
613
|
+
if (tomlTableBody(content, "features.multi_agent_v2") !== null) {
|
|
614
|
+
const next = editScalarInTable(content, "features.multi_agent_v2", "subagent_developer_instructions", encoded);
|
|
615
|
+
if (next === content) return { ok: true, changed: false };
|
|
616
|
+
atomicWriteFile(path, next);
|
|
617
|
+
return { ok: true, changed: true };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const eol = dominantEol(content);
|
|
621
|
+
const lines = content.split(/\r?\n/);
|
|
622
|
+
const featuresHeader = lines.findIndex(l => /^\s*\[features\]\s*(?:#.*)?$/.test(l));
|
|
623
|
+
if (featuresHeader !== -1) {
|
|
624
|
+
let featuresEnd = lines.length;
|
|
625
|
+
for (let i = featuresHeader + 1; i < lines.length; i++) {
|
|
626
|
+
if (/^\s*\[/.test(lines[i])) { featuresEnd = i; break; }
|
|
627
|
+
}
|
|
628
|
+
for (let i = featuresHeader + 1; i < featuresEnd; i++) {
|
|
629
|
+
const line = lines[i];
|
|
630
|
+
const inlineMatch = line.match(/^(\s*)multi_agent_v2\s*=\s*\{/);
|
|
631
|
+
if (inlineMatch) {
|
|
632
|
+
const openIdx = inlineMatch[0].length - 1;
|
|
633
|
+
const closeIdx = findInlineTableEnd(line, openIdx);
|
|
634
|
+
if (closeIdx === -1) return { ok: false, error: "malformed multi_agent_v2 inline table" };
|
|
635
|
+
const entry = findInlineEntry(line, openIdx + 1, closeIdx, "subagent_developer_instructions");
|
|
636
|
+
if (encoded === null) {
|
|
637
|
+
if (!entry) return { ok: true, changed: false };
|
|
638
|
+
let start = entry.keyStart;
|
|
639
|
+
let stop = entry.valueEnd;
|
|
640
|
+
let j = stop;
|
|
641
|
+
while (j < closeIdx && line[j] === " ") j++;
|
|
642
|
+
if (line[j] === ",") {
|
|
643
|
+
stop = j + 1;
|
|
644
|
+
while (stop < closeIdx && line[stop] === " ") stop++;
|
|
645
|
+
} else {
|
|
646
|
+
let k = start;
|
|
647
|
+
while (k > openIdx + 1 && line[k - 1] === " ") k--;
|
|
648
|
+
if (line[k - 1] === ",") start = k - 1;
|
|
649
|
+
}
|
|
650
|
+
lines[i] = line.slice(0, start) + line.slice(stop);
|
|
651
|
+
atomicWriteFile(path, applyEol(lines.join("\n"), eol));
|
|
652
|
+
return { ok: true, changed: true };
|
|
653
|
+
}
|
|
654
|
+
if (entry) {
|
|
655
|
+
if (line.slice(entry.valueStart, entry.valueEnd).trim() === encoded) return { ok: true, changed: false };
|
|
656
|
+
lines[i] = line.slice(0, entry.valueStart) + encoded + line.slice(entry.valueEnd);
|
|
657
|
+
} else {
|
|
658
|
+
let insertPos = closeIdx;
|
|
659
|
+
while (insertPos > openIdx + 1 && line[insertPos - 1] === " ") insertPos--;
|
|
660
|
+
const hasEntries = line.slice(openIdx + 1, insertPos).trim().length > 0;
|
|
661
|
+
const insertion = hasEntries
|
|
662
|
+
? `, subagent_developer_instructions = ${encoded} `
|
|
663
|
+
: ` subagent_developer_instructions = ${encoded} `;
|
|
664
|
+
lines[i] = line.slice(0, insertPos) + insertion + line.slice(closeIdx);
|
|
665
|
+
}
|
|
666
|
+
atomicWriteFile(path, applyEol(lines.join("\n"), eol));
|
|
667
|
+
return { ok: true, changed: true };
|
|
668
|
+
}
|
|
669
|
+
const boolMatch = line.match(/^(\s*)multi_agent_v2\s*=\s*(true|false)(\s*(?:#.*)?)$/);
|
|
670
|
+
if (boolMatch) {
|
|
671
|
+
if (encoded === null) return { ok: true, changed: false };
|
|
672
|
+
lines[i] = `${boolMatch[1]}multi_agent_v2 = { enabled = ${boolMatch[2]}, subagent_developer_instructions = ${encoded} }${boolMatch[3]}`;
|
|
673
|
+
atomicWriteFile(path, applyEol(lines.join("\n"), eol));
|
|
674
|
+
return { ok: true, changed: true };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (encoded === null) return { ok: true, changed: false };
|
|
680
|
+
const suffix = content.endsWith("\n") || content.length === 0 ? "" : eol;
|
|
681
|
+
const separator = content.length > 0 && !content.endsWith(`${eol}${eol}`) ? eol : "";
|
|
682
|
+
const tableText = `[features.multi_agent_v2]${eol}subagent_developer_instructions = ${encoded}${eol}`;
|
|
683
|
+
atomicWriteFile(path, `${content}${suffix}${separator}${tableText}`);
|
|
684
|
+
return { ok: true, changed: true };
|
|
685
|
+
}
|
|
686
|
+
|
|
227
687
|
function editAgentsMaxThreads(value: number | null, configPath?: string, migratedComment?: string): ConfigEditResult {
|
|
228
688
|
const path = configPath ?? activeCodexConfigPath();
|
|
229
689
|
const content = readConfigText(path);
|
|
@@ -313,11 +773,51 @@ function ensureDisabledV2Config(value: number | null, configPath?: string, migra
|
|
|
313
773
|
return { ok: true, changed: true };
|
|
314
774
|
}
|
|
315
775
|
|
|
316
|
-
/**
|
|
776
|
+
/**
|
|
777
|
+
* The effective concurrency limit expressed in the units of the currently ACTIVE
|
|
778
|
+
* backend: under V2 the total-thread limit upstream enforces, under V1 the child
|
|
779
|
+
* limit. The active backend's own key wins; the other backend's key is translated
|
|
780
|
+
* across the root-agent slot. Display path only — never throws: a stored value
|
|
781
|
+
* outside the translatable range is returned raw (at that magnitude the ±1 root
|
|
782
|
+
* slot is already below float precision, and crashing `ocx v2 status` or
|
|
783
|
+
* `GET /api/v2` is not a price worth paying for a translation that means nothing).
|
|
784
|
+
* Migration code uses `discoverStoredThreadLimit` instead, which keeps provenance.
|
|
785
|
+
*/
|
|
317
786
|
export function getLogicalMaxThreads(configPath?: string): number | null {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
787
|
+
if (isMultiAgentV2Enabled(configPath)) {
|
|
788
|
+
const v2 = getMaxConcurrentThreads(configPath);
|
|
789
|
+
if (v2 !== null) return v2;
|
|
790
|
+
const legacy = getAgentsMaxThreads(configPath);
|
|
791
|
+
if (legacy === null) return null;
|
|
792
|
+
return isTranslatableV1ChildLimit(legacy) ? v1ChildLimitToV2TotalLimit(legacy) : legacy;
|
|
793
|
+
}
|
|
794
|
+
const legacy = getAgentsMaxThreads(configPath);
|
|
795
|
+
if (legacy !== null) return legacy;
|
|
796
|
+
const v2 = getMaxConcurrentThreads(configPath);
|
|
797
|
+
if (v2 === null) return null;
|
|
798
|
+
return isTranslatableV2TotalLimit(v2) ? v2TotalLimitToV1ChildLimit(v2) : v2;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
type ThreadLimitUnits = "v1-child" | "v2-total";
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Which storage the active limit lives in, in that storage's native units. The
|
|
805
|
+
* active backend's own key wins; the other backend's key is the fallback and keeps
|
|
806
|
+
* ITS units. Never translates and never throws. This is the migration-side sibling
|
|
807
|
+
* of `getLogicalMaxThreads`: a migration needs to know exactly which storage the
|
|
808
|
+
* value came from, and a display function's raw fallback would lose that.
|
|
809
|
+
*/
|
|
810
|
+
function discoverStoredThreadLimit(configPath?: string): { value: number; units: ThreadLimitUnits } | null {
|
|
811
|
+
if (isMultiAgentV2Enabled(configPath)) {
|
|
812
|
+
const v2 = getMaxConcurrentThreads(configPath);
|
|
813
|
+
if (v2 !== null) return { value: v2, units: "v2-total" };
|
|
814
|
+
const legacy = getAgentsMaxThreads(configPath);
|
|
815
|
+
return legacy === null ? null : { value: legacy, units: "v1-child" };
|
|
816
|
+
}
|
|
817
|
+
const legacy = getAgentsMaxThreads(configPath);
|
|
818
|
+
if (legacy !== null) return { value: legacy, units: "v1-child" };
|
|
819
|
+
const v2 = getMaxConcurrentThreads(configPath);
|
|
820
|
+
return v2 === null ? null : { value: v2, units: "v2-total" };
|
|
321
821
|
}
|
|
322
822
|
|
|
323
823
|
function activeThreadComment(content: string, v2Enabled: boolean): string | undefined {
|
|
@@ -403,7 +903,26 @@ export function transitionMultiAgentV2(
|
|
|
403
903
|
const preflightError = transitionConfigError(original);
|
|
404
904
|
if (preflightError) return { ok: false, error: preflightError };
|
|
405
905
|
const beforeEnabled = isMultiAgentV2Enabled(path);
|
|
406
|
-
|
|
906
|
+
// A caller-supplied limit is already in the DESTINATION backend's units and is
|
|
907
|
+
// never translated. A discovered limit carries the units of the storage it was
|
|
908
|
+
// read from and crosses the root-slot boundary only when those units differ
|
|
909
|
+
// from the destination's — which covers both backend flips and same-state
|
|
910
|
+
// storage migrations (legacy-only under V2, V2-only under V1). The range
|
|
911
|
+
// check runs only when a translation is actually needed, and before the try
|
|
912
|
+
// block so an out-of-range stored value is a normal error result rather than
|
|
913
|
+
// a RangeError escaping the rollback contract.
|
|
914
|
+
const discovered = discoverStoredThreadLimit(path);
|
|
915
|
+
const destinationUnits: ThreadLimitUnits = enabled ? "v2-total" : "v1-child";
|
|
916
|
+
let threadLimit = options.threadLimit ?? discovered?.value ?? null;
|
|
917
|
+
if (options.threadLimit === undefined && discovered !== null && discovered.units !== destinationUnits) {
|
|
918
|
+
const translatable = discovered.units === "v1-child" ? isTranslatableV1ChildLimit : isTranslatableV2TotalLimit;
|
|
919
|
+
if (!translatable(discovered.value)) {
|
|
920
|
+
return { ok: false, error: `stored thread limit out of translatable range: ${discovered.value}` };
|
|
921
|
+
}
|
|
922
|
+
threadLimit = discovered.units === "v1-child"
|
|
923
|
+
? v1ChildLimitToV2TotalLimit(discovered.value)
|
|
924
|
+
: v2TotalLimitToV1ChildLimit(discovered.value);
|
|
925
|
+
}
|
|
407
926
|
const migratedComment = activeThreadComment(original, beforeEnabled);
|
|
408
927
|
try {
|
|
409
928
|
if (enabled) {
|
package/src/codex/quota.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { atomicWriteFile, getConfigDir } from "../config";
|
|
4
|
+
|
|
1
5
|
export type StoredAccountQuota = {
|
|
2
6
|
weeklyPercent?: number;
|
|
3
7
|
monthlyPercent?: number;
|
|
@@ -7,6 +11,20 @@ export type StoredAccountQuota = {
|
|
|
7
11
|
updatedAt: number;
|
|
8
12
|
};
|
|
9
13
|
|
|
14
|
+
/** Disk snapshot under OPENCODEX_HOME — usage percents only (no emails/tokens). */
|
|
15
|
+
const QUOTA_CACHE_FILENAME = "codex-quota-cache.json";
|
|
16
|
+
/** Keep last-known bars across restarts; WHAM still refreshes on TTL in live/prime paths. */
|
|
17
|
+
const QUOTA_DISK_MAX_AGE_MS = 6 * 60 * 60_000;
|
|
18
|
+
const QUOTA_PERSIST_DEBOUNCE_MS = 250;
|
|
19
|
+
|
|
20
|
+
type QuotaDiskFile = {
|
|
21
|
+
version: 1;
|
|
22
|
+
quotas: Record<string, StoredAccountQuota>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let diskHydrated = false;
|
|
26
|
+
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
|
|
10
28
|
export type WhamUsageResponse = {
|
|
11
29
|
email?: string | null;
|
|
12
30
|
plan_type?: string | null;
|
|
@@ -120,6 +138,7 @@ export function setAccountQuotaFromParsed(
|
|
|
120
138
|
if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
|
|
121
139
|
next.resetCredits = quota.resetCredits;
|
|
122
140
|
accountQuota.set(accountId, next);
|
|
141
|
+
schedulePersistAccountQuotas();
|
|
123
142
|
return;
|
|
124
143
|
}
|
|
125
144
|
|
|
@@ -145,6 +164,7 @@ export function setAccountQuotaFromParsed(
|
|
|
145
164
|
else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
|
|
146
165
|
|
|
147
166
|
accountQuota.set(accountId, next);
|
|
167
|
+
schedulePersistAccountQuotas();
|
|
148
168
|
}
|
|
149
169
|
|
|
150
170
|
export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQuota, "updatedAt"> | null {
|
|
@@ -235,19 +255,74 @@ export function updateAccountQuota(
|
|
|
235
255
|
if (resetCredits !== undefined) quota.resetCredits = resetCredits;
|
|
236
256
|
|
|
237
257
|
accountQuota.set(accountId, quota);
|
|
258
|
+
schedulePersistAccountQuotas();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function hydrateAccountQuotasFromDisk(): void {
|
|
262
|
+
if (diskHydrated) return;
|
|
263
|
+
diskHydrated = true;
|
|
264
|
+
try {
|
|
265
|
+
const path = join(getConfigDir(), QUOTA_CACHE_FILENAME);
|
|
266
|
+
if (!existsSync(path)) return;
|
|
267
|
+
const raw = readFileSync(path, "utf8");
|
|
268
|
+
const parsed = JSON.parse(raw) as QuotaDiskFile;
|
|
269
|
+
if (!parsed || parsed.version !== 1 || !parsed.quotas || typeof parsed.quotas !== "object") return;
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
for (const [accountId, quota] of Object.entries(parsed.quotas)) {
|
|
272
|
+
if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number") continue;
|
|
273
|
+
if (now - quota.updatedAt > QUOTA_DISK_MAX_AGE_MS) continue;
|
|
274
|
+
if (!accountQuota.has(accountId)) accountQuota.set(accountId, quota);
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// Corrupt/missing cache must never block routing or the dashboard.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function schedulePersistAccountQuotas(): void {
|
|
282
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
283
|
+
persistTimer = setTimeout(() => {
|
|
284
|
+
persistTimer = null;
|
|
285
|
+
try {
|
|
286
|
+
const quotas: Record<string, StoredAccountQuota> = {};
|
|
287
|
+
for (const [accountId, quota] of accountQuota.entries()) {
|
|
288
|
+
quotas[accountId] = quota;
|
|
289
|
+
}
|
|
290
|
+
const body: QuotaDiskFile = { version: 1, quotas };
|
|
291
|
+
atomicWriteFile(join(getConfigDir(), QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`);
|
|
292
|
+
} catch {
|
|
293
|
+
// Best-effort persistence only.
|
|
294
|
+
}
|
|
295
|
+
}, QUOTA_PERSIST_DEBOUNCE_MS);
|
|
238
296
|
}
|
|
239
297
|
|
|
240
298
|
export function getAccountQuota(accountId: string): StoredAccountQuota | null {
|
|
299
|
+
hydrateAccountQuotasFromDisk();
|
|
241
300
|
return accountQuota.get(accountId) ?? null;
|
|
242
301
|
}
|
|
243
302
|
|
|
244
303
|
export function listAccountQuotas(): IterableIterator<[string, StoredAccountQuota]> {
|
|
304
|
+
hydrateAccountQuotasFromDisk();
|
|
245
305
|
return accountQuota.entries();
|
|
246
306
|
}
|
|
247
307
|
|
|
248
308
|
export function clearAccountQuota(accountId?: string): void {
|
|
249
|
-
if (accountId)
|
|
250
|
-
|
|
309
|
+
if (accountId) {
|
|
310
|
+
accountQuota.delete(accountId);
|
|
311
|
+
schedulePersistAccountQuotas();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
accountQuota.clear();
|
|
315
|
+
diskHydrated = false;
|
|
316
|
+
if (persistTimer) {
|
|
317
|
+
clearTimeout(persistTimer);
|
|
318
|
+
persistTimer = null;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const path = join(getConfigDir(), QUOTA_CACHE_FILENAME);
|
|
322
|
+
if (existsSync(path)) unlinkSync(path);
|
|
323
|
+
} catch {
|
|
324
|
+
// Best-effort; memory is already cleared.
|
|
325
|
+
}
|
|
251
326
|
}
|
|
252
327
|
|
|
253
328
|
export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null {
|