@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901

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 (131) hide show
  1. package/bin/ocx.mjs +127 -16
  2. package/gui/dist/assets/index-6HZDkIpX.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/aside.svg +3 -0
  6. package/gui/dist/provider-icons/baseten.svg +13 -0
  7. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  8. package/gui/dist/provider-icons/cerebras.svg +26 -0
  9. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  10. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  11. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  12. package/gui/dist/provider-icons/featherless.svg +4 -0
  13. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  14. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  15. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  16. package/gui/dist/provider-icons/kilo.svg +13 -0
  17. package/gui/dist/provider-icons/litellm.svg +1 -0
  18. package/gui/dist/provider-icons/minimax.svg +1 -0
  19. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  20. package/gui/dist/provider-icons/nebius.svg +1 -0
  21. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  22. package/gui/dist/provider-icons/nous.svg +149 -0
  23. package/gui/dist/provider-icons/novita.svg +32 -0
  24. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  25. package/gui/dist/provider-icons/openclaw.svg +54 -0
  26. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  27. package/gui/dist/provider-icons/parallel.svg +13 -0
  28. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  29. package/gui/dist/provider-icons/sambanova.svg +276 -0
  30. package/gui/dist/provider-icons/scaleway.svg +11 -0
  31. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  32. package/gui/dist/provider-icons/synthetic.svg +12 -0
  33. package/gui/dist/provider-icons/together.svg +18 -0
  34. package/gui/dist/provider-icons/umans.svg +30 -0
  35. package/gui/dist/provider-icons/venice.svg +165 -0
  36. package/gui/dist/provider-icons/vultr.svg +15 -0
  37. package/gui/dist/provider-icons/zai.svg +218 -0
  38. package/gui/dist/provider-icons/zcode.svg +219 -0
  39. package/gui/dist/provider-icons/zenmux.svg +1 -0
  40. package/package.json +1 -1
  41. package/src/adapters/cursor/live-models.ts +1 -0
  42. package/src/adapters/cursor/protobuf-request.ts +4 -1
  43. package/src/adapters/cursor/tool-definitions.ts +36 -4
  44. package/src/adapters/openai-responses.ts +72 -10
  45. package/src/bridge.ts +15 -15
  46. package/src/cli/capabilities.ts +14 -0
  47. package/src/cli/codex-cli-update.ts +96 -0
  48. package/src/cli/codex-shim-autorestore.ts +3 -0
  49. package/src/cli/dispatch.ts +54 -18
  50. package/src/cli/export-command.ts +18 -17
  51. package/src/cli/help.ts +2 -2
  52. package/src/cli/index.ts +423 -32
  53. package/src/cli/integrations.ts +24 -2
  54. package/src/cli/launcher-context.ts +53 -2
  55. package/src/cli/opencode.ts +126 -33
  56. package/src/cli/registry.ts +16 -10
  57. package/src/cli/system-command.ts +6 -1
  58. package/src/cli/uninstall-plan.ts +86 -0
  59. package/src/clients/config-export.ts +293 -28
  60. package/src/codex/account-store.ts +131 -12
  61. package/src/codex/auth-api.ts +202 -33
  62. package/src/codex/autostart-health.ts +3 -3
  63. package/src/codex/catalog/provider-fetch.ts +96 -10
  64. package/src/codex/catalog/sync.ts +4 -3
  65. package/src/codex/cli-install-provenance.ts +795 -0
  66. package/src/codex/convergence.ts +4 -3
  67. package/src/codex/credential-mutation-epoch.ts +11 -0
  68. package/src/codex/history-job.ts +10 -0
  69. package/src/codex/history-manifest.ts +35 -2
  70. package/src/codex/history-provider.ts +196 -19
  71. package/src/codex/history-worker.ts +3 -0
  72. package/src/codex/main-account.ts +2 -0
  73. package/src/codex/model-entitlements.ts +430 -27
  74. package/src/codex/native-profile-manager.ts +4 -0
  75. package/src/codex/quota-401-recovery.ts +190 -0
  76. package/src/codex/quota-recovery-timing.ts +28 -0
  77. package/src/codex/quota.ts +6 -0
  78. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  79. package/src/codex/reset-credit-recovery.ts +20 -2
  80. package/src/codex/routing.ts +68 -16
  81. package/src/codex/shim.ts +204 -18
  82. package/src/codex/subagent-model-fallback.ts +4 -1
  83. package/src/codex/user-identity.ts +2 -1
  84. package/src/config/paths.ts +18 -3
  85. package/src/config/pending-teardown-names.d.mts +8 -0
  86. package/src/config/pending-teardown-names.mjs +69 -0
  87. package/src/config/pending-teardown.ts +286 -0
  88. package/src/config.ts +23 -0
  89. package/src/generated/compatibility-version.json +155 -67
  90. package/src/integrations/journal.ts +12 -1
  91. package/src/integrations/registry.ts +112 -0
  92. package/src/integrations/state.ts +67 -5
  93. package/src/integrations/writer.ts +107 -16
  94. package/src/lib/bounded-subprocess.ts +36 -0
  95. package/src/lib/process-control.ts +30 -5
  96. package/src/lib/state-store-registrations.ts +8 -0
  97. package/src/lib/strict-semver.ts +47 -0
  98. package/src/lib/windows-elevation.ts +32 -1
  99. package/src/lib/windows-secret-acl.ts +47 -25
  100. package/src/lib/windows-service-mutation-lock.ts +133 -0
  101. package/src/lib/windows-user-principal.ts +15 -17
  102. package/src/oauth/index.ts +220 -13
  103. package/src/oauth/store.ts +220 -18
  104. package/src/responses/spill-store.ts +354 -29
  105. package/src/responses/state.ts +775 -8
  106. package/src/server/index.ts +4 -3
  107. package/src/server/lifecycle.ts +5 -1
  108. package/src/server/management/integration-routes.ts +36 -5
  109. package/src/server/management/model-rows.ts +11 -2
  110. package/src/server/management/provider-routes.ts +4 -0
  111. package/src/server/management/system-restart.ts +5 -5
  112. package/src/server/management-api.ts +72 -13
  113. package/src/server/responses/encrypted-payload.ts +18 -1
  114. package/src/server/startup-action-control.ts +3 -2
  115. package/src/server/stop-teardown.ts +84 -0
  116. package/src/service.ts +749 -53
  117. package/src/sidecar/candidates.ts +1 -1
  118. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  119. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  120. package/src/update/index.ts +39 -6
  121. package/src/update/job.ts +10 -11
  122. package/src/update/proxy-liveness-probe.d.mts +6 -0
  123. package/src/update/proxy-liveness-probe.mjs +84 -0
  124. package/src/update/stop-contract.d.mts +2 -0
  125. package/src/update/stop-contract.mjs +15 -0
  126. package/src/update/stop-decision.d.mts +10 -0
  127. package/src/update/stop-decision.mjs +34 -0
  128. package/src/vision/eligibility.ts +19 -1
  129. package/src/vision/index.ts +4 -18
  130. package/gui/dist/assets/index-CowztZdo.js +0 -112
  131. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -12,6 +12,8 @@ import {
12
12
  } from "../config";
