@bitkyc08/opencodex 2.38.0 → 2.39.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.
Files changed (81) hide show
  1. package/bin/ocx.mjs +58 -6
  2. package/gui/dist/assets/index-D-lchsPw.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/baseten.svg +13 -0
  6. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  7. package/gui/dist/provider-icons/cerebras.svg +26 -0
  8. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  9. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  10. package/gui/dist/provider-icons/featherless.svg +4 -0
  11. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  12. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  13. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  14. package/gui/dist/provider-icons/kilo.svg +13 -0
  15. package/gui/dist/provider-icons/litellm.svg +1 -0
  16. package/gui/dist/provider-icons/minimax.svg +1 -0
  17. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  18. package/gui/dist/provider-icons/nebius.svg +1 -0
  19. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  20. package/gui/dist/provider-icons/nous.svg +149 -0
  21. package/gui/dist/provider-icons/novita.svg +32 -0
  22. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  23. package/gui/dist/provider-icons/parallel.svg +13 -0
  24. package/gui/dist/provider-icons/sambanova.svg +276 -0
  25. package/gui/dist/provider-icons/scaleway.svg +11 -0
  26. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  27. package/gui/dist/provider-icons/synthetic.svg +12 -0
  28. package/gui/dist/provider-icons/together.svg +18 -0
  29. package/gui/dist/provider-icons/umans.svg +30 -0
  30. package/gui/dist/provider-icons/venice.svg +165 -0
  31. package/gui/dist/provider-icons/vultr.svg +15 -0
  32. package/gui/dist/provider-icons/zai.svg +218 -0
  33. package/gui/dist/provider-icons/zenmux.svg +1 -0
  34. package/package.json +1 -1
  35. package/src/adapters/cursor/live-models.ts +1 -0
  36. package/src/adapters/openai-responses.ts +72 -10
  37. package/src/bridge.ts +15 -15
  38. package/src/cli/dispatch.ts +54 -18
  39. package/src/cli/index.ts +420 -30
  40. package/src/cli/integrations.ts +24 -2
  41. package/src/cli/uninstall-plan.ts +86 -0
  42. package/src/codex/account-store.ts +121 -8
  43. package/src/codex/auth-api.ts +202 -33
  44. package/src/codex/catalog/provider-fetch.ts +76 -9
  45. package/src/codex/history-job.ts +10 -0
  46. package/src/codex/history-manifest.ts +35 -2
  47. package/src/codex/history-provider.ts +196 -19
  48. package/src/codex/history-worker.ts +3 -0
  49. package/src/codex/quota-401-recovery.ts +190 -0
  50. package/src/codex/quota-recovery-timing.ts +28 -0
  51. package/src/codex/quota.ts +6 -0
  52. package/src/codex/routing.ts +68 -16
  53. package/src/codex/subagent-model-fallback.ts +4 -1
  54. package/src/config/pending-teardown-names.d.mts +8 -0
  55. package/src/config/pending-teardown-names.mjs +69 -0
  56. package/src/config/pending-teardown.ts +286 -0
  57. package/src/generated/compatibility-version.json +84 -32
  58. package/src/integrations/journal.ts +12 -1
  59. package/src/integrations/writer.ts +82 -7
  60. package/src/lib/process-control.ts +30 -5
  61. package/src/lib/state-store-registrations.ts +8 -0
  62. package/src/oauth/index.ts +220 -13
  63. package/src/oauth/store.ts +220 -18
  64. package/src/responses/spill-store.ts +20 -0
  65. package/src/responses/state.ts +288 -2
  66. package/src/server/management/integration-routes.ts +36 -5
  67. package/src/server/management-api.ts +66 -12
  68. package/src/server/responses/encrypted-payload.ts +18 -1
  69. package/src/server/stop-teardown.ts +84 -0
  70. package/src/service.ts +155 -20
  71. package/src/update/index.ts +36 -4
  72. package/src/update/proxy-liveness-probe.d.mts +6 -0
  73. package/src/update/proxy-liveness-probe.mjs +84 -0
  74. package/src/update/stop-contract.d.mts +2 -0
  75. package/src/update/stop-contract.mjs +15 -0
  76. package/src/update/stop-decision.d.mts +10 -0
  77. package/src/update/stop-decision.mjs +34 -0
  78. package/src/vision/eligibility.ts +19 -1
  79. package/src/vision/index.ts +4 -18
  80. package/gui/dist/assets/index-C14iCj_Q.js +0 -112
  81. package/gui/dist/assets/index-D7PIz7_g.css +0 -1
