@bitkyc08/opencodex 2.41.0 → 2.43.0-preview.20260906

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 (260) 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-VGeQEZ_v.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 +54 -24
  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/identity.ts +8 -2
  26. package/src/adapters/kiro-calibration.ts +181 -0
  27. package/src/adapters/kiro.ts +135 -3
  28. package/src/adapters/openai-responses.ts +259 -29
  29. package/src/adapters/responses-code-mode.ts +59 -0
  30. package/src/adapters/tool-catalog-nudge.ts +1 -1
  31. package/src/adapters/xai-schema-analysis.ts +86 -0
  32. package/src/adapters/xai-tool-schema.ts +2 -87
  33. package/src/adapters/xai-web-search.ts +1 -1
  34. package/src/bridge.ts +47 -13
  35. package/src/chat/inbound.ts +11 -3
  36. package/src/claude/inbound-content-options.ts +60 -0
  37. package/src/claude/inbound-model-options.ts +142 -0
  38. package/src/claude/inbound-records.ts +7 -0
  39. package/src/claude/inbound.ts +10 -202
  40. package/src/claude/model-info.ts +45 -0
  41. package/src/cli/account-auth.ts +49 -9
  42. package/src/cli/account-extended.ts +7 -1
  43. package/src/cli/capabilities.ts +15 -4
  44. package/src/cli/claude.ts +232 -39
  45. package/src/cli/config-command.ts +9 -1
  46. package/src/cli/dispatch.ts +5 -1
  47. package/src/cli/doctor.ts +10 -0
  48. package/src/cli/effort.ts +372 -0
  49. package/src/cli/export-command.ts +3 -9
  50. package/src/cli/help.ts +1 -0
  51. package/src/cli/index.ts +13 -0
  52. package/src/cli/init.ts +4 -0
  53. package/src/cli/model-selection-guidance.ts +30 -0
  54. package/src/cli/models-runtime.ts +3 -2
  55. package/src/cli/models.ts +8 -3
  56. package/src/cli/observe.ts +3 -1
  57. package/src/cli/opencode.ts +4 -1
  58. package/src/cli/provider-runtime.ts +65 -0
  59. package/src/cli/provider.ts +8 -0
  60. package/src/cli/registry.ts +16 -2
  61. package/src/cli/runtime-api.ts +3 -1
  62. package/src/cli/star-prompt.ts +22 -6
  63. package/src/cli/status-probes.ts +168 -0
  64. package/src/cli/status.ts +5 -168
  65. package/src/clients/config-export/constants.ts +69 -0
  66. package/src/clients/config-export/contracts.ts +154 -0
  67. package/src/clients/config-export/dsh.ts +132 -0
  68. package/src/clients/config-export/fast-models.ts +29 -0
  69. package/src/clients/config-export/mcode.ts +83 -0
  70. package/src/clients/config-export/model-metadata.ts +108 -0
  71. package/src/clients/config-export/omp.ts +104 -0
  72. package/src/clients/config-export/zcode.ts +92 -0
  73. package/src/clients/config-export.ts +18 -710
  74. package/src/codex/account-lifecycle.ts +20 -3
  75. package/src/codex/account-usability.ts +2 -0
  76. package/src/codex/auth-api.ts +272 -32
  77. package/src/codex/auth-context.ts +328 -24
  78. package/src/codex/catalog/effort.ts +44 -5
  79. package/src/codex/catalog/metadata.ts +149 -14
  80. package/src/codex/catalog/native-models.ts +116 -4
  81. package/src/codex/catalog/parsing.ts +122 -8
  82. package/src/codex/catalog/provider-fetch.ts +154 -23
  83. package/src/codex/catalog/reserve.ts +52 -0
  84. package/src/codex/catalog/sync.ts +89 -16
  85. package/src/codex/catalog.ts +1 -1
  86. package/src/codex/convergence-types.ts +1 -0
  87. package/src/codex/convergence.ts +2 -0
  88. package/src/codex/data/upstream-models.json +169 -0
  89. package/src/codex/forward-transport-headers.ts +25 -0
  90. package/src/codex/inject.ts +99 -34
  91. package/src/codex/injected-marker.ts +30 -4
  92. package/src/codex/journal.ts +14 -0
  93. package/src/codex/legacy-config-keys.ts +68 -0
  94. package/src/codex/log-guard/inspect-schema.ts +137 -0
  95. package/src/codex/log-guard/inspect.ts +2 -134
  96. package/src/codex/loopback-target.ts +54 -0
  97. package/src/codex/main-account-cache.ts +63 -1
  98. package/src/codex/main-account-hard-lock.ts +52 -0
  99. package/src/codex/main-account.ts +3 -1
  100. package/src/codex/management-convergence.ts +3 -0
  101. package/src/codex/model-entitlements.ts +54 -4
  102. package/src/codex/project-config-warnings.ts +92 -2
  103. package/src/codex/prompt-layers/encoding.ts +80 -0
  104. package/src/codex/prompt-layers/paths.ts +54 -0
  105. package/src/codex/prompt-layers/revision.ts +55 -0
  106. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  107. package/src/codex/prompt-layers/toml-read.ts +181 -0
  108. package/src/codex/prompt-layers.ts +14 -520
  109. package/src/codex/quota-auto-refresh-state.ts +16 -0
  110. package/src/codex/quota-auto-refresh.ts +219 -0
  111. package/src/codex/quota-types.ts +51 -0
  112. package/src/codex/quota.ts +252 -93
  113. package/src/codex/reserve-availability.ts +177 -0
  114. package/src/codex/routing.ts +28 -9
  115. package/src/codex/shim.ts +53 -11
  116. package/src/codex/subagent-model-fallback.ts +23 -3
  117. package/src/combos/failover.ts +125 -7
  118. package/src/combos/identifiers.ts +89 -0
  119. package/src/combos/index.ts +4 -0
  120. package/src/combos/resolve.ts +80 -9
  121. package/src/combos/types.ts +20 -93
  122. package/src/config/subagent-models.ts +24 -0
  123. package/src/config.ts +156 -13
  124. package/src/generated/compatibility-version.json +474 -178
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/integrations/journal.ts +65 -4
  127. package/src/integrations/store.ts +5 -0
  128. package/src/lab/events/limits.ts +4 -0
  129. package/src/lib/destination-policy.ts +31 -2
  130. package/src/lib/errors.ts +39 -0
  131. package/src/lib/provider-outbound.ts +69 -3
  132. package/src/lib/proxy-env.ts +22 -0
  133. package/src/lib/redact-folding.ts +176 -0
  134. package/src/lib/redact.ts +2 -175
  135. package/src/lib/state-store-sweeper.ts +20 -6
  136. package/src/lib/token-estimate.ts +94 -27
  137. package/src/lib/windows-user-principal.ts +53 -5
  138. package/src/oauth/account-quota-rank.ts +40 -1
  139. package/src/oauth/anthropic-routing.ts +99 -3
  140. package/src/oauth/chatgpt-device.ts +187 -0
  141. package/src/oauth/chatgpt.ts +31 -4
  142. package/src/oauth/generic-account-failover.ts +36 -13
  143. package/src/oauth/index.ts +140 -29
  144. package/src/oauth/log.ts +3 -0
  145. package/src/oauth/login-cli.ts +5 -0
  146. package/src/oauth/meta-muse.ts +117 -15
  147. package/src/oauth/pool-settings-capability.ts +15 -4
  148. package/src/providers/api-keys.ts +8 -10
  149. package/src/providers/default-aliases.ts +39 -0
  150. package/src/providers/derive.ts +10 -2
  151. package/src/providers/fastwire.ts +36 -7
  152. package/src/providers/initial-model-selection-runtime.ts +90 -0
  153. package/src/providers/initial-model-selection.ts +120 -0
  154. package/src/providers/key-failover.ts +134 -54
  155. package/src/providers/key-store.ts +11 -1
  156. package/src/providers/label.ts +1 -1
  157. package/src/providers/model-discovery.ts +76 -0
  158. package/src/providers/model-rename-startup.ts +72 -8
  159. package/src/providers/muse-subscription-usage.ts +95 -0
  160. package/src/providers/openai-sidecar.ts +17 -5
  161. package/src/providers/openai-tiers-destination.ts +102 -0
  162. package/src/providers/openai-tiers.ts +2 -99
  163. package/src/providers/opencode-go-transport.ts +41 -0
  164. package/src/providers/quota-key-accounts.ts +141 -0
  165. package/src/providers/quota-types.ts +9 -0
  166. package/src/providers/quota.ts +625 -98
  167. package/src/providers/registry.ts +60 -17
  168. package/src/providers/xai-responses-opt-in.ts +31 -5
  169. package/src/quota/reset-activation.ts +81 -0
  170. package/src/quota/reset-detector.ts +305 -0
  171. package/src/quota/reset-notify-config.ts +162 -0
  172. package/src/quota/reset-observer.ts +125 -0
  173. package/src/quota/reset-poller.ts +160 -0
  174. package/src/quota/reset-seen-store.ts +385 -0
  175. package/src/quota/reset-sinks.ts +199 -0
  176. package/src/quota/window-mapping.ts +106 -0
  177. package/src/responses/apply-patch-envelope.ts +46 -0
  178. package/src/responses/code-mode-helper-compat.ts +39 -1
  179. package/src/responses/custom-tool-compat.ts +10 -4
  180. package/src/responses/hosted-tool-policy.ts +12 -4
  181. package/src/responses/parser-content.ts +133 -0
  182. package/src/responses/parser-text-format.ts +24 -0
  183. package/src/responses/parser-tools.ts +188 -0
  184. package/src/responses/parser.ts +3 -326
  185. package/src/responses/state.ts +124 -28
  186. package/src/router.ts +48 -13
  187. package/src/routing/analytics.ts +1 -0
  188. package/src/routing/capability.ts +17 -4
  189. package/src/server/auth-cors.ts +7 -1
  190. package/src/server/background-lifecycle.ts +23 -1
  191. package/src/server/chat-completions.ts +25 -3
  192. package/src/server/claude-messages.ts +62 -5
  193. package/src/server/effort-row.ts +1 -1
  194. package/src/server/fast-row.ts +295 -0
  195. package/src/server/gui-static.ts +30 -4
  196. package/src/server/index.ts +122 -28
  197. package/src/server/live.ts +18 -4
  198. package/src/server/management/agent-settings-routes.ts +2 -2
  199. package/src/server/management/combo-routes.ts +37 -9
  200. package/src/server/management/config-routes.ts +93 -2
  201. package/src/server/management/integration-routes.ts +108 -0
  202. package/src/server/management/model-routes.ts +13 -3
  203. package/src/server/management/model-rows.ts +20 -1
  204. package/src/server/management/native-integration-routes.ts +4 -1
  205. package/src/server/management/oauth-account-routes.ts +45 -10
  206. package/src/server/management/provider-routes.ts +34 -3
  207. package/src/server/management/quota-reset-routes.ts +57 -0
  208. package/src/server/management/route-registry.ts +7 -4
  209. package/src/server/management/shared.ts +19 -5
  210. package/src/server/management/system-routes.ts +3 -2
  211. package/src/server/management-api.ts +14 -2
  212. package/src/server/ports.ts +12 -2
  213. package/src/server/relay-eager.ts +38 -23
  214. package/src/server/relay.ts +4 -0
  215. package/src/server/request-log.ts +6 -0
  216. package/src/server/responses/codex-ws-correlation.ts +65 -0
  217. package/src/server/responses/codex-ws-exchange.ts +261 -0
  218. package/src/server/responses/codex-ws-metadata.ts +134 -0
  219. package/src/server/responses/codex-ws-pool.ts +162 -0
  220. package/src/server/responses/codex-ws-request.ts +87 -0
  221. package/src/server/responses/codex-ws-session.ts +93 -0
  222. package/src/server/responses/codex-ws-wire.ts +144 -0
  223. package/src/server/responses/collaboration.ts +41 -1
  224. package/src/server/responses/compact.ts +105 -12
  225. package/src/server/responses/core.ts +510 -57
  226. package/src/server/responses/empty-completion-guard.ts +4 -0
  227. package/src/server/responses/fetch-helpers.ts +10 -3
  228. package/src/server/responses/input-admission.ts +16 -9
  229. package/src/server/responses/responses-field-backfill.ts +1 -1
  230. package/src/server/responses/ws-upstream.ts +34 -318
  231. package/src/server/responses-custom-tool-repair.ts +20 -4
  232. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  233. package/src/server/safe-response-headers.ts +23 -0
  234. package/src/server/search.ts +9 -0
  235. package/src/server/subagent-models-startup.ts +27 -0
  236. package/src/server/system-env-shell.ts +238 -0
  237. package/src/server/system-env.ts +7 -234
  238. package/src/server/ws-bridge.ts +3 -25
  239. package/src/server/xai-responses-startup.ts +21 -0
  240. package/src/service-manager-probe.ts +1 -1
  241. package/src/service.ts +55 -16
  242. package/src/types/config.ts +108 -12
  243. package/src/types/provider.ts +36 -7
  244. package/src/types/request.ts +8 -0
  245. package/src/types/tools.ts +26 -1
  246. package/src/types.ts +2 -0
  247. package/src/update/notify.ts +8 -2
  248. package/src/usage/cost.ts +38 -28
  249. package/src/usage/expected-prices.ts +34 -15
  250. package/src/usage/log.ts +2 -0
  251. package/src/usage/model-identity.ts +26 -0
  252. package/src/usage/summary.ts +15 -1
  253. package/src/vision/describe.ts +6 -0
  254. package/src/vision/image-rewrite.ts +108 -0
  255. package/src/vision/index.ts +19 -306
  256. package/src/vision/plan.ts +205 -0
  257. package/src/web-search/executor.ts +6 -0
  258. package/src/web-search/index.ts +8 -1
  259. package/gui/dist/assets/index-B2YjLA-i.css +0 -1
  260. package/gui/dist/assets/index-aPup8CKb.js +0 -112