13
13
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
14
14
  import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
15
+ import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
16
+ import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
15
17
 
16
18
  type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
17
19
  type CodexAccountStore = Record<string, CodexAccountCredentialRecord>;
@@ -111,6 +113,11 @@ function persist(store: CodexAccountStore): void {
111
113
  atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
112
114
  }
113
115
 
116
+ function persistCredentialMutation(store: CodexAccountStore): void {
117
+ persist(store);
118
+ advanceCodexCredentialMutationEpoch();
119
+ }
120
+
114
121
  function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick<
115
122
  CodexAccountCredentialRecord,
116
123
  "lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError"
@@ -142,7 +149,7 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent
142
149
  replacedAt: current ? Date.now() : undefined,
143
150
  ...preservedValidationMetadata(current),
144
151
  };
145
- persist(store);
152
+ persistCredentialMutation(store);
146
153
  });
147
154
  }
148
155
 
@@ -213,7 +220,7 @@ export function saveCodexAccountCredentialIfGeneration(
213
220
  replacedAt: current.replacedAt,
214
221
  ...preservedValidationMetadata(current),
215
222
  };
216
- persist(store);
223
+ persistCredentialMutation(store);
217
224
  return true;
218
225
  });
219
226
  }
@@ -304,7 +311,7 @@ export function commitRefreshedCodexCredentialWithAliases(
304
311
  propagatedAliases.push({ id: aliasId, generation: aliasGeneration });
305
312
  }