@@ -0,0 +1,190 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isCodexAccountGenerationLive, type CodexRefreshProvenance } from "./account-store";
3
+ import { QUOTA_RECOVERY_LEASE_MS } from "./quota-recovery-timing";
4
+
5
+ /**
6
+ * One refresh-and-replay per credential lineage, for a WHAM 401 (#3019).
7
+ *
8
+ * A bare 401 from `backend-api/wham/usage` is what a stale-but-refreshable bearer produces
9
+ * after a plan change, so quarantining on it tells the operator to re-authenticate an
10
+ * account that was fine. The fix is to refresh and replay once — and `once` is the whole
11
+ * problem, because an unbounded retry against an upstream 401 is a self-inflicted
12
+ * credential-stuffing loop.
13
+ *
14
+ * ## Why a claim id rather than the lineage
15
+ *
16
+ * Keying the budget on the lineage cannot separate an old claimant from a later retry on
17
+ * the same lineage: claim L, a transient failure releases L, another poll claims L, and
18
+ * the first caller's late settlement spends the second caller's claim. The claim id makes
19
+ * every mutation a compare-and-set on `(accountId, lineage, claimId)`, so a stale
20
+ * completion is a no-op instead of somebody else's budget.
21
+ *
22
+ * ## Why `spent` never expires and `claimed` does
23
+ *
24
+ * Expiring a spent record would hand the same lineage another refresh, which is exactly
25
+ * the property this module exists to hold. But a caller can be cancelled or die between
26
+ * claim and settle, so `claimed` carries a bounded lease: an expired lease is reclaimable,
27
+ * and is never promoted to `spent` because the refresh may not have happened.
28
+ */
29
+
30
+ export type RecoveryRecord =
31
+ | { state: "claimed"; lineage: number; claimId: string; expiresAt: number }
32
+ /**
33
+ * `terminal` marks a lineage whose refresh proved the grant is dead. It is spent AND the
34
+ * account must keep reporting needs-reauth: without it, the next bare 401 finds the
35
+ * budget used, reports transient, and a dead credential looks healthy.
36
+ */
37
+ | { state: "spent"; lineage: number; terminal?: true }
38
+ | { state: "backoff"; lineage: number; nextAttemptAt: number };
39
+
40
+ export type ClaimResult =
41
+ | { granted: true; claimId: string }
42
+ | { granted: false; reason: "spent" | "in-flight" | "backoff" };
43
+
44
+ const records = new Map<string, RecoveryRecord>();
45
+
46
+ function leaseExpired(record: RecoveryRecord, now: number): boolean {
47
+ return record.state === "claimed" && record.expiresAt <= now;
48
+ }
49
+
50
+ /**
51
+ * Claim the one refresh this lineage is entitled to.
52
+ *
53
+ * A live claim blocks EVERY lineage for the account, not just its own. The refresh it
54
+ * fences commits `G -> G+1` before the claimant can settle, so during that window a
55
+ * `G+1` claim would otherwise be granted and the late settlement would land on nothing.
56
+ */
57
+ export function claimQuotaRecovery(
58
+ accountId: string,
59
+ lineage: number,
60
+ now: number = Date.now(),
61
+ ): ClaimResult {
62
+ const existing = records.get(accountId);
63
+ if (existing && !leaseExpired(existing, now)) {
64
+ if (existing.state === "claimed") return { granted: false, reason: "in-flight" };
65
+ if (existing.state === "spent") {
66
+ // Only this lineage is fenced. A different one is a new grant with its own budget.
67
+ if (existing.lineage === lineage) return { granted: false, reason: "spent" };
68
+ } else if (existing.lineage === lineage && existing.nextAttemptAt > now) {
69
+ return { granted: false, reason: "backoff" };
70
+ }
71
+ }
72
+ const claimId = randomUUID();
73
+ records.set(accountId, { state: "claimed", lineage, claimId, expiresAt: now + QUOTA_RECOVERY_LEASE_MS });
74
+ return { granted: true, claimId };
75
+ }
76
+
77
+ function heldClaim(accountId: string, claimId: string): Extract<RecoveryRecord, { state: "claimed" }> | null {
78
+ const existing = records.get(accountId);
79
+ if (!existing || existing.state !== "claimed" || existing.claimId !== claimId) return null;
80
+ return existing;
81
+ }
82
+
83
+ /**
84
+ * Record the outcome of a claimed refresh.
85
+ *
86
+ * Which lineage gets fenced depends on the outcome, and getting this backwards is how a
87
+ * fresh credential loses the recovery it is entitled to:
88
+ *
89
+ * - `self-refresh` and `joined-lineage` are this lineage's own attempt, so the RETURNED
90
+ * generation is spent. The returned one, never the rejected one: a successful token
91
+ * response can rotate only the refresh grant, leaving the access token byte-identical,
92
+ * and the credential has already moved by then.
93
+ * - `external-replacement` means somebody else's credential is now stored. The claimed
94
+ * OLD lineage is spent — this caller did use its attempt — and the returned generation
95
+ * is left untouched so it can claim on its own next 401.
96
+ */
97
+ export function settleQuotaRecovery(
98
+ accountId: string,
99
+ claimId: string,
100
+ outcome: { provenance: CodexRefreshProvenance; generation: number },
101
+ ): void {
102
+ const held = heldClaim(accountId, claimId);
103
+ if (!held) return; // superseded or already settled: never disturb a newer claimant
104
+ const fenced = outcome.provenance === "external-replacement" ? held.lineage : outcome.generation;
105
+ records.set(accountId, { state: "spent", lineage: fenced });
106
+ }
107
+
108
+ /**
109
+ * Record a refresh that failed with proof the grant itself is dead.
110
+ *
111
+ * Distinct from {@link releaseQuotaRecovery}: a revoked or expired grant will not become
112
+ * valid on the next poll, so backing off would let a later 401 report the account healthy
113
+ * while it is not. The lineage is fenced durably and the caller quarantines.
114
+ */
115
+ export function settleQuotaRecoveryTerminal(accountId: string, claimId: string): void {
116
+ const held = heldClaim(accountId, claimId);
117
+ if (!held) return;
118
+ records.set(accountId, { state: "spent", lineage: held.lineage, terminal: true });
119
+ }
120
+
121
+ /** Did this lineage's one refresh prove the grant dead? */
122
+ export function quotaRecoveryTerminalFor(accountId: string, lineage: number): boolean {
123
+ const record = records.get(accountId);
124
+ return record?.state === "spent" && record.lineage === lineage && record.terminal === true;
125
+ }
126
+
127
+ /**
128
+ * Release a claim whose refresh failed without proving anything about the credential.
129
+ *
130
+ * Into BACKOFF, not into eligibility: a failed quota request does not refresh the quota
131
+ * timestamp, so successive dashboard and background polls would each issue another token
132
+ * refresh — the loop, reopened from the other side.
133
+ */
134
+ export function releaseQuotaRecovery(
135
+ accountId: string,
136
+ claimId: string,
137
+ backoffMs: number,
138
+ now: number = Date.now(),
139
+ ): void {
140
+ const held = heldClaim(accountId, claimId);
141
+ if (!held) return;
142
+ records.set(accountId, { state: "backoff", lineage: held.lineage, nextAttemptAt: now + backoffMs });
143
+ }
144
+
145
+ /**
146
+ * Drop records for accounts that no longer exist, and fences whose credential has moved.
147
+ *
148
+ * A live claim is exempt. Its refresh commits the generation forward, so "the fenced
149
+ * generation is no longer stored" is the EXPECTED state mid-flight, and removing it there
150
+ * would let a second claim in before the first settles.
151
+ */
152
+ export function reconcileQuotaRecovery(liveAccountIds: ReadonlySet<string>, now: number = Date.now()): number {
153
+ let removed = 0;
154
+ for (const [accountId, record] of [...records]) {
155
+ if (!liveAccountIds.has(accountId)) {
156
+ records.delete(accountId);
157
+ removed += 1;
158
+ continue;
159
+ }
160
+ if (record.state === "claimed" && !leaseExpired(record, now)) continue;
161
+ if (!isCodexAccountGenerationLive(accountId, record.lineage)) {
162
+ records.delete(accountId);
163
+ removed += 1;
164
+ }
165
+ }
166
+ return removed;
167
+ }
168
+
169
+ /** Drop expired backoff windows and abandoned leases. */
170
+ export function sweepExpiredQuotaRecovery(now: number = Date.now()): number {
171
+ let removed = 0;
172
+ for (const [accountId, record] of [...records]) {
173
+ // A spent record is durable: expiring it would grant the same lineage another refresh.
174
+ if (record.state === "spent") continue;
175
+ const stale = record.state === "backoff" ? record.nextAttemptAt <= now : leaseExpired(record, now);
176
+ if (stale) {
177
+ records.delete(accountId);
178
+ removed += 1;
179
+ }
180
+ }
181
+ return removed;
182
+ }
183
+
184
+ export function quotaRecoveryRecordForTests(accountId: string): RecoveryRecord | undefined {
185
+ return records.get(accountId);
186
+ }
187
+
188
+ export function resetQuotaRecoveryForTests(): void {
189
+ records.clear();
190
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Timing contract shared by the credential refresh, the WHAM quota request, and the
3
+ * 401-recovery budget (#3019).
4
+ *
5
+ * A leaf on purpose: `auth-api.ts` imports the recovery store to claim and settle, so the
6
+ * recovery store cannot import `auth-api.ts` back for the WHAM timeout. Both of them, and
7
+ * `account-store.ts`, import this instead.
8
+ *
9
+ * These were three inline literals in three files. The lease has to outlast the operations
10
+ * it fences — a lease that expires mid-refresh admits a second claim for a lineage that is
11
+ * already being refreshed — so it is derived here rather than restated as a round number
12
+ * that drifts away from what it covers.
13
+ */
14
+
15
+ /** The refresh flight's own `AbortSignal.timeout` ceiling. */
16
+ export const CODEX_REFRESH_FLIGHT_CEILING_MS = 30_000;
17
+
18
+ /** Deadline for one `backend-api/wham/usage` request. */
19
+ export const WHAM_REQUEST_TIMEOUT_MS = 8_000;
20
+
21
+ /**
22
+ * How long a recovery claim stays valid without settlement.
23
+ *
24
+ * The sequence a claim covers is: WHAM request → refresh → WHAM replay. Both quota legs sit
25
+ * inside the lease, which is why the request timeout is counted twice.
26
+ */
27
+ export const QUOTA_RECOVERY_LEASE_MS =
28
+ CODEX_REFRESH_FLIGHT_CEILING_MS + WHAM_REQUEST_TIMEOUT_MS * 2;
@@ -110,6 +110,12 @@ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boo
110
110
  // Valid upstream percentages are normalized to 0..100. Keep "unknown" outside that domain so an
111
111
  // actually exhausted account is still eligible for threshold rotation.
112
112
  export const CODEX_UNKNOWN_USAGE_SCORE = 101;
113
+ /**
114
+ * A window reading at or above this is a measured refusal, not a position on a scale.
115
+ *
116
+ * Separate from `CODEX_UNKNOWN_USAGE_SCORE` because they mean opposite things: unknown is
117
+ * "we have not observed this account", 100 is "we observed it and it is full".
118
+ */
113
119
  export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
114
120
 
115
121
  export function isCodexQuotaExhausted(
@@ -17,7 +17,7 @@ import {
17
17
  seedPoolRotationAccount,
18
18
  selectPriorityTier,
19
19
  } from "./pool-rotation";
20
- import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
20
+ import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
21
21
  import { isThirtyDayOnlyCodexPlan } from "./plan";
22
22
  import {
23
23
  MAIN_CODEX_ACCOUNT_ID,
@@ -364,7 +364,8 @@ export function computeCodexUsageScore(quota: {
364
364
  weeklyPercent?: number;
365
365
  monthlyPercent?: number;
366
366
  shortPercent?: number;
367
- } | null, plan?: unknown): number {
367
+ shortResetAt?: number;
368
+ } | null, plan?: unknown, now: number = Date.now()): number {
368
369
  if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
369
370
  const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
370
371
  const longWindows = isThirtyDayOnlyCodexPlan(plan)
@@ -376,11 +377,50 @@ export function computeCodexUsageScore(quota: {
376
377
  // account whose weekly/monthly usage is entirely unverified look like the emptiest in the
377
378
  // pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay
378
379
  // unknown until a governing window is actually observed.
379
- if (knownLong.length === 0) return CODEX_UNKNOWN_USAGE_SCORE;
380
+ //
381
+ // A FULL burst window is the exception (#3029). It is not an optimistic guess about an
382
+ // unobserved window — it is a direct observation that the account cannot serve a request
383
+ // right now, whatever its monthly position turns out to be. Unknown-means-selectable is
384
+ // correct for uncertainty and wrong for a measured refusal: the account stays selected,
385
+ // `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential.
386
+ if (knownLong.length === 0) {
387
+ return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE;
388
+ }
380
389
  const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong;
381
390
  return Math.max(...values);
382
391
  }
383
392
 
393
+ /**
394
+ * A short-only reading that proves the account is blocked NOW.
395
+ *
396
+ * Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates
397
+ * carry the old short tuple forward, and disk hydration accepts a persisted reading for
398
+ * hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose
399
+ * five-hour window has since reset. That is #3029 pointed the other way: the issue is that
400
+ * an exhausted account stays selected, and "a recovered account stays excluded" trades one
401
+ * unusable pool for another.
402
+ *
403
+ * A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative
404
+ * direction here is the one that keeps an account selectable: a wrongly-selected account
405
+ * fails one request, while a wrongly-excluded one is invisible until someone reads the pool
406
+ * by hand.
407
+ */
408
+ function isTerminalShortWindow(
409
+ quota: { shortPercent?: number; shortResetAt?: number },
410
+ now: number,
411
+ ): boolean {
412
+ if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false;
413
+ if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
414
+ const resetAt = quota.shortResetAt;
415
+ if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) return false;
416
+ // Both units reach storage: `normalizeResetAt` does not scale, and the GUI disambiguates
417
+ // by magnitude at read time. A comparison written against one assumption is off by 1000x
418
+ // against the other, and in the seconds-read-as-milliseconds direction every terminal
419
+ // reading looks like it reset in 1970 — a fix that passes its own test and does nothing.
420
+ const resetAtMs = resetAt < 10_000_000_000 ? resetAt * 1000 : resetAt;
421
+ return resetAtMs > now;
422
+ }
423
+
384
424
  export function classifyCodexUpstreamOutcome(
385
425
  outcome: CodexUpstreamOutcome,
386
426
  denial?: "workspace" | "entitlement",
@@ -1131,7 +1171,7 @@ function getEligiblePoolAccounts(
1131
1171
  return selectPriorityTier(
1132
1172
  ids,
1133
1173
  codexAccountPriorityLookup(config),
1134
- id => hasCodexQuotaHeadroom(config, id, selectionOptions),
1174
+ id => hasCodexQuotaHeadroom(config, id, selectionOptions, now),
1135
1175
  pinnedCodexAccountId(config),
1136
1176
  );
1137
1177
  }
@@ -1163,12 +1203,14 @@ function hasCodexQuotaHeadroom(
1163
1203
  config: OcxConfig,
1164
1204
  accountId: string,
1165
1205
  selectionOptions?: CodexAccountUsabilityOptions,
1206
+ now: number = Date.now(),
1166
1207
  ): boolean {
1167
1208
  const threshold = config.autoSwitchThreshold ?? 80;
1168
1209
  if (threshold <= 0) return true;
1169
1210
  const usage = computeCodexUsageScore(
1170
1211
  getAccountQuota(accountId),
1171
1212
  getPoolAccountPlanForSelection(config, accountId, selectionOptions),
1213
+ now,
1172
1214
  );
1173
1215
  if (isUnknownUsage(usage)) return true;
1174
1216
  return usage < threshold;
@@ -1188,7 +1230,7 @@ function pickFillFirstCodexAccount(
1188
1230
  if (eligible.length === 0) return null;
1189
1231
 
1190
1232
  const active = getEffectiveActiveCodexAccountId(config);
1191
- if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions)) {
1233
+ if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions, now)) {
1192
1234
  return active;
1193
1235
  }
1194
1236
 
@@ -1200,7 +1242,7 @@ function pickNextFillFirstCodexAccount(
1200
1242
  config: OcxConfig,
1201
1243
  afterId: string | null,
1202
1244
  eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()),
1203
- _now = Date.now(),
1245
+ now = Date.now(),
1204
1246
  selectionOptions?: CodexAccountUsabilityOptions,
1205
1247
  ): string | null {
1206
1248
  if (eligible.length === 0) return null;
@@ -1208,7 +1250,7 @@ function pickNextFillFirstCodexAccount(
1208
1250
  if (!afterId) {
1209
1251
  // Prefer an under-threshold account when starting with no active cursor.
1210
1252
  for (const id of ordered) {
1211
- if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id;
1253
+ if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id;
1212
1254
  }
1213
1255
  return ordered[0] ?? null;
1214
1256
  }
@@ -1223,7 +1265,7 @@ function pickNextFillFirstCodexAccount(
1223
1265
  const startIdx = stableAll.indexOf(afterId);
1224
1266
  if (startIdx < 0) {
1225
1267
  for (const id of ordered) {
1226
- if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id;
1268
+ if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id;
1227
1269
  }
1228
1270
  return ordered[0] ?? null;
1229
1271
  }
@@ -1234,7 +1276,7 @@ function pickNextFillFirstCodexAccount(
1234
1276
  const candidate = stableAll[(startIdx + step) % stableAll.length]!;
1235
1277
  if (!eligible.includes(candidate)) continue;
1236
1278
  if (!fallback) fallback = candidate;
1237
- if (hasCodexQuotaHeadroom(config, candidate, selectionOptions)) return candidate;
1279
+ if (hasCodexQuotaHeadroom(config, candidate, selectionOptions, now)) return candidate;
1238
1280
  }
1239
1281
  return fallback ?? ordered[0] ?? null;
1240
1282
  }
@@ -1356,6 +1398,7 @@ function pickLowerUsageAccount(
1356
1398
  const usage = computeCodexUsageScore(
1357
1399
  getAccountQuota(id),
1358
1400
  getPoolAccountPlanForSelection(config, id, selectionOptions),
1401
+ now,
1359
1402
  );
1360
1403
  if (usage < bestUsage) {
1361
1404
  best = id;
@@ -1370,6 +1413,7 @@ function pickLowestUsageAmong(
1370
1413
  config: OcxConfig,
1371
1414
  ids: readonly string[],
1372
1415
  selectionOptions?: CodexAccountUsabilityOptions,
1416
+ now: number = Date.now(),
1373
1417
  ): string | null {
1374
1418
  let best: string | null = null;
1375
1419
  let bestUsage = Number.POSITIVE_INFINITY;
@@ -1377,6 +1421,7 @@ function pickLowestUsageAmong(
1377
1421
  const usage = computeCodexUsageScore(
1378
1422
  getAccountQuota(id),
1379
1423
  getPoolAccountPlanForSelection(config, id, selectionOptions),
1424
+ now,
1380
1425
  );
1381
1426
  if (usage < bestUsage) {
1382
1427
  best = id;
@@ -1397,6 +1442,7 @@ export function pickLowestUsageCodexAccount(
1397
1442
  config,
1398
1443
  getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions),
1399
1444
  selectionOptions,
1445
+ now,
1400
1446
  );
1401
1447
  }
1402
1448
 
@@ -1540,7 +1586,7 @@ function pickPriorityPreemption(
1540
1586
  if (
1541
1587
  pinned !== undefined
1542
1588
  && eligible.includes(pinned)
1543
- && hasCodexQuotaHeadroom(config, pinned, selectionOptions)
1589
+ && hasCodexQuotaHeadroom(config, pinned, selectionOptions, now)
1544
1590
  ) return null;
1545
1591
  const priorityOf = codexAccountPriorityLookup(config);
1546
1592
  if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
@@ -1548,8 +1594,9 @@ function pickPriorityPreemption(
1548
1594
  // picking one would hand the request straight back to a drained account.
1549
1595
  return pickLowestUsageAmong(
1550
1596
  config,
1551
- eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions)),
1597
+ eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)),
1552
1598
  selectionOptions,
1599
+ now,
1553
1600
  );
1554
1601
  }
1555
1602
 
@@ -1566,6 +1613,7 @@ function releaseDrainedCodexAccountPin(
1566
1613
  CodexAccountUsabilityOptions,
1567
1614
  "nativeMainSelectionOnly" | "isMainAccountTokenLive"
1568
1615
  >,
1616
+ now: number = Date.now(),
1569
1617
  ): void {
1570
1618
  const pinned = pinnedCodexAccountId(config);
1571
1619
  if (pinned === undefined) return;
@@ -1580,7 +1628,7 @@ function releaseDrainedCodexAccountPin(
1580
1628
  // is readable. Cached reauth and configured pause state were handled above.
1581
1629
  if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return;
1582
1630
  const drained = !isCodexAccountUsable(config, pinned, selectionOptions)
1583
- || !hasCodexQuotaHeadroom(config, pinned, selectionOptions);
1631
+ || !hasCodexQuotaHeadroom(config, pinned, selectionOptions, now);
1584
1632
  if (!drained) return;
1585
1633
  clearCodexAccountPin(config);
1586
1634
  saveConfigPreservingClaudeCode(config);
@@ -1600,6 +1648,7 @@ function applyQuotaAutoSwitch(
1600
1648
  const activeUsage = computeCodexUsageScore(
1601
1649
  quota,
1602
1650
  getPoolAccountPlanForSelection(config, active, selectionOptions),
1651
+ now,
1603
1652
  );
1604
1653
  // Unknown usage is not evidence that a user's explicit selection crossed the
1605
1654
  // threshold. Wait for quota priming instead of rotating among guesses.
@@ -1633,7 +1682,7 @@ function isHealthySharedCodexSelection(
1633
1682
  selectionOptions: CodexAccountUsabilityOptions | undefined,
1634
1683
  ): boolean {
1635
1684
  return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions)
1636
- && hasCodexQuotaHeadroom(config, accountId, selectionOptions)
1685
+ && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)
1637
1686
  && !shouldFailover(config, accountId, now);
1638
1687
  }
1639
1688
 
@@ -1720,6 +1769,7 @@ function previewReusableAffinityAccount(
1720
1769
  const usage = computeCodexUsageScore(
1721
1770
  getAccountQuota(entry.accountId),
1722
1771
  getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
1772
+ now,
1723
1773
  );
1724
1774
  if (!isUnknownUsage(usage) && usage >= threshold) {
1725
1775
  const best = pickLowerUsageAccount(
@@ -1755,6 +1805,7 @@ function reevaluateAffinityQuota(
1755
1805
  ? computeCodexUsageScore(
1756
1806
  getAccountQuota(entry.accountId),
1757
1807
  getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
1808
+ now,
1758
1809
  )
1759
1810
  : 0;
1760
1811
  const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
@@ -1848,6 +1899,7 @@ export function previewCodexAccountForRequest(
1848
1899
  const usage = computeCodexUsageScore(
1849
1900
  getAccountQuota(active),
1850
1901
  getPoolAccountPlanForSelection(config, active, selectionOptions),
1902
+ now,
1851
1903
  );
1852
1904
  if (!isUnknownUsage(usage) && usage >= threshold) {
1853
1905
  active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
@@ -1887,7 +1939,7 @@ export function resolveCodexAccountForThreadDetailed(
1887
1939
  // revive after quota resets. Independent model scopes must never persist a
1888
1940
  // change to shared routing state.
1889
1941
  if (!isIndependentCodexQuotaScope(quotaScope)) {
1890
- releaseDrainedCodexAccountPin(config, sharedStateSelectionOptions(selectionOptions));
1942
+ releaseDrainedCodexAccountPin(config, sharedStateSelectionOptions(selectionOptions), now);
1891
1943
  }
1892
1944
  const sharedActiveBeforeSelection = getEffectiveActiveCodexAccountId(config);
1893
1945
  const preserveSharedSelectionForModelDetour = modelScopedSelection && (
@@ -1945,7 +1997,7 @@ export function resolveCodexAccountForThreadDetailed(
1945
1997
  && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions);
1946
1998
  const failoverReady = shouldFailover(config, entry.accountId, now);
1947
1999
  const healthyForSharedAffinity = selectableForSharedState
1948
- && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions)
2000
+ && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions, now)
1949
2001
  && !failoverReady;
1950
2002
  if (
1951
2003
  selectableForRequest
@@ -2041,7 +2093,7 @@ export function resolveCodexAccountForThreadDetailed(
2041
2093
  sharedSelectionOptions,
2042
2094
  );
2043
2095
  const activeHealthyForSharedSelection = activeSelectableForSharedState
2044
- && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions)
2096
+ && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions, now)
2045
2097
  && !shouldFailover(config, active, now);
2046
2098
  if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
2047
2099
  const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
@@ -226,7 +226,10 @@ export function isNativeModelQuotaExhausted(
226
226
  const resolvedAccountId = resolveRouteFallbackAccountId(route, config, accountId);
227
227
  if (!resolvedAccountId) return false;
228
228
  const quota = getAccountQuota(resolvedAccountId);
229
- const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId));
229
+ // Subagent fallback reads the same score, so a stale terminal reading would push
230
+ // subagents off a native model whose window has already reset. Thread the caller's clock
231
+ // rather than letting the scorer read wall time - the two would silently diverge.
232
+ const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId), now);
230
233
  if (usage >= CODEX_UNKNOWN_USAGE_SCORE) return false;
231
234
  return usage >= quotaThreshold(config);
232
235
  }
@@ -0,0 +1,8 @@
1
+ export declare const PENDING_TEARDOWN_PREFIX: string;
2
+ export declare const PENDING_TEARDOWN_SUFFIX: string;
3
+ export declare const PENDING_TEARDOWN_UNREADABLE_SUFFIX: string;
4
+ export declare function isPendingTeardownFileName(name: unknown): boolean;
5
+ export declare function isQuarantinedTeardownFileName(name: unknown): boolean;
6
+ export declare function isAnyTeardownObligationFileName(name: unknown): boolean;
7
+ export declare function pendingTeardownNonceFromFileName(name: string): string | null;
8
+ export declare function hasPendingTeardownIn(readdir: (dir: string) => string[], dir: string): boolean;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Naming rules for pending-teardown receipts, shared by both update lanes (#3008).
3
+ *
4
+ * Plain ESM because `bin/ocx.mjs` runs under Node before Bun exists and cannot import the
5
+ * TypeScript module. It lives here rather than being spelled out twice because that is
6
+ * exactly how this broke: the launcher kept checking the retired singleton filename after
7
+ * the receipts moved to one file per claim, so the npm lane silently stopped seeing every
8
+ * outstanding obligation.
9
+ */
10
+
11
+ export const PENDING_TEARDOWN_PREFIX = "pending-teardown-";
12
+ export const PENDING_TEARDOWN_SUFFIX = ".json";
13
+ /**
14
+ * Suffix for an obligation that could not be read.
15
+ *
16
+ * It is still an obligation. Quarantine renames the file so the ordinary recovery loop
17
+ * stops re-reading garbage, but it must NOT stop counting: an update that proceeds
18
+ * because the evidence was filed away is exactly the outcome the receipt exists to
19
+ * prevent. Both lanes treat this as outstanding until an operator removes it.
20
+ */
21
+ export const PENDING_TEARDOWN_UNREADABLE_SUFFIX = ".unreadable.json";
22
+ const NONCE_RE = /^[0-9a-f]{32}$/;
23
+
24
+ /** A receipt the recovery loop should read and try to discharge. */
25
+ export function isPendingTeardownFileName(name) {
26
+ if (typeof name !== "string") return false;
27
+ if (isQuarantinedTeardownFileName(name)) return false;
28
+ if (!name.startsWith(PENDING_TEARDOWN_PREFIX) || !name.endsWith(PENDING_TEARDOWN_SUFFIX)) return false;
29
+ return NONCE_RE.test(name.slice(PENDING_TEARDOWN_PREFIX.length, name.length - PENDING_TEARDOWN_SUFFIX.length));
30
+ }
31
+
32
+ /** A receipt that could not be read and is waiting on a human. */
33
+ export function isQuarantinedTeardownFileName(name) {
34
+ if (typeof name !== "string") return false;
35
+ if (!name.startsWith(PENDING_TEARDOWN_PREFIX) || !name.endsWith(PENDING_TEARDOWN_UNREADABLE_SUFFIX)) return false;
36
+ return NONCE_RE.test(name.slice(
37
+ PENDING_TEARDOWN_PREFIX.length,
38
+ name.length - PENDING_TEARDOWN_UNREADABLE_SUFFIX.length,
39
+ ));
40
+ }
41
+
42
+ /** Any obligation at all — readable or quarantined. Both block an update. */
43
+ export function isAnyTeardownObligationFileName(name) {
44
+ return isPendingTeardownFileName(name) || isQuarantinedTeardownFileName(name);
45
+ }
46
+
47
+ export function pendingTeardownNonceFromFileName(name) {
48
+ if (!isPendingTeardownFileName(name)) return null;
49
+ return name.slice(PENDING_TEARDOWN_PREFIX.length, name.length - PENDING_TEARDOWN_SUFFIX.length);
50
+ }
51
+
52
+ /**
53
+ * Does the given config directory hold any outstanding obligation?
54
+ *
55
+ * Quarantined receipts count. Filing one away to unblock an update would let the very
56
+ * next `ocx update` install over a teardown that never ran — the enforcement has to
57
+ * survive until a human removes the file.
58
+ */
59
+ export function hasPendingTeardownIn(readdir, dir) {
60
+ try {
61
+ return readdir(dir).some(isAnyTeardownObligationFileName);
62
+ } catch (error) {
63
+ // "There is no home yet" is the only honest empty answer. Any other failure —
64
+ // permissions, I/O, a file where the directory should be — means an obligation may be
65
+ // sitting there unread, and reporting "none" would let an update install over a
66
+ // teardown that never ran. Absence of proof is not proof of absence.
67
+ return error?.code !== "ENOENT";
68
+ }
69
+ }