@@ -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";
@@ -274,6 +289,69 @@ function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAc
274
289
  } as T;
275
290
  }
276
291
 
292
+ /**
293
+ * Last reset-credit count this process parsed for the main account, tagged with the
294
+ * physical ChatGPT account it was read from.
295
+ *
296
+ * It is deliberately memory-only. The quota store is keyed by the stable `__main__`
297
+ * ALIAS, and `~/.codex/auth.json` can be swapped for another account while the proxy is
298
+ * not running — `reconcileMainCodexAccountRuntimeState` only purges alias-keyed state
299
+ * when it observes the id CHANGE, and its first observation after a restart has nothing
300
+ * to compare against. A disk-hydrated `__main__` entry can therefore belong to the
301
+ * previous login, so filling the DTO from it would show one account's tickets on
302
+ * another's card. Pool accounts have no such hole because their store key IS the account
303
+ * id. Binding the value to `requestAccountId` keeps the fill honest: after a restart the
304
+ * badge simply waits for the first usage response that carries the summary.
305
+ */
306
+ let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null;
307
+
308
+ function rememberMainResetCredits(accountId: string | null, credits: number | undefined): void {
309
+ if (accountId === null || credits === undefined) return;
310
+ mainResetCreditsProvenance = { accountId, credits };
311
+ }
312
+
313
+ /** Forget the remembered count when the physical main identity is no longer the same. */
314
+ function mainResetCreditsForCurrentIdentity(): number | undefined {
315
+ if (!mainResetCreditsProvenance) return undefined;
316
+ const currentAccountId = getMainChatgptAccountId();
317
+ if (currentAccountId === null) return undefined;
318
+ if (currentAccountId !== mainResetCreditsProvenance.accountId) {
319
+ mainResetCreditsProvenance = null;
320
+ return undefined;
321
+ }
322
+ return mainResetCreditsProvenance.credits;
323
+ }
324
+
325
+ /**
326
+ * The main account is the only account whose DTO quota comes from the raw WHAM parse
327
+ * result instead of the merged store: `poolAccountDto` serializes what
328
+ * `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO
329
+ * spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits`
330
+ * only intermittently, and the store exists to bridge that gap
331
+ * (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new
332
+ * snapshot omits it), so the main card lost its ticket badge on every response that
333
+ * happened to omit the summary while pool cards kept theirs.
334
+ *
335
+ * Only `resetCredits` is carried, deliberately, and only from an identity-tagged
336
+ * in-process observation rather than the alias-keyed store. The window fields have
337
+ * *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) —
338
+ * so reinstating the whole stored object would resurrect a window the parse meant to
339
+ * clear whenever the store write was refused by generation gating. A freshly parsed value
340
+ * always wins, including `0`: zero is defined, so it never takes the fill branch.
341
+ */
342
+ function mainQuotaWithCarriedResetCredits(
343
+ parsed: Omit<StoredAccountQuota, "updatedAt">,
344
+ ): StoredAccountQuota {
345
+ const carried = parsed.resetCredits === undefined
346
+ ? mainResetCreditsForCurrentIdentity()
347
+ : undefined;
348
+ return {
349
+ ...parsed,
350
+ ...(carried !== undefined ? { resetCredits: carried } : {}),
351
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
352
+ };
353
+ }
354
+
277
355
  function poolAccountDto(
278
356
  account: CodexAccount,
279
357
  quotaResult: PoolQuotaResult,
@@ -749,6 +827,7 @@ async function fetchMainAccountInfoAttempt(
749
827
  retriesRemaining: number,
750
828
  existingNativeMainLease?: AdmissionLease,
751
829
  nativeMainSharedClaimHeld = false,
830
+ explicitRefresh: boolean = forceRefresh,
752
831
  ): Promise<MainAccountInfoFetchResult> {
753
832
  const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim();
754
833
  if (!nativeMainLease) {
@@ -761,7 +840,7 @@ async function fetchMainAccountInfoAttempt(
761
840
  }
762
841
  try {
763
842
  const operation = async () => ({
764
- ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease),
843
+ ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh),
765
844
  identityGeneration: captureMainAccountIdentityGeneration(),
766
845
  });
767
846
  if (nativeMainSharedClaimHeld) return await operation();
@@ -815,6 +894,12 @@ async function fetchMainAccountInfoWhileOwned(
815
894
  if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
816
895
  return { info: cached, credentialChecked: true, hasCredential: true };
817
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();
818
903
  try {
819
904
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
820
905
  headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
@@ -833,9 +918,20 @@ async function fetchMainAccountInfoWhileOwned(
833
918
  const data = (await resp.json()) as WhamUsageResponse;
834
919
  const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
835
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
+ }
836
927
  const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
837
- 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);
838
931
  const freshResetCredits = quota?.resetCredits;
932
+ // Tag the count with the identity it was read from, so a later response that omits the
933
+ // summary can restore the badge without ever crossing an account boundary.
934
+ rememberMainResetCredits(requestAccountId, freshResetCredits);
839
935
  const result = {
840
936
  email: data.email ?? null,
841
937
  plan,
@@ -857,7 +953,7 @@ async function fetchMainAccountInfoWhileOwned(
857
953
  // score and auto-switch the main account exactly like a pool account (Option A).
858
954
  setMainAccountPlan(result.plan);
859
955
  if (result.quota) {
860
- setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration);
956
+ setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota);
861
957
  }
862
958
  return {
863
959
  info: result,
@@ -965,6 +1061,7 @@ export interface CodexAuthAccountDto {
965
1061
  healthSummary: string;
966
1062
  healthAction?: string;
967
1063
  quotaProbeSkipped?: true;
1064
+ mainAccountHardLock?: MainAccountHardLockStatus;
968
1065
  }
969
1066
 
970
1067
  interface FreshPoolPlanUpdate {
@@ -1353,10 +1450,9 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
1353
1450
  }
1354
1451
  try {
1355
1452
  const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan);
1356
- // Defence in depth: `spark` is already excluded at the claim site, since generic WHAM
1357
- // cannot prove a spark recovery. Keep the settle-side guard so a future claim change
1358
- // cannot silently start clearing spark on generic evidence.
1359
- 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")
1360
1456
  && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
1361
1457
  settleCodexQuotaRecoveryProbe(claim, recovered, {
1362
1458
  credentialGeneration: result.freshCredentialGeneration,
@@ -1371,10 +1467,50 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
1371
1467
  return cooldownRecoveryInFlight;
1372
1468
  }
1373
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
+
1374
1507
  export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void {
1375
1508
  registerStateSweepAfterTick({
1376
1509
  name: "codex-cooldown-recovery",
1377
- afterTick: () => { void runCodexCooldownRecoveryProbes(config); },
1510
+ afterTick: () => {
1511
+ void runCodexCooldownRecoveryProbes(config);
1512
+ void runMainAccountHardLockRecovery(config);
1513
+ },
1378
1514
  });
1379
1515
  }
1380
1516
 
@@ -1636,14 +1772,12 @@ export async function listCodexAuthAccountsSnapshot(
1636
1772
  logLabel: "main",
1637
1773
  isMain: true,
1638
1774
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1775
+ mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig),
1639
1776
  priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1640
1777
  hasCredential: hasMainCredential,
1641
1778
  needsReauth: mainNeedsReauth,
1642
1779
  quota: mainInfo.quota ? {
1643
- ...quotaForPlan({
1644
- ...mainInfo.quota,
1645
- updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
1646
- }, mainInfo.plan),
1780
+ ...quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan),
1647
1781
  } : null,
1648
1782
  ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
1649
1783
  };
@@ -2105,31 +2239,107 @@ export async function handleCodexAuthAPI(
2105
2239
  }
2106
2240
 
2107
2241
  if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
2108
- const body = (await req.json().catch(() => ({}))) as { accountId?: string };
2242
+ const body = (await req.json().catch(() => ({}))) as {
2243
+ accountId?: string;
2244
+ operationId?: unknown;
2245
+ };
2109
2246
  if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
2110
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;
2111
2257
 
2112
2258
  try {
2113
2259
  const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
2114
- const idempotencyKey = crypto.randomUUID();
2115
- const resp = await fetch(
2116
- "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
2117
- {
2118
- method: "POST",
2119
- headers: {
2120
- Authorization: `Bearer ${auth.accessToken}`,
2121
- "ChatGPT-Account-Id": auth.chatgptAccountId,
2122
- "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),
2123
2315
  },
2124
- body: JSON.stringify({ redeem_request_id: idempotencyKey }),
2125
- signal: AbortSignal.timeout(10_000),
2126
- },
2127
- );
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
+ }
2128
2323
  if (!resp.ok) {
2129
2324
  await resp.body?.cancel().catch(() => {});
2325
+ if (identity) markManualResetCreditOperationAmbiguous(identity);
2130
2326
  return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
2131
2327
  }
2132
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
+ }
2133
2343
  // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
2134
2344
  // and return remaining only when that refresh freshly parsed available_count.
2135
2345
  // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
@@ -2167,7 +2377,15 @@ export async function handleCodexAuthAPI(
2167
2377
  }
2168
2378
 
2169
2379
  if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
2170
- const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean; openBrowser?: unknown };
2380
+ const body = (await req.json().catch(() => ({}))) as {
2381
+ id?: string;
2382
+ reauth?: boolean;
2383
+ openBrowser?: unknown;
2384
+ device?: unknown;
2385
+ };
2386
+ // Device mode: no local browser, no loopback listener. The only way to add
2387
+ // an account to a headless hub (#3366).
2388
+ const useDeviceFlow = body.device === true;
2171
2389
  const requestedAccountId = body.id?.trim();
2172
2390
  const reauth = body.reauth === true;
2173
2391
  if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
@@ -2197,13 +2415,20 @@ export async function handleCodexAuthAPI(
2197
2415
  codexAuthLoginState.set(flowId, loginOwner);
2198
2416
  try {
2199
2417
  const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../oauth");
2200
- const result = await startLoginFlow("chatgpt", { forceLogin: true });
2418
+ const result = await startLoginFlow("chatgpt", {
2419
+ forceLogin: true,
2420
+ ...(useDeviceFlow ? { flow: "device" as const } : {}),
2421
+ });
2201
2422
 
2202
2423
  // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
2203
2424
  // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
2204
2425
  // Both login routes share one resolver so this surface cannot drift from the other.
2205
2426
  const { shouldOpenBrowserForLogin } = await import("../oauth/open-browser-choice");
2206
- if (result.url && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
2427
+ // A device flow's URL is a verification page the user opens on ANOTHER
2428
+ // machine. Opening it on the hub host is useless at best, and on a
2429
+ // headless host it fails. `deviceCode` is the same signal the generic
2430
+ // OAuth login route uses to make this decision.
2431
+ if (result.url && !result.deviceCode && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
2207
2432
  const { openUrl } = await import("../lib/open-url");
2208
2433
  openUrl(result.url);
2209
2434
  }
@@ -2211,7 +2436,14 @@ export async function handleCodexAuthAPI(
2211
2436
  (async () => {
2212
2437
  try {
2213
2438
  let completed = false;
2214
- for (let i = 0; i < 150; i++) {
2439
+ // The device grant lives 15 minutes and the whole point is that the
2440
+ // user walks to another device to enter the code. A 5-minute server
2441
+ // budget would kill the flow at minute five while the grant is still
2442
+ // valid. The extra 30 attempts past 450 are settlement margin: a user
2443
+ // who authorizes in the final seconds still needs the token exchange
2444
+ // and credential write to land before this loop gives up.
2445
+ const pollAttempts = useDeviceFlow ? 480 : 150;
2446
+ for (let i = 0; i < pollAttempts; i++) {
2215
2447
  await new Promise(r => setTimeout(r, 2000));
2216
2448
  const st = getLoginStatus("chatgpt");
2217
2449
  if (st.done && st.loggedIn) {
@@ -2437,7 +2669,15 @@ export async function handleCodexAuthAPI(
2437
2669
  })();
2438
2670
 
2439
2671
  setCodexLoginState(flowId, { status: "pending" });
2440
- return jsonResponse({ ok: true, flowId, url: result.url, instructions: result.instructions });
2672
+ return jsonResponse({
2673
+ ok: true,
2674
+ flowId,
2675
+ url: result.url,
2676
+ instructions: result.instructions,
2677
+ // Dropped before #3366: every device-code surface renders this field,
2678
+ // so withholding it left the GUI and CLI with no code to show.
2679
+ ...(result.deviceCode ? { deviceCode: result.deviceCode } : {}),
2680
+ });
2441
2681
  } catch (e) {
2442
2682
  if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
2443
2683
  const msg = e instanceof Error ? e.message : String(e);