306
313
  }
307
- persist(store);
314
+ persistCredentialMutation(store);
308
315
  return { committed: true, propagatedAliases };
309
316
  });
310
317
  }
@@ -315,7 +322,7 @@ export function tombstoneCodexAccount(id: string): number {
315
322
  const current = store[id];
316
323
  const generation = (current?.generation ?? 0) + 1;
317
324
  store[id] = { generation, deletedAt: Date.now() };
318
- persist(store);
325
+ persistCredentialMutation(store);
319
326
  return generation;
320
327
  });
321
328
  }
@@ -401,7 +408,29 @@ type CodexRefreshResult = CodexTokenResult & {
401
408
  * refresh of the one the caller was holding, not somebody else's replacement.
402
409
  */
403
410
  selfRefreshed?: boolean;
411
+ /**
412
+ * Three-way form of {@link selfRefreshed}, kept alongside it so existing callers are
413
+ * unaffected (#3019). `selfRefreshed` is `provenance === "self-refresh"`.
414
+ */
415
+ provenance?: CodexRefreshProvenance;
404
416
  };
417
+
418
+ /**
419
+ * How THIS caller arrived at the credential it is returning (#3019).
420
+ *
421
+ * `selfRefreshed` is a boolean, and a boolean cannot carry three cases. Its `false` means
422
+ * both "somebody else replaced the credential" and "I joined an in-flight refresh of the
423
+ * same grant and adopted its result" — and a recovery budget has to treat those opposite
424
+ * ways. Joining is the same lineage getting its one refresh; replacement is a NEW lineage
425
+ * that has not had one yet, and charging it for somebody else's attempt would deny the
426
+ * fresh credential the recovery this exists to grant.
427
+ */
428
+ export type CodexRefreshProvenance = "self-refresh" | "joined-lineage" | "external-replacement";
429
+
430
+ /** Terminal outcome of one forced refresh, as seen by the caller that requested it. */
431
+ export type ForcedRefreshOutcome =
432
+ | { kind: "resolved"; provenance: CodexRefreshProvenance; generation: number; rotated: boolean }
433
+ | { kind: "failed"; error: unknown };
405
434
  const MAX_CODEX_REFRESH_FLIGHTS = 32;
406
435
  const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
