@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import {
3
3
  atomicWriteFile,
4
+ deleteConfigTopLevelKey,
4
5
  getConfigPath,
5
6
  saveConfigPreservingClaudeCode,
6
7
  withConfigMutationLockSync,
@@ -12,9 +13,10 @@ import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
12
13
  import { clearAccountQuota } from "./quota";
13
14
  import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
14
15
  import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
15
- import { clearMainAccountCredentialPresence, clearMainAccountInfoCache } from "./main-account-cache";
16
+ import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaIdentity } from "./main-account-cache";
16
17
  import { forgetCodexAccountPause } from "./account-pause";
17
18
  import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
19
+ import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state";
18
20
  import { codexAccountNamespaceEntries, codexAccountPickerEnabled } from "./account-namespaces";
19
21
  import type { OcxConfig } from "../types";
20
22
 
@@ -64,9 +66,13 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
64
66
  if (currentAccountId === null) return false;
65
67
  const previousAccountId = observedMainChatgptAccountId;
66
68
  observedMainChatgptAccountId = currentAccountId;
67
- if (previousAccountId === undefined || previousAccountId === currentAccountId) return false;
69
+ if (previousAccountId === undefined || previousAccountId === currentAccountId) {
70
+ observeMainQuotaIdentity(currentAccountId);
71
+ return false;
72
+ }
68
73
 
69
74
  purgeMainCodexAccountRuntimeState();
75
+ observeMainQuotaIdentity(currentAccountId);
70
76
  return true;
71
77
  }
72
78
 
@@ -79,11 +85,15 @@ export function applyConfirmedMainCodexAccountTransition(
79
85
  toAccountId: string,
80
86
  ): boolean {
81
87
  if (!fromAccountId || !toAccountId || fromAccountId === toAccountId) {
82
- if (toAccountId) observedMainChatgptAccountId = toAccountId;
88
+ if (toAccountId) {
89
+ observedMainChatgptAccountId = toAccountId;
90
+ observeMainQuotaIdentity(toAccountId);
91
+ }
83
92
  return false;
84
93
  }
85
94
  observedMainChatgptAccountId = toAccountId;
86
95
  purgeMainCodexAccountRuntimeState();
96
+ observeMainQuotaIdentity(toAccountId);
87
97
  return true;
88
98
  }
89
99
 
@@ -135,6 +145,12 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
135
145
  .filter(account => account.isMain || account.id !== accountId);
136
146
  forgetCodexAccountPause(runtimeConfig, accountId);
137
147
  forgetCodexAccountPriority(runtimeConfig, accountId);
148
+ if (runtimeConfig.codexQuotaAutoRefresh?.[accountId]) {
149
+ const retained = { ...runtimeConfig.codexQuotaAutoRefresh };
150
+ delete retained[accountId];
151
+ if (Object.keys(retained).length > 0) runtimeConfig.codexQuotaAutoRefresh = retained;
152
+ else deleteConfigTopLevelKey(runtimeConfig, "codexQuotaAutoRefresh");
153
+ }
138
154
  clearCodexAccountPin(runtimeConfig, accountId);
139
155
  if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;
140
156
 
@@ -167,6 +183,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
167
183
  return hadVisiblePickerBinding;
168
184
  });
169
185
 
186
+ forgetCodexQuotaAutoRefreshAccount(accountId);
170
187
  if (cleanupFailed) throw new CodexAccountDeleteCleanupError();
171
188
  return pickerVisibilityChanged;
172
189
  }