407
436
  interface RefreshFlight {
@@ -581,22 +610,81 @@ function awaitOwnCancellation<T>(work: Promise<T>, callerSignal?: AbortSignal):
581
610
  */
582
611
  export async function forceRefreshCodexPoolToken(
583
612
  id: string,
584
- options: { rejectedGeneration: number; rejectedAccessToken: string; signal?: AbortSignal },
585
- ): Promise<CodexTokenResult & { rotated: boolean; selfRefreshed: boolean }> {
586
- const result = await resolveCodexToken(
613
+ options: {
614
+ rejectedGeneration: number;
615
+ rejectedAccessToken: string;
616
+ signal?: AbortSignal;
617
+ /**
618
+ * Fires with THIS caller's classified outcome, regardless of `signal` (#3019).
619
+ *
620
+ * Cancellation rejects what the caller awaits; the shared flight keeps running and
621
+ * commits. A recovery budget claimed before the refresh therefore has no one left to
622
+ * settle it — the claim expires and the already-refreshed lineage gets a second
623
+ * refresh, which is the loop the budget exists to close. This callback is attached to
624
+ * the resolution itself, so it fires with no waiter present.
625
+ *
626
+ * It is called exactly once per call, for both success and failure, and its own
627
+ * failures are swallowed: settlement bookkeeping must never reject a credential the
628
+ * caller successfully obtained, nor disturb another waiter on the same flight.
629
+ */
630
+ onSettled?: (outcome: ForcedRefreshOutcome) => void | Promise<void>;
631
+ },
632
+ ): Promise<CodexTokenResult & { rotated: boolean; selfRefreshed: boolean; provenance: CodexRefreshProvenance }> {
633
+ const settle = (outcome: ForcedRefreshOutcome) => {
634
+ // Both halves matter: a synchronous throw and a rejected thenable are equally capable
635
+ // of turning settlement bookkeeping into an unhandled rejection that fails the process.
636
+ try { void Promise.resolve(options.onSettled?.(outcome)).catch(() => {}); } catch { /* ignore */ }
637
+ };
638
+ const classify = (result: CodexRefreshResult): CodexRefreshProvenance =>
639
+ // Default to the conservative reading. A path that did not classify itself is not
640
+ // assumed to be this caller's own lineage: charging a replacement for somebody else's
641
+ // attempt is the failure mode, so an unlabelled path leaves the returned lineage its
642
+ // own budget.
643
+ result.provenance ?? (result.selfRefreshed === true ? "self-refresh" : "external-replacement");
644
+
645
+ // The completion is NOT the caller's await.
646
+ //
647
+ // `options.signal` cancels what this function returns, while the shared flight keeps
648
+ // running and commits. Settling from the cancelled await therefore reported "failed" for
649
+ // a refresh that was about to succeed — releasing the budget, and letting the newly
650
+ // refreshed lineage claim again moments later. So the settlement rides an uncancelled
651
+ // resolution and the caller's cancellation is layered on top of it.
652
+ // A caller that is already gone must not start work. `resolveCodexToken` is called
653
+ // without the caller signal below, which bypasses its own pre-abort guard, so a
654
+ // pre-aborted request would otherwise rotate a credential nobody is waiting for.
655
+ if (options.signal?.aborted) {
656
+ settle({ kind: "failed", error: options.signal.reason });
657
+ throw options.signal.reason;
658
+ }
659
+ const completion = resolveCodexToken(
587
660
  id,
588
661
  { rejectedGeneration: options.rejectedGeneration, rejectedAccessToken: options.rejectedAccessToken },
589
- options.signal,
662
+ // Deliberately no caller signal: the flight is shared and this settlement speaks for
663
+ // the credential, not for whoever happened to be waiting.
664
+ undefined,
665
+ );
666
+ completion.then(
667
+ resolved => settle({
668
+ kind: "resolved",
669
+ provenance: classify(resolved),
670
+ generation: resolved.generation,
671
+ rotated: resolved.accessToken !== options.rejectedAccessToken,
672
+ }),
673
+ error => settle({ kind: "failed", error }),
590
674
  );
675
+ const result = await awaitOwnCancellation(completion, options.signal);
676
+ const provenance = classify(result);
677
+ const rotated = result.accessToken !== options.rejectedAccessToken;
591
678
  return {
592
679
  accessToken: result.accessToken,
593
680
  chatgptAccountId: result.chatgptAccountId,
594
681
  generation: result.generation,
595
- rotated: result.accessToken !== options.rejectedAccessToken,
682
+ rotated,
596
683
  // Only a CAS this call performed itself proves the new credential descends from the
597
684
  // rejected one; anything else is somebody else's replacement and must not be treated
598
685
  // as this request's own lineage.
599
- selfRefreshed: result.selfRefreshed === true,
686
+ selfRefreshed: provenance === "self-refresh",
687
+ provenance,
600
688
  };
601
689
  }
602
690
 
@@ -627,7 +715,15 @@ async function resolveCodexToken(
627
715
  // correct again and refreshing would burn a rotation for nothing.
628
716
  const forcedTargetsStoredCredential = forced !== undefined && !forcedFenceSuperseded(record.generation, forced);
629
717
  if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS && !forcedTargetsStoredCredential) {
630
- return { accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId, generation: record.generation };
718
+ // The freshness shortcut: nothing was refreshed and nothing was adopted. A forced
719
+ // caller reaches it only once its fence was superseded, which is a replacement by
720
+ // definition; an ordinary caller does not read this field.
721
+ return {
722
+ accessToken: cred.accessToken,
723
+ chatgptAccountId: cred.chatgptAccountId,
724
+ generation: record.generation,
725
+ provenance: "external-replacement",
726
+ };
631
727
  }
632
728
 
633
729
  const existing = refreshLocks.get(refreshGrantFingerprint);
@@ -652,6 +748,14 @@ async function resolveCodexToken(
652
748
  accessToken: currentCred.accessToken,
653
749
  chatgptAccountId: currentCred.chatgptAccountId,
654
750
  generation: current.generation,
751
+ // Adopted the stored result of a flight this caller joined: same grant, same
752
+ // lineage. Not a replacement — that distinction is the whole point of #3019.
753
+ //
754
+ // Only `external-replacement` is inherited. The flight's own success is tagged
755
+ // `self-refresh` for the caller that performed the CAS, and copying that here
756
+ // would tell a caller that did no CAS that the credential is its own lineage.
757
+ // Everything this branch adopts is, by definition, a join.
758
+ provenance: refreshed.provenance === "external-replacement" ? "external-replacement" : "joined-lineage",
655
759
  };
656
760
  }
657
761
  }
@@ -687,6 +791,9 @@ async function resolveCodexToken(
687
791
  accessToken: currentCred.accessToken,
688
792
  chatgptAccountId: currentCred.chatgptAccountId,
689
793
  generation: current.generation,
794
+ // `forcedFenceSuperseded` is exactly "somebody else moved this credential past
795
+ // the generation I was holding" — a new lineage, entitled to its own budget.
796
+ provenance: "external-replacement",
690
797
  };
691
798
  }
692
799
  if (
@@ -714,6 +821,7 @@ async function resolveCodexToken(
714
821
  // This joiner performed its own CAS onto its own record, so the resulting
715
822
  // generation is its own lineage even though another caller drove the fetch.
716
823
  selfRefreshed: true,
824
+ provenance: "self-refresh",
717
825
  resolvedGrantFingerprint: refreshGrantFingerprint,
718
826
  };
719
827
  }
@@ -741,7 +849,7 @@ async function resolveCodexToken(
741
849
  * eviction) and the 30s ceiling remain, because those bound the flight itself.
742
850
  */
743
851
  const abort = new AbortController();
744
- const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]);
852
+ const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(CODEX_REFRESH_FLIGHT_CEILING_MS)]);
745
853
  let flight!: RefreshFlight;
746
854
  const fetchPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise<CodexRefreshResult> => {
747
855
  const current = readCodexAccountRecord(id);
@@ -759,6 +867,9 @@ async function resolveCodexToken(
759
867
  credential: lockedCred,
760
868
  // This credential belongs to a DIFFERENT grant than the flight was opened
761
869
  // for. Tagging it keeps a joiner from adopting it as its own.
870
+ // It is also somebody else's credential by definition, so a joiner that ends up
871
+ // adopting it must not charge it to this lineage's budget (#3019).
872
+ provenance: "external-replacement",
762
873
  ...(lockedRefreshGrantFingerprint !== undefined
763
874
  ? { resolvedGrantFingerprint: lockedRefreshGrantFingerprint }
764
875
  : {}),
@@ -777,6 +888,9 @@ async function resolveCodexToken(
777
888
  chatgptAccountId: lockedCred.chatgptAccountId,
778
889
  generation: startGeneration,
779
890
  credential: lockedCred,
891
+ // The stored credential is fresh and no forced fence still targets it: whoever
892
+ // wrote it, it was not this call. A joiner adopting it inherits that provenance.
893
+ provenance: "external-replacement",
780
894
  resolvedGrantFingerprint: refreshGrantFingerprint,
781
895
  };
782
896
  }
@@ -797,6 +911,7 @@ async function resolveCodexToken(
797
911
  credential: sameGrantFreshCredential,
798
912
  resolvedGrantFingerprint: refreshGrantFingerprint,
799
913
  selfRefreshed: true,
914
+ provenance: "self-refresh",
800
915
  };
801
916
  }
802
917
  const res = await fetch(CHATGPT_TOKEN_URL, {
@@ -876,6 +991,7 @@ async function resolveCodexToken(
876
991
  // token — tagging the new grant would make every legitimate joiner look foreign.
877
992
  resolvedGrantFingerprint: refreshGrantFingerprint,
878
993
  selfRefreshed: true,
994
+ provenance: "self-refresh",
879
995
  };
880
996
  });