@@ -9,6 +9,7 @@ import {
9
9
  import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id";
10
10
  import type { OcxConfig } from "../types";
11
11
  import { isNativeMainTrafficBlocked } from "./native-profile-startup";
12
+ import { isMainAccountHardLocked } from "./main-account-hard-lock";
12
13
 
13
14
  export interface CodexAccountUsabilityOptions {
14
15
  /** Route using cached runtime state only; the caller must reject selected main before auth. */
@@ -26,6 +27,7 @@ export function isCodexAccountUsable(
26
27
  ): boolean {
27
28
  if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false;
28
29
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
30
+ if (isMainAccountHardLocked(config)) return false;
29
31
  // Startup recovery owns the physical auth/vault boundary. Never parse or select
30
32
  // native __main__ while an encrypted switch journal is pending or inconclusive.
31
33
  if (!options.nativeMainSelectionOnly && isNativeMainTrafficBlocked()) return false;
@@ -69,6 +69,7 @@ import {
69
69
  isCompleteCodexQuotaRecoverySnapshot,
70
70
  isCodexQuotaExhausted,
71
71
  listAccountQuotas,
72
+ parseMainPolicyUsageQuota,
72
73
  parseUsageQuota,
73
74
  setAccountQuotaFromParsed,
74
75
  updateAccountQuota,
@@ -84,7 +85,10 @@ export {
84
85
  updateAccountQuota,
85
86
  } from "./quota";
86
87
  import { extractAccountId } from "../oauth/chatgpt";
87
- import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
88
+ import {
89
+ getMainAccountPlan, getValidMainAccountToken, isMainAccountTokenVerifiablyLive,
90
+ MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan,
91
+ } from "./main-account";
88
92
  import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper";
89
93
  import { reconcileLiveStateStores } from "../lib/state-store-registrations";
90
94
  import {
@@ -92,12 +96,17 @@ import {
92
96
  clearMainAccountInfoCache,
93
97
  getMainAccountCredentialPresence,
94
98
  getMainAccountInfoCache,
99
+ getMainQuotaCredentialGeneration,
95
100
  isMainAccountIdentityGenerationLive,
101
+ matchesMainQuotaCredential,
102
+ observeMainQuotaCredential,
96
103
  setMainAccountCredentialPresence,
97
104
  setMainAccountInfoCache,
98
105
  type MainAccountInfo,
99
106
  } from "./main-account-cache";
100
107
  export { clearMainAccountInfoCache } from "./main-account-cache";
108
+ import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock";
109
+ import { observeMainReserveRevocation } from "./reserve-availability";
101
110
  import { maskEmail } from "../lib/privacy";
102
111
  import { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
103
112
  export { maskEmail } from "../lib/privacy";
@@ -120,6 +129,12 @@ import {
120
129
  isValidCodexAccountId,
121
130
  } from "./account-id";
122
131
  import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
132
+ import {
133
+ markManualResetCreditOperationAmbiguous,
134
+ openManualResetCreditOperation,
135
+ settleManualResetCreditOperation,
136
+ } from "./reset-credit-operation-ledger";
137
+ import { isCodexResetCreditOperationId } from "./reset-credit-recovery";
123
138
  import { ResourceAdmissionError, type AdmissionLease } from "../lib/admission";
124
139
  import { tryAcquireNativeMainProfileClaim } from "./native-main-admission";
125
140
  import { withNativeMainSharedClaim } from "./native-main-claim";
@@ -812,6 +827,7 @@ async function fetchMainAccountInfoAttempt(
812
827
  retriesRemaining: number,
813
828
  existingNativeMainLease?: AdmissionLease,
814
829
  nativeMainSharedClaimHeld = false,
830
+ explicitRefresh: boolean = forceRefresh,
815
831
  ): Promise<MainAccountInfoFetchResult> {
816
832
  const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim();
817
833
  if (!nativeMainLease) {
@@ -824,7 +840,7 @@ async function fetchMainAccountInfoAttempt(
824
840
  }
825
841
  try {
826
842
  const operation = async () => ({
827
- ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease),
843
+ ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh),
828
844
  identityGeneration: captureMainAccountIdentityGeneration(),
829
845
  });
830
846
  if (nativeMainSharedClaimHeld) return await operation();
@@ -878,6 +894,12 @@ async function fetchMainAccountInfoWhileOwned(
878
894
  if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
879
895
  return { info: cached, credentialChecked: true, hasCredential: true };
880
896
  }
897
+ // Bind quota to the owned credential and the account actually selected by WHAM's header.
898
+ // A conflicting legacy token/account tuple is not evidence for the new policy.
899
+ const mainQuotaWriter = requestAccountId === tokens.account_id
900
+ ? observeMainQuotaCredential(tokens.access_token, tokens.account_id)
901
+ : undefined;
902
+ const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration();
881
903
  try {
882
904
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
883
905
  headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
@@ -896,8 +918,16 @@ async function fetchMainAccountInfoWhileOwned(
896
918
  const data = (await resp.json()) as WhamUsageResponse;
897
919
  const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
898
920
  if (retried) return retried;
921
+ // A delayed response from a replaced bearer cannot revoke a newer Reserve grant,
922
+ // even in the same workspace or after an A→B→A credential transition.
923
+ if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
924
+ && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) {
925
+ observeMainReserveRevocation(data, mainQuotaWriter);
926
+ }
899
927
  const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
900
- const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
928
+ const usage = { ...data, ...(plan ? { plan_type: plan } : {}) };
929
+ const quota = parseUsageQuota(usage);
930
+ const policyQuota = parseMainPolicyUsageQuota(usage);
901
931
  const freshResetCredits = quota?.resetCredits;
902
932
  // Tag the count with the identity it was read from, so a later response that omits the
903
933
  // summary can restore the badge without ever crossing an account boundary.
@@ -923,7 +953,7 @@ async function fetchMainAccountInfoWhileOwned(
923
953
  // score and auto-switch the main account exactly like a pool account (Option A).
924
954
  setMainAccountPlan(result.plan);
925
955
  if (result.quota) {
926
- setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration);
956
+ setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota);
927
957
  }
928
958
  return {
929
959
  info: result,
@@ -1031,6 +1061,7 @@ export interface CodexAuthAccountDto {
1031
1061
  healthSummary: string;
1032
1062
  healthAction?: string;
1033
1063
  quotaProbeSkipped?: true;
1064
+ mainAccountHardLock?: MainAccountHardLockStatus;
1034
1065
  }
1035
1066
 
1036
1067
  interface FreshPoolPlanUpdate {
@@ -1419,10 +1450,9 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
1419
1450
  }
1420
1451
  try {
1421
1452
  const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan);
1422
- // Defence in depth: `spark` is already excluded at the claim site, since generic WHAM
1423
- // cannot prove a spark recovery. Keep the settle-side guard so a future claim change
1424
- // cannot silently start clearing spark on generic evidence.
1425
- const recovered = claim.scope !== "spark"
1453
+ // Defence in depth: independent scopes are already excluded at the claim site.
1454
+ // Generic WHAM must never clear Spark or Reserve even if claim selection changes.
1455
+ const recovered = (claim.scope === undefined || claim.scope === "shared")
1426
1456
  && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
1427
1457
  settleCodexQuotaRecoveryProbe(claim, recovered, {
1428
1458
  credentialGeneration: result.freshCredentialGeneration,
@@ -1437,10 +1467,50 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
1437
1467
  return cooldownRecoveryInFlight;
1438
1468
  }
1439
1469
 
1470
+ let mainHardLockRecoveryInFlight: Promise<void> | null = null;
1471
+
1472
+ /** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */
1473
+ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise<void> {
1474
+ if (mainHardLockRecoveryInFlight) return mainHardLockRecoveryInFlight;
1475
+ if (getMainAccountHardLockStatus(config).state !== "blocked"
1476
+ || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
1477
+ const lease = tryAcquireNativeMainProfileClaim();
1478
+ if (!lease) return;
1479
+ mainHardLockRecoveryInFlight = (async () => {
1480
+ reconcileMainCodexAccountRuntimeState();
1481
+ if (getMainAccountHardLockStatus(config).state !== "blocked"
1482
+ || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
1483
+ const identityGeneration = captureMainAccountIdentityGeneration();
1484
+ const writerGeneration = captureConfigGeneration();
1485
+ try {
1486
+ // Refresh can require an exclusive credential claim: never hold WHAM's shared
1487
+ // claim while obtaining a valid token. The runtime lease spans both operations.
1488
+ if (!await getValidMainAccountToken({ preserveReauth: true })) return;
1489
+ } catch (error) {
1490
+ if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
1491
+ && isMainAccountIdentityGenerationLive(identityGeneration)) {
1492
+ markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
1493
+ }
1494
+ return;
1495
+ }
1496
+ if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
1497
+ await fetchMainAccountInfoAttempt(true, 1, lease, false, false);
1498
+ })().catch(() => {
1499
+ // Best-effort background metadata read; no cooldown/pause or policy clearing on failure.
1500
+ }).finally(() => {
1501
+ lease.release();
1502
+ mainHardLockRecoveryInFlight = null;
1503
+ });
1504
+ return mainHardLockRecoveryInFlight;
1505
+ }
1506
+
1440
1507
  export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void {
1441
1508
  registerStateSweepAfterTick({
1442
1509
  name: "codex-cooldown-recovery",
1443
- afterTick: () => { void runCodexCooldownRecoveryProbes(config); },
1510
+ afterTick: () => {
1511
+ void runCodexCooldownRecoveryProbes(config);
1512
+ void runMainAccountHardLockRecovery(config);
1513
+ },
1444
1514
  });
1445
1515
  }
1446
1516
 
@@ -1702,6 +1772,7 @@ export async function listCodexAuthAccountsSnapshot(
1702
1772
  logLabel: "main",
1703
1773
  isMain: true,
1704
1774
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1775
+ mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig),
1705
1776
  priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1706
1777
  hasCredential: hasMainCredential,
1707
1778
  needsReauth: mainNeedsReauth,
@@ -2168,31 +2239,107 @@ export async function handleCodexAuthAPI(
2168
2239
  }
2169
2240
 
2170
2241
  if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
2171
- const body = (await req.json().catch(() => ({}))) as { accountId?: string };
2242
+ const body = (await req.json().catch(() => ({}))) as {
2243
+ accountId?: string;
2244
+ operationId?: unknown;
2245
+ };
2172
2246
  if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
2173
2247
  const accountId = body.accountId;
2248
+ // Optional caller-owned idempotency identity (#3375 axis D). Absent => legacy
2249
+ // behavior: a fresh random redeem_request_id and no durable ledger row.
2250
+ // The ledger throws TypeError on a malformed id, so the format check has to
2251
+ // happen here rather than at the call site, or it surfaces as a 500.
2252
+ const hasOperationId = body.operationId !== undefined;
2253
+ if (hasOperationId && !isCodexResetCreditOperationId(body.operationId)) {
2254
+ return jsonResponse({ error: "Invalid operationId format" }, 400);
2255
+ }
2256
+ const requestedOperationId = hasOperationId ? body.operationId as string : undefined;
2174
2257
 
2175
2258
  try {
2176
2259
  const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
2177
- const idempotencyKey = crypto.randomUUID();
2178
- const resp = await fetch(
2179
- "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
2180
- {
2181
- method: "POST",
2182
- headers: {
2183
- Authorization: `Bearer ${auth.accessToken}`,
2184
- "ChatGPT-Account-Id": auth.chatgptAccountId,
2185
- "Content-Type": "application/json",
2260
+ // The ledger keys manual operations by the *physical* ChatGPT account, which is
2261
+ // only known after the auth wrapper resolves credentials. Open here, not earlier.
2262
+ const identity = requestedOperationId === undefined
2263
+ ? undefined
2264
+ : {
2265
+ accountId,
2266
+ chatgptAccountId: auth.chatgptAccountId,
2267
+ operationId: requestedOperationId,
2268
+ } as const;
2269
+ let idempotencyKey: string;
2270
+ if (identity) {
2271
+ const opened = openManualResetCreditOperation(identity);
2272
+ if (opened.kind === "terminal") {
2273
+ // Durably settled already: replay the recorded outcome instead of
2274
+ // trusting upstream idempotency for an irreversible spend. No
2275
+ // `remaining` — that field is only reported from a freshly parsed
2276
+ // available_count, and a replay has none.
2277
+ return jsonResponse({ code: opened.code, replayed: true });
2278
+ }
2279
+ if (opened.kind === "identity-mismatch") {
2280
+ return jsonResponse({
2281
+ error: "operation_id_owned_by_another_account",
2282
+ code: "identity_mismatch",
2283
+ }, 409);
2284
+ }
2285
+ if (opened.kind !== "execute") {
2286
+ // capacity | unavailable -> fail closed. Falling back to a random id
2287
+ // would silently reintroduce the double-spend this identity prevents.
2288
+ const response = jsonResponse({
2289
+ error: opened.kind === "capacity"
2290
+ ? "reset_credit_ledger_capacity"
2291
+ : "reset_credit_ledger_unavailable",
2292
+ code: opened.kind,
2293
+ }, 503);
2294
+ response.headers.set("Retry-After", "1");
2295
+ return response;
2296
+ }
2297
+ // Canonical id, which an alias join may map to an earlier caller id.
2298
+ idempotencyKey = opened.operationId;
2299
+ } else {
2300
+ idempotencyKey = crypto.randomUUID();
2301
+ }
2302
+ let resp: Response;
2303
+ try {
2304
+ resp = await fetch(
2305
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
2306
+ {
2307
+ method: "POST",
2308
+ headers: {
2309
+ Authorization: `Bearer ${auth.accessToken}`,
2310
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
2311
+ "Content-Type": "application/json",
2312
+ },
2313
+ body: JSON.stringify({ redeem_request_id: idempotencyKey }),
2314
+ signal: AbortSignal.timeout(10_000),
2186
2315
  },
2187
- body: JSON.stringify({ redeem_request_id: idempotencyKey }),
2188
- signal: AbortSignal.timeout(10_000),
2189
- },
2190
- );
2316
+ );
2317
+ } catch (error) {
2318
+ // Dispatch outcome unknown: the credit may or may not have been spent.
2319
+ // Mark ambiguous so a replay of this same id is never treated as new.
2320
+ if (identity) markManualResetCreditOperationAmbiguous(identity);
2321
+ throw error;
2322
+ }
2191
2323
  if (!resp.ok) {
2192
2324
  await resp.body?.cancel().catch(() => {});
2325
+ if (identity) markManualResetCreditOperationAmbiguous(identity);
2193
2326
  return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
2194
2327
  }
2195
2328
  const result = safeResetCreditConsumeDto(await resp.json());
2329
+ if (identity) {
2330
+ // Narrow explicitly rather than casting: `safeResetCreditConsumeDto`
2331
+ // normalizes anything unrecognized to "unknown", and settling that
2332
+ // would come back as a mismatch and leave the row pending anyway.
2333
+ // Settlement failure never downgrades the user-visible outcome: the
2334
+ // spend already happened upstream, and reporting failure would invite
2335
+ // a manual retry -- the exact double-spend this unit removes.
2336
+ if (result.code === "reset" || result.code === "already_redeemed"
2337
+ || result.code === "nothing_to_reset" || result.code === "no_credit") {
2338
+ settleManualResetCreditOperation(identity, result.code);
2339
+ } else {
2340
+ markManualResetCreditOperationAmbiguous(identity);
2341
+ }
2342
+ }
2196
2343
  // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
2197
2344
  // and return remaining only when that refresh freshly parsed available_count.
2198
2345
  // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).