881
997
  /*
@@ -917,6 +1033,9 @@ async function resolveCodexToken(
917
1033
  // produced this generation, and a forced caller needs that to know whether the new
918
1034
  // credential descends from the one it was holding.
919
1035
  ...(result.selfRefreshed !== undefined ? { selfRefreshed: result.selfRefreshed } : {}),
1036
+ // Provenance rides out with the rest: a joiner that adopts this result needs the
1037
+ // flight's own classification, not a guess made at the adoption site (#3019).
1038
+ ...(result.provenance !== undefined ? { provenance: result.provenance } : {}),
920
1039
  ...(result.resolvedGrantFingerprint !== undefined
921
1040
  ? { resolvedGrantFingerprint: result.resolvedGrantFingerprint }
922
1041
  : {}),
@@ -10,6 +10,7 @@ import {
10
10
  getCodexAccountCredential,
11
11
  getValidCodexToken,
12
12
  isCodexAccountGenerationLive,
13
+ forceRefreshCodexPoolToken,
13
14
  markCodexAccountValidated,
14
15
  readCodexAccountRecord,
15
16
  saveCodexAccountCredential,
@@ -124,6 +125,14 @@ import { tryAcquireNativeMainProfileClaim } from "./native-main-admission";
124
125
  import { withNativeMainSharedClaim } from "./native-main-claim";
125
126
  import { resolveNativeProfileContext } from "./native-profile-store";
126
127
  import { NativeProfileError } from "./native-profile-types";
128
+ import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing";
129
+ import {
130
+ claimQuotaRecovery,
131
+ quotaRecoveryTerminalFor,
132
+ releaseQuotaRecovery,
133
+ settleQuotaRecovery,
134
+ settleQuotaRecoveryTerminal,
135
+ } from "./quota-401-recovery";
127
136
 
128
137
  function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError {
129
138
  return error instanceof NativeProfileError
@@ -766,7 +775,7 @@ async function fetchMainAccountInfoWhileOwned(
766
775
  try {
767
776
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
768
777
  headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
769
- signal: AbortSignal.timeout(8000),
778
+ signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
770
779
  });
771
780
  if (!resp.ok) {
772
781
  const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
@@ -959,6 +968,180 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh
959
968
  }
960
969
  }
961
970
 
971
+
972
+
973
+ /**
974
+ * One refresh-and-replay for a pool account whose WHAM request came back 401 (#3019).
975
+ *
976
+ * The account list used to convert any 401 straight into `needsReauth`, and a bare 401 is
977
+ * exactly what a stale-but-refreshable bearer produces after a plan change — so a healthy
978
+ * credential was thrown away and the operator was told to log in again.
979
+ *
980
+ * Bounded by the recovery store: one attempt per credential lineage. An unbounded retry
981
+ * against an upstream 401 is a self-inflicted credential-stuffing loop, which is why the
982
+ * claim is taken BEFORE the refresh and settled by the flight rather than by this caller.
983
+ */
984
+ async function recoverPoolQuotaFrom401(ctx: {
985
+ accountId: string;
986
+ existing: StoredAccountQuota | null;
987
+ configuredPlan: string | undefined;
988
+ rejectedAccessToken: string;
989
+ rejectedGeneration: number;
990
+ resp: Response;
991
+ onCredentialGeneration?: (generation: number) => void;
992
+ }): Promise<PoolQuotaResult> {
993
+ const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx;
994
+
995
+ // Structured terminal evidence short-circuits everything: the same allowlist and bounded
996
+ // parser the main account uses, because it is the same endpoint answering.
997
+ if (await isTerminalPoolAuthResponse(resp)) {
998
+ // Durable, not just this response: the account list re-polls, and without a recorded
999
+ // mark the next bare 401 finds nothing terminal and reports the account healthy.
1000
+ //
1001
+ // Scoped to the generation this evidence is ABOUT. An account-wide mark would outlive
1002
+ // the credential it condemned, so a late terminal response arriving after the operator
1003
+ // re-authenticated would quarantine the replacement.
1004
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
1005
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
1006
+ }
1007
+
1008
+ const claim = claimQuotaRecovery(accountId, rejectedGeneration);
1009
+ if (!claim.granted) {
1010
+ // A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the
1011
+ // budget being used would make the next bare 401 report a dead credential as healthy.
1012
+ if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) {
1013
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
1014
+ }
1015
+ // Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a
1016
+ // transient failure is backing off. Report transient and let the next poll try —
1017
+ // quarantining here would undo the whole point of the budget.
1018
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
1019
+ }
1020
+
1021
+ let refreshed: Awaited<ReturnType<typeof forceRefreshCodexPoolToken>>;
1022
+ try {
1023
+ refreshed = await forceRefreshCodexPoolToken(accountId, {
1024
+ rejectedGeneration,
1025
+ rejectedAccessToken,
1026
+ // Settlement rides the flight, not this await: a cancelled caller would otherwise
1027
+ // leave the claim to expire while the shared refresh commits, and the already
1028
+ // refreshed lineage would get a second attempt.
1029
+ onSettled: outcome => {
1030
+ if (outcome.kind === "resolved") {
1031
+ settleQuotaRecovery(accountId, claim.claimId, outcome);
1032
+ } else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) {
1033
+ // A revoked or expired grant does not become valid on the next poll. Releasing it
1034
+ // into backoff would let the following bare 401 find a non-terminal record and
1035
+ // report a dead credential as healthy.
1036
+ settleQuotaRecoveryTerminal(accountId, claim.claimId);
1037
+ } else {
1038
+ releaseQuotaRecovery(accountId, claim.claimId, QUOTA_RECOVERY_BACKOFF_MS);
1039
+ }
1040
+ },
1041
+ });
1042
+ } catch (e) {
1043
+ // A refresh that failed terminally is the one case where the credential really is gone.
1044
+ // Everything else is unknown, and unknown is not proof.
1045
+ if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
1046
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
1047
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
1048
+ }
1049
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
1050
+ }
1051
+
1052
+ // A byte-identical access token means replaying earns the same 401. Report transient
1053
+ // rather than burning the replay; the fence already moved to the returned generation.
1054
+ if (!refreshed.rotated) {
1055
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
1056
+ }
1057
+
1058
+ // The flight may have moved the generation while this request was in the air. Tell the
1059
+ // coalescing layer where the credential actually is, or a late caller joins on a stale
1060
+ // generation and opens a redundant flight.
1061
+ ctx.onCredentialGeneration?.(refreshed.generation);
1062
+
1063
+ const writerGeneration = captureConfigGeneration();
1064
+ const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
1065
+ headers: {
1066
+ Authorization: `Bearer ${refreshed.accessToken}`,
1067
+ "ChatGPT-Account-Id": refreshed.chatgptAccountId,
1068
+ },
1069
+ signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
1070
+ });
1071
+ if (!replay.ok) {
1072
+ if (replay.status === 401 && await isTerminalPoolAuthResponse(replay)) {
1073
+ // The refresh already settled this claim non-terminally, so the record alone would
1074
+ // let the next poll call a dead credential healthy. The evidence is about the
1075
+ // REFRESHED credential, which is what the replay used.
1076
+ markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
1077
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
1078
+ }
1079
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
1080
+ }
1081
+ return await commitPoolQuotaResponse(replay, {
1082
+ accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration,
1083
+ });
1084
+ }
1085
+
1086
+ /** Backoff after a refresh failure that proved nothing about the credential. */
1087
+ const QUOTA_RECOVERY_BACKOFF_MS = 60_000;
1088
+
1089
+ /** Same allowlist and bounded parser as the main account: it is the same endpoint. */
1090
+ async function isTerminalPoolAuthResponse(resp: Response): Promise<boolean> {
1091
+ // Consume the original rather than a clone. `resp.clone()` tees the body, and the
1092
+ // bounded parser's timeout cancels only its own reader — the unread original branch
1093
+ // keeps buffering. Nothing needs this response afterwards, so there is nothing to tee.
1094
+ const code = await readMainAuthErrorCode(resp);
1095
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
1096
+ }
1097
+
1098
+ /** A revoked or expired grant is terminal; an unknown or transport failure is not. */
1099
+ function isTerminalRefreshError(error: TokenRefreshError): boolean {
1100
+ // Read the discriminator, not the message. TokenRefreshError carries `reason`, and
1101
+ // matching on human text would let a durable quarantine decision change the next time
1102
+ // somebody rewords an error string.
1103
+ return error.reason === "revoked" || error.reason === "expired";
1104
+ }
1105
+
1106
+ /** Parse and store a successful WHAM response. Shared by the first attempt and the replay. */
1107
+ async function commitPoolQuotaResponse(
1108
+ resp: Response,
1109
+ ctx: {
1110
+ accountId: string;
1111
+ existing: StoredAccountQuota | null;
1112
+ configuredPlan: string | undefined;
1113
+ generation: number;
1114
+ writerGeneration: number;
1115
+ },
1116
+ ): Promise<PoolQuotaResult> {
1117
+ const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx;
1118
+ const data = (await resp.json()) as WhamUsageResponse;
1119
+ const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
1120
+ const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
1121
+ const freshResetCredits = quota?.resetCredits;
1122
+ if (!quota) {
1123
+ return {
1124
+ quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
1125
+ needsReauth: false,
1126
+ credentialGeneration: generation,
1127
+ ...(freshPlan !== undefined ? { freshPlan, freshCredentialGeneration: generation } : {}),
1128
+ };
1129
+ }
1130
+ if (!isCodexAccountGenerationLive(accountId, generation)) {
1131
+ return { quota: null, needsReauth: false, credentialGeneration: generation };
1132
+ }
1133
+ setAccountQuotaFromParsed(accountId, quota, writerGeneration);
1134
+ return {
1135
+ quota: getAccountQuota(accountId),
1136
+ needsReauth: false,
1137
+ credentialGeneration: generation,
1138
+ freshQuota: quota,
1139
+ freshCredentialGeneration: generation,
1140
+ ...(freshPlan !== undefined ? { freshPlan } : {}),
1141
+ ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
1142
+ };
1143
+ }
1144
+
962
1145
  async function fetchFreshPoolAccountQuota(
963
1146
  accountId: string,
964
1147
  existing: StoredAccountQuota | null,
@@ -976,39 +1159,25 @@ async function fetchFreshPoolAccountQuota(
976
1159
  signal: AbortSignal.timeout(8000),
977
1160
  });
978
1161
  if (!resp.ok) {
979
- return {
980
- quota: existing ?? null,
981
- needsReauth: resp.status === 401,
982
- credentialGeneration: generation,
983
- };
984
- }
985
- const data = (await resp.json()) as WhamUsageResponse;
986
- const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
987
- const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
988
- const freshResetCredits = quota?.resetCredits;
989
- if (!quota) {
990
- return {
991
- quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
992
- needsReauth: false,
993
- credentialGeneration: generation,
994
- ...(freshPlan !== undefined
995
- ? { freshPlan, freshCredentialGeneration: generation }
996
- : {}),
997
- };
998
- }
999
- if (!isCodexAccountGenerationLive(accountId, generation)) {
1000
- return { quota: null, needsReauth: false, credentialGeneration: generation };
1162
+ if (resp.status !== 401) {
1163
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: generation };
1164
+ }
1165
+ // A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so
1166
+ // quarantining on it tells the operator to re-authenticate an account that was fine
1167
+ // (#3019). Refresh once, replay once, and only then decide.
1168
+ return await recoverPoolQuotaFrom401({
1169
+ accountId,
1170
+ existing,
1171
+ configuredPlan,
1172
+ rejectedAccessToken: accessToken,
1173
+ rejectedGeneration: generation,
1174
+ resp,
1175
+ onCredentialGeneration,
1176
+ });
1001
1177
  }
1002
- setAccountQuotaFromParsed(accountId, quota, writerGeneration);
1003
- return {
1004
- quota: getAccountQuota(accountId),
1005
- needsReauth: false,
1006
- credentialGeneration: generation,
1007
- freshQuota: quota,
1008
- freshCredentialGeneration: generation,
1009
- ...(freshPlan !== undefined ? { freshPlan } : {}),
1010
- ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
1011
- };
1178
+ return await commitPoolQuotaResponse(resp, {
1179
+ accountId, existing, configuredPlan, generation, writerGeneration,
1180
+ });
1012
1181
  } catch (e) {
1013
1182
  if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
1014
1183
  || e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
@@ -90,9 +90,9 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth
90
90
  : inputs.routingKind === "custom-local" || inputs.routingKind === "unknown"
91
91
  ? COMMANDS.restoreNative
92
92
  : inputs.serviceSupported
93
- // An already-registered service is refreshed in place: `repair` rewrites its assets
94
- // and restarts it without re-registering, so it needs no elevation on Windows and
95
- // cannot switch a WinSW install to Task Scheduler the way `install` would. Only a
93
+ // An already-registered service is refreshed in place: `repair` reuses healthy Windows
94
+ // scheduler definitions, while stale ones may be re-registered and require elevation.
95
+ // It still cannot switch a WinSW install to Task Scheduler the way `install` would. Only a
96
96
  // genuinely absent (or conflicting, which needs uninstall-then-install) service
97
97
  // gets the registering command.
98
98
  ? (inputs.serviceInstalled && !inputs.serviceConflict ? COMMANDS.repairService : COMMANDS.installService)