@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -30,7 +30,7 @@ import { probeHostname } from "../server/proxy-liveness";
30
30
  import type { OcxConfig } from "../types";
31
31
 
32
32
  export type { ConfigFormat } from "../integrations/serialize";
33
- export type { ManagedFragment, ManagedContribution, BuildContribution, OpencodeLaunchEnv, OpencodeCatalogModel, ExportModel, ExportContext, ExportClientId, ExportClientSpec, PiModelEntry } from "./config-export/contracts";
33
+ export type { ManagedFragment, ManagedContribution, BuildContribution, OpencodeLaunchEnv, OpencodeCatalogModel, ExportModel, ExportContext, ExportClientId, ExportClientSpec, PiModelEntry, DroidReasoningDefaults } from "./config-export/contracts";
34
34
  export { OPENCODE_PROVIDER_ID, OPENCODE_CONFIG_SCHEMA, OPENCODE_API_KEY_ENV, OPENCODE_API_KEY_ENV_REF, KILO_API_KEY_ENV, KILO_API_KEY_ENV_REF, KILO_CONFIG_SCHEMA, HERMES_API_KEY_ENV, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV, OPENCLAW_API_KEY_ENV_REF, LOOPBACK_API_KEY_PLACEHOLDER, GAJAE_API_KEY_ENV, SCHEMA_REQUIRED_OUTPUT_BUDGET, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG } from "./config-export/constants";
35
35
  export { normalizeExportModels } from "./config-export/model-metadata";
36
36
  export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./config-export/omp";
@@ -51,12 +51,13 @@ export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProv
51
51
  export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode";
52
52
  export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProviderEntry, RaycastGeneratedConfig } from "./config-export/raycast";
53
53
  export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
54
- export { droidHomeDir, droidConfigPath, buildDroidClientConfig, summarizeDroid, buildDroidContribution } from "./config-export/droid";
54
+ export { droidHomeDir, droidConfigPath, buildDroidClientConfig, summarizeDroid, buildDroidContribution, droidReasoningModels, droidDefaultsFromOwnedRows, validateDroidReasoningDefaults } from "./config-export/droid";
55
55
  export type { DroidModelEntry, DroidGeneratedConfig } from "./config-export/droid";
56
56
 
57
57
  import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts";
58
58
  import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV, KILO_API_KEY_ENV } from "./config-export/constants";
59
- import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata";
59
+ import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, inputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata";
60
+ import { exportReasoningEfforts, exportDefaultReasoningEffort, legacyReasoningMetadata, type LegacyEffortVariant } from "./config-export/reasoning-metadata";
60
61
  import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp";
61
62
  import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh";
62
63
  import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode";
@@ -72,7 +73,7 @@ import { droidConfigPath, buildDroidClientConfig, summarizeDroid, buildDroidCont
72
73
 
73
74
  export interface OpencodeModelEntry {
74
75
  name: string;
75
- limit?: { context: number; output: number };
76
+ limit?: { context: number; input?: number; output: number };
76
77
  /**
77
78
  * opencode's own capability fields, derived from the catalog row's declared input
78
79
  * modalities. Written only when the row declares at least one — an entry without them is
@@ -81,22 +82,30 @@ export interface OpencodeModelEntry {
81
82
  */
82
83
  attachment?: boolean;
83
84
  modalities?: { input: string[]; output: string[] };
85
+ tool_call?: boolean;
86
+ reasoning?: boolean;
87
+ interleaved?: { field: "reasoning_content" };
88
+ options?: { reasoningEffort: string };
89
+ variants?: Record<string, LegacyEffortVariant>;
84
90
  }
85
91
 
86
92
  /**
87
93
  * One selectable reasoning effort.
88
94
  *
89
- * opencode V2 applies these only from the `providers` block: a `variants` array under the
90
- * legacy `provider` block is parsed and then dropped, so the V1 block stays variant-free
91
- * rather than carrying fields that look configured but never reach a request.
95
+ * Native V2 variants use settings; legacy clients receive a separate options map.
92
96
  */
93
97
  export interface OpencodeModelVariant {
94
98
  id: string;
95
99
  settings: { reasoningEffort: string };
96
100
  }
97
101
 
98
- export interface OpencodeV2ModelEntry extends OpencodeModelEntry {
99
- variants?: OpencodeModelVariant[];
102
+ export interface OpencodeV2ModelEntry {
103
+ name: string;
104
+ limit?: { context: number; input?: number; output: number };
105
+ capabilities?: { tools: boolean; input?: string[]; output?: string[] };
106
+ compatibility?: { reasoningField: "reasoning_content" };
107
+ settings?: { reasoningEffort: string };
108
+ variants: OpencodeModelVariant[];
100
109
  }
101
110
 
102
111
  /** Endpoint and admission, spelled once and shared by both block generations. */
@@ -114,7 +123,7 @@ export interface OpencodeProviderBlock {
114
123
  models: Record<string, OpencodeModelEntry>;
115
124
  }
116
125
 
117
- /** opencode V2 provider block: `package` + `settings`. The only form whose variants apply. */
126
+ /** opencode V2 provider block: `package` + `settings` and native variant arrays. */
118
127
  export interface OpencodeV2ProviderBlock {
119
128
  package: string;
120
129
  name: string;
@@ -147,16 +156,10 @@ export interface OpencodeGeneratedConfig {
147
156
  const OPENCODE_PROVIDER_NPM = "@ai-sdk/openai-compatible";
148
157
 
149
158
  /**
150
- * opencode V2's spelling of the same runtime. V2 resolves providers through its own
151
- * package table and ignores the V1 `npm` field, so a V2 block has to name this package
152
- * or the provider is not loaded at all.
153
- *
154
- * Verified end-to-end against opencode 0.0.0-beta-18684: `GET /api/model` resolves this
155
- * package for the provider and applies the per-model `variants`. opencode changes its
156
- * provider package table between releases, so re-verify the supported range whenever it
157
- * moves; a stale string breaks only the V2 block, silently.
159
+ * Canonical V2 runtime spelling. Current clients alias the old @opencode-ai scope,
160
+ * but exported documents use the documented package rather than depending on that alias.
158
161
  */
159
- const OPENCODE_V2_PROVIDER_PACKAGE = "@opencode-ai/ai/providers/openai-compatible";
162
+ const OPENCODE_V2_PROVIDER_PACKAGE = "@opencode/ai/providers/openai-compatible";
160
163
 
161
164
  /** Display name for the provider block, identical in both generations. */
162
165
  const OPENCODE_PROVIDER_NAME = "OpenCodex";
@@ -672,35 +675,11 @@ function opencodeProviderConnection(baseURL: string, config: OcxConfig): Opencod
672
675
  }
673
676
 
674
677
  /**
675
- * Selectable reasoning efforts for one model, in canonical ladder order.
676
- *
677
- * No model-level `settings.reasoningEffort` default is emitted: the proxy already applies
678
- * its own configured default when a request carries no effort, and pinning one here would
679
- * override a default the user controls in opencodex. Variants are opt-in per selection,
680
- * which is the same reason we never emit `defaultModel` for MCode.
681
- *
682
- * `none` is dropped even when a ladder declares it.
683
- *
684
- * The original reason no longer holds and is recorded here so it is not repeated: the chat
685
- * ingress `OUTPUT_CONFIG_EFFORTS` allowlist DID omit `none`, so selecting it sent no effort
686
- * at all and fell back to the proxy default. That allowlist now accepts `none` (audit F7),
687
- * because it is the runtime's disable sentinel and dropping it let a provider default
688
- * re-enable thinking a caller had turned off.
689
- *
690
- * The variant stays filtered anyway, deliberately and narrowly: emitting it would change
691
- * what this exporter writes into a user's opencode config, and whether opencode's own
692
- * picker round-trips `reasoningEffort: "none"` to the wire this proxy reads has not been
693
- * verified here. Re-enabling it is a scoped follow-up that needs that check first, not a
694
- * side effect of an ingress fix. MCode and ZCode filter `none` for their own separate
695
- * reasons, documented at their call sites.
678
+ * Exact declared choices. An empty array is intentional: omitting variants lets OpenCode
679
+ * synthesize low/medium/high, even for a model whose upstream declares no adjustable effort.
696
680
  */
697
- function opencodeEffortVariants(model: OpencodeCatalogModel): OpencodeModelVariant[] | undefined {
698
- if (model.reasoningEfforts === undefined) return undefined;
699
- // Canonical order (none, minimal, then low..ultra) and dedupe, so the picker order does
700
- // not depend on whatever order a provider listed its efforts in.
701
- const efforts = canonicalizeReasoningEfforts(model.reasoningEfforts).filter(effort => effort !== "none");
702
- if (efforts.length === 0) return undefined;
703
- return efforts.map(effort => ({ id: effort, settings: { reasoningEffort: effort } }));
681
+ function opencodeEffortVariants(model: OpencodeCatalogModel): OpencodeModelVariant[] {
682
+ return (exportReasoningEfforts(model) ?? []).map(effort => ({ id: effort, settings: { reasoningEffort: effort } }));
704
683
  }
705
684
 
706
685
  /**
@@ -712,7 +691,7 @@ function opencodeEffortVariants(model: OpencodeCatalogModel): OpencodeModelVaria
712
691
  * the pair) clamped to the context window.
713
692
  *
714
693
  * Two blocks instead of one because opencode V2 reads the `providers` map and V1 reads
715
- * `provider`, and only the V2 form applies `variants`. Emitting both keeps V1 installs
694
+ * `provider`, with native arrays versus legacy maps for variants. Emitting both keeps V1 installs
716
695
  * working: V2 merges them by provider id and model id, so a model listed in both blocks
717
696
  * appears once, with the V2 entry's name, connection, and variants.
718
697
  */
@@ -725,36 +704,37 @@ export function opencodeProviderBlocks(
725
704
  const v2Models: Record<string, OpencodeV2ModelEntry> = {};
726
705
  for (const model of expandFastExportModels(catalogModels)) {
727
706
  const key = model.namespaced;
728
- const entry: OpencodeModelEntry = { name: exportModelLabel(model) };
707
+ const entry: OpencodeModelEntry = { name: exportModelLabel(model), ...legacyReasoningMetadata(model) };
729
708
  const context = authoritativeContextWindow(model.contextWindow);
730
709
  if (context !== undefined) {
731
710
  entry.limit = { context, output: outputBudgetFor(context, model) };
711
+ const input = inputBudgetFor(context, model);
712
+ if (input !== undefined) entry.limit.input = input;
732
713
  }
733
- // `attachment` / `modalities` are fields of opencode's V1 model schema — the shape its
734
- // published config.json defines and the one its loader reads (verified against opencode
735
- // 1.18.30, src/provider/provider.ts: `model.attachment ?? …` / `model.modalities?.input`).
736
- // They ride on both generations anyway: the two blocks are two spellings of one model list,
737
- // and the V2 model schema (capabilities.{tools,input,output}, which opencode fills by
738
- // migrating this same `modalities` field) ignores keys it does not define — its loader
739
- // decodes with `onExcessProperty: "ignore"`. Same values on both, so a merge cannot make
740
- // the two entries disagree.
714
+ // Native V2 decoders ignore legacy attachment/modalities fields. Emit each generation's
715
+ // actual capability shape rather than relying on migration of the other provider block.
741
716
  const capabilities = opencodeModelCapabilities(model.inputModalities);
742
717
  if (capabilities) {
743
718
  entry.attachment = capabilities.attachment;
744
719
  entry.modalities = capabilities.modalities;
745
720
  }
721
+ if (typeof model.supportsTools === "boolean") entry.tool_call = model.supportsTools;
746
722
  v1Models[key] = entry;
747
- const variants = opencodeEffortVariants(model);
748
- // Own `limit` and `modalities` objects, not shared references: the two blocks are
749
- // serialized and reasoned about separately, and an in-place edit of one must never move
750
- // the other.
723
+ const defaultEffort = exportDefaultReasoningEffort(model);
724
+ // V2 requires tools whenever capabilities exists. A partial object discards the entire
725
+ // native block, losing explicit variants. Unknown tools use the legacy modality migration
726
+ // rather than inventing a tools flag to make the native schema accept the declaration.
727
+ const v2Capabilities: OpencodeV2ModelEntry["capabilities"] = typeof model.supportsTools === "boolean" ? {
728
+ tools: model.supportsTools,
729
+ ...(capabilities ? { input: [...capabilities.modalities.input], output: [...capabilities.modalities.output] } : {}),
730
+ } : undefined;
751
731
  v2Models[key] = {
752
- ...entry,
732
+ name: entry.name,
753
733
  ...(entry.limit ? { limit: { ...entry.limit } } : {}),
754
- ...(entry.modalities
755
- ? { modalities: { input: [...entry.modalities.input], output: [...entry.modalities.output] } }
756
- : {}),
757
- ...(variants ? { variants } : {}),
734
+ ...(v2Capabilities ? { capabilities: v2Capabilities } : {}),
735
+ ...(entry.interleaved ? { compatibility: { reasoningField: "reasoning_content" } } : {}),
736
+ ...(defaultEffort !== undefined ? { settings: { reasoningEffort: defaultEffort } } : {}),
737
+ variants: opencodeEffortVariants(model),
758
738
  };
759
739
  }
760
740
  return {
@@ -6,7 +6,7 @@
6
6
  * dashboard or CLI pick, never creates the file, and refuses a file with comments: the write
7
7
  * re-serializes JSON, and a comment the user wrote would be lost without a word.
8
8
  */
9
- import { lstatSync, readFileSync } from "node:fs";
9
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
@@ -40,12 +40,26 @@ type Loaded =
40
40
 
41
41
  function load(path: string): Loaded {
42
42
  let text: string;
43
+ let fd: number | undefined;
43
44
  try {
44
- if (!lstatSync(path).isFile()) return { kind: "invalid" };
45
- text = readFileSync(path, "utf8");
45
+ // POSIX FIFOs must not block before fstat can reject them. Windows omits
46
+ // these flags and retains the descriptor/path identity checks below.
47
+ const flags = process.platform === "win32"
48
+ ? constants.O_RDONLY
49
+ : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
50
+ fd = openSync(path, flags);
51
+ const opened = fstatSync(fd, { bigint: true });
52
+ const current = lstatSync(path, { bigint: true });
53
+ if (!opened.isFile() || !current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) {
54
+ return { kind: "invalid" };
55
+ }
56
+ text = readFileSync(fd, "utf8");
46
57
  } catch (error) {
47
58
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
59
+ if ((error as NodeJS.ErrnoException).code === "ELOOP") return { kind: "invalid" };
48
60
  throw error;
61
+ } finally {
62
+ if (fd !== undefined) closeSync(fd);
49
63
  }
50
64
  if (hasJsoncComments(text)) return { kind: "comments" };
51
65
  try {
@@ -4,6 +4,7 @@ import { isThirtyDayOnlyCodexPlan } from "./plan";
4
4
  import {
5
5
  CODEX_EXHAUSTED_USAGE_PERCENT,
6
6
  isTerminalShortWindow,
7
+ hasSpendableCodexCredits,
7
8
  resetAtToMs,
8
9
  type StoredAccountQuota,
9
10
  } from "./quota-types";
@@ -14,7 +15,7 @@ import {
14
15
  * Upstream does not refuse an account that holds ChatGPT credits at 100%: it serves the request
15
16
  * and draws the balance. Selection only takes an account off on quota after a refusal, so an
16
17
  * account with credits was never moved off. Spending is opt-in: only the ids in
17
- * `creditCodexAccountIds` may keep serving from credits, and every other account leaves rotation
18
+ * `creditCodexAccountIds` may keep serving from fresh spendable credits, and every other account leaves rotation
18
19
  * at 100% and comes back after its reset. Pool accounts and the `__main__` login both carry the
19
20
  * switch; the main login is also checked where the main-account hard lock is.
20
21
  */
@@ -95,5 +96,6 @@ export function isCodexAccountHeldForCredits(
95
96
  plan: unknown,
96
97
  now: number,
97
98
  ): boolean {
98
- return !codexAccountUsesCreditsAfterLimit(config, accountId) && isCodexUsageLimitReached(quota, plan, now);
99
+ return !(codexAccountUsesCreditsAfterLimit(config, accountId) && hasSpendableCodexCredits(quota, now))
100
+ && isCodexUsageLimitReached(quota, plan, now);
99
101
  }
@@ -18,6 +18,27 @@ const reauthAccounts = new Map<string, number | undefined>();
18
18
  let lastReconciledGeneration = 0;
19
19
  let liveAccountIds = new Set<string>();
20
20
 
21
+ // Refusal evidence is independent of the ordinary quarantine flag. Profile switches make a key
22
+ // inapplicable, not forgotten; the oldest of at most 64 records is evicted to bound process state.
23
+ const refusedMainRefreshGrants = new Set<string>();
24
+ const MAX_REFUSED_MAIN_REFRESH_GRANTS = 64;
25
+
26
+ export function markMainRefreshGrantRejected(key: string): void {
27
+ refusedMainRefreshGrants.add(key);
28
+ if (refusedMainRefreshGrants.size > MAX_REFUSED_MAIN_REFRESH_GRANTS) {
29
+ refusedMainRefreshGrants.delete(refusedMainRefreshGrants.values().next().value!);
30
+ }
31
+ }
32
+
33
+ export function isMainRefreshGrantRejected(key: string): boolean {
34
+ return refusedMainRefreshGrants.has(key);
35
+ }
36
+
37
+ export function clearMainRefreshGrantRejection(key: string): void {
38
+ refusedMainRefreshGrants.delete(key);
39
+ }
40
+
41
+
21
42
  export function markAccountNeedsReauth(
22
43
  id: string,
23
44
  writerGeneration = captureConfigGeneration(),
@@ -1,3 +1,6 @@
1
+ import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure, type ChatgptRefreshDiagnosticCode } from "./chatgpt-refresh-failure";
2
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
+ export { classifyChatgptRefreshFailure } from "./chatgpt-refresh-failure";
1
4
  import { readOrcaAuthSource } from "./orca-auth-source";
2
5
  import { createHash } from "node:crypto";
3
6
  import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
@@ -526,7 +529,8 @@ const CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
526
529
 
527
530
  export class TokenRefreshError extends Error {
528
531
  reason: "expired" | "revoked" | "unknown";
529
- constructor(reason: "expired" | "revoked" | "unknown", message: string) {
532
+ constructor(reason: "expired" | "revoked" | "unknown", message: string,
533
+ readonly status?: number, readonly code?: ChatgptRefreshDiagnosticCode) {
530
534
  super(message);
531
535
  this.name = "TokenRefreshError";
532
536
  this.reason = reason;
@@ -1322,52 +1326,12 @@ async function resolveCodexToken(
1322
1326
  signal,
1323
1327
  });
1324
1328
  if (!res.ok) {
1325
- const errText = await res.text().catch(() => "");
1326
- let errDesc: string;
1327
- let errCodeExact: string | undefined;
1328
- try {
1329
- const parsed = JSON.parse(errText) as {
1330
- error?: string | { code?: string; message?: string };
1331
- error_description?: string;
1332
- };
1333
- if (typeof parsed.error === "string") {
1334
- errCodeExact = parsed.error.trim();
1335
- errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ");
1336
- } else if (parsed.error && typeof parsed.error === "object") {
1337
- errCodeExact = typeof parsed.error.code === "string" ? parsed.error.code.trim() : undefined;
1338
- errDesc = [parsed.error.code, parsed.error.message, parsed.error_description].filter(Boolean).join(": ");
1339
- } else {
1340
- errDesc = parsed.error_description || `HTTP ${res.status}`;
1341
- }
1342
- if (!errDesc) errDesc = `HTTP ${res.status}`;
1343
- } catch { errDesc = `HTTP ${res.status}`; }
1344
- // `invalid_grant` is the standard OAuth code for a refresh token that is no longer
1345
- // usable, and upstream sends it bare with no description. Without it here the dead
1346
- // grant is classified "unknown", which callers treat as transient — so the account
1347
- // is never retired and every request repeats the same doomed refresh (#2887).
1348
- //
1349
- // Matched on the exact `error` CODE, not anywhere in the combined text: a transient
1350
- // `server_error` whose description happens to mention invalid_grant would otherwise
1351
- // retire a healthy account, which is the failure this whole change exists to remove.
1352
- //
1353
- // That rule binds the DESCRIPTION words too. "invalidated", "revoked" and "expired" read
1354
- // as terminal prose, but upstream puts arbitrary text there: a `server_error` whose
1355
- // description says "token was revoked" or "session expired" is still a 5xx blip, and
1356
- // retiring the account on it is exactly the false quarantine #2887 exists to prevent.
1357
- // So a body that carries a structured code is classified by that code ALONE. The
1358
- // substring fallback survives only where there is no structured code to read at all --
1359
- // a description-only body, or one this parser could not decode -- because there the
1360
- // prose is the only signal upstream gave us.
1361
- const structuredCode = errCodeExact ? errCodeExact : undefined;
1362
- const proseIsOnlySignal = structuredCode === undefined;
1363
- const reason = structuredCode === "invalid_grant"
1364
- || structuredCode === "refresh_token_invalidated"
1365
- || (proseIsOnlySignal
1366
- && (errDesc.includes("invalidated") || errDesc.includes("revoked"))) ? "revoked" as const
1367
- : structuredCode === "refresh_token_expired"
1368
- || (proseIsOnlySignal && errDesc.includes("expired")) ? "expired" as const
1369
- : "unknown" as const;
1370
- throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
1329
+ const body = await readBoundedResponseBody(res, { signal, maxBytes: 16_384, fatalUtf8: true })
1330
+ .catch(() => undefined);
1331
+ const failure = classifyChatgptRefreshFailure(res.status, body?.displaySafe ? body.text : "");
1332
+ const { reason } = failure;
1333
+ noteChatgptRefreshFailure("pool", res.status, failure);
1334
+ throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`, res.status, failure.code);
1371
1335
  }
1372
1336
  const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
1373
1337
  // Guard against a missing/non-finite/negative expires_in (malformed upstream
@@ -2,8 +2,7 @@ import { readCodexAccountRecord } from "./account-store";
2
2
  import { isAccountNeedsReauth } from "./account-runtime-state";
3
3
  import {
4
4
  MAIN_CODEX_ACCOUNT_ID,
5
- hasMainAccountRefreshGrant,
6
- isMainAccountCredentialUsable,
5
+ getMainAccountCredentialStatus,
7
6
  isMainAccountTokenLive,
8
7
  } from "./main-account";
9
8
  import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id";
@@ -14,7 +13,7 @@ import { isMainAccountHardLocked } from "./main-account-hard-lock";
14
13
  export interface CodexAccountUsabilityOptions {
15
14
  /** Route using cached runtime state only; the caller must reject selected main before auth. */
16
15
  nativeMainSelectionOnly?: boolean;
17
- /** Test seam for proving whether routing attempted a physical native-token read. */
16
+ /** Liveness override; ownership/read fences independently control physical credential reads. */
18
17
  isMainAccountTokenLive?: typeof isMainAccountTokenLive;
19
18
  /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
20
19
  modelEligibleAccountIds?: ReadonlySet<string>;
@@ -88,15 +87,19 @@ export function codexAccountUnusableReason(
88
87
  // A legacy pool row with the sentinel makes an active `__main__` ambiguous.
89
88
  // Fail closed until the authenticated compatibility-delete path removes it.
90
89
  if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return "legacy_pool_sentinel";
91
- if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return "needs_reauth";
92
- // A selection-only caller owns the recovery/drain fence and will reject main
93
- // before reservation or token materialization. Treat cached main as a routing
94
- // candidate without touching the credential file so affinity is not rebound.
90
+ // These scopes cannot consume physical stored-main state, even indirectly through an
91
+ // ordinary quarantine plus hasMainAccountRefreshGrant(). Caller liveness is request-owned.
95
92
  if (options.nativeMainSelectionOnly) return undefined;
93
+ if (options.requestOwnedMainCredential) {
94
+ return options.isMainAccountTokenLive?.() === false ? "main_credential_unavailable" : undefined;
95
+ }
96
+ const credential = getMainAccountCredentialStatus();
97
+ if (credential.refreshGrantRejected) return "needs_reauth";
98
+ if (isAccountNeedsReauth(accountId) && !credential.hasRefreshGrant) return "needs_reauth";
96
99
  // Main account: a refresh grant is enough to route; materialization refreshes before I/O.
97
100
  const mainLive = options.isMainAccountTokenLive
98
101
  ? options.isMainAccountTokenLive()
99
- : isMainAccountCredentialUsable();
102
+ : credential.usable;
100
103
  return mainLive ? undefined : "main_credential_unavailable";
101
104
  }
102
105
  const exists = (config.codexAccounts ?? [])
@@ -15,7 +15,7 @@ import { getCodexAccountAutoSwitchThresholdOverride } from "../account-auto-swit
15
15
  import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing";
16
16
  import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan";
17
17
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
18
- import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
18
+ import { getValidMainAccountToken, isMainAccountRefreshGrantRejected, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
19
19
  import { captureConfigGeneration } from "../../lib/state-store-sweeper";
20
20
  import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, getObservedMainQuotaIdentityKey, isMainAccountIdentityGenerationLive } from "../main-account-cache";
21
21
  import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
@@ -342,60 +342,76 @@ export async function listCodexAuthAccountsSnapshot(
342
342
  maskEmails,
343
343
  )];
344
344
  });
345
- const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
346
- const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
347
- // An ordinary same-account return can be parsed after its credential was replaced.
348
- // The card and hard-lock status must describe the same published quota snapshot.
349
- const mainInfo = mainSnapshotLive
350
- ? mainResult.infoUnpublished ? getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO : mainResult.info
351
- : EMPTY_MAIN_ACCOUNT_INFO;
352
- const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
353
- ? mainResult.hasCredential
354
- : getMainAccountCredentialPresence() ?? false;
355
- const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
356
- const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
357
- const mainHealth = projectCodexAccountHealth({
358
- accountId: MAIN_CODEX_ACCOUNT_ID,
359
- needsReauth: mainNeedsReauth,
360
- });
361
- // The main row carries the same attribution as a pool row. Reaching this point without
362
- // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the
363
- // cause is a refresh that did not complete.
364
- const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
365
- ? "missing_credential"
366
- : mainNeedsReauth
367
- ? "refresh_failed"
368
- : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
369
- const main: CodexAuthAccountDto = {
370
- id: MAIN_CODEX_ACCOUNT_ID,
371
- email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
372
- plan: mainInfo.plan,
373
- ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
345
+ const projectSnapshot = (mainRefreshRefused = false): CodexAuthAccountsSnapshot => {
346
+ const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
347
+ const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
348
+ // An ordinary same-account return can be parsed after its credential was replaced.
349
+ // The card and hard-lock status must describe the same published quota snapshot.
350
+ const mainInfo = mainSnapshotLive
351
+ ? mainResult.infoUnpublished ? getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO : mainResult.info
352
+ : EMPTY_MAIN_ACCOUNT_INFO;
353
+ const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
354
+ ? mainResult.hasCredential
355
+ : getMainAccountCredentialPresence() ?? false;
356
+ const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
357
+ const liveQuotaRefresh = mainSnapshotLive && mainResult.quotaRefreshGeneration !== undefined
374
358
  && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
375
- ? { quotaRefresh: mainResult.quotaRefresh } : {}),
376
- logLabel: "main",
377
- isMain: true,
378
- paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
379
- mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
380
- externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
381
- priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
382
- autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
383
- creditsAfterLimit: codexAccountUsesCreditsAfterLimit(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
384
- hasCredential: hasMainCredential,
385
- needsReauth: mainNeedsReauth,
386
- ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
387
- quota: mainInfo.quota
388
- ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
389
- : null,
390
- ...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId()),
391
- ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
392
- };
393
- return {
394
- accounts: [main, ...withQuota],
395
- mainIdentityGeneration: mainSnapshotLive
396
- ? fetchedMainGeneration
397
- : captureMainAccountIdentityGeneration(),
359
+ ? mainResult.quotaRefresh : undefined;
360
+ const mainNeedsReauth = mainMissingCredential || mainRefreshRefused || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
361
+ const mainHealth = projectCodexAccountHealth({
362
+ accountId: MAIN_CODEX_ACCOUNT_ID,
363
+ needsReauth: mainNeedsReauth,
364
+ });
365
+ // The main row carries the same attribution as a pool row. Reaching this point without
366
+ // Only a current terminal usage probe can attribute the reauth cause. A transient status
367
+ // never replaces a refresh failure, including a separately recorded refused grant.
368
+ const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
369
+ ? "missing_credential"
370
+ : mainNeedsReauth
371
+ ? mainResult.terminalAuthFailure === true && liveQuotaRefresh?.status === "http_error"
372
+ ? "unauthorized" : "refresh_failed"
373
+ : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
374
+ const main: CodexAuthAccountDto = {
375
+ id: MAIN_CODEX_ACCOUNT_ID,
376
+ email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
377
+ plan: mainInfo.plan,
378
+ ...(liveQuotaRefresh ? { quotaRefresh: liveQuotaRefresh } : {}),
379
+ logLabel: "main",
380
+ isMain: true,
381
+ paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
382
+ mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
383
+ externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
384
+ priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
385
+ autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
386
+ creditsAfterLimit: codexAccountUsesCreditsAfterLimit(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
387
+ hasCredential: hasMainCredential,
388
+ needsReauth: mainNeedsReauth,
389
+ ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
390
+ quota: mainInfo.quota
391
+ ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
392
+ : null,
393
+ ...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId()),
394
+ ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
395
+ };
396
+ return {
397
+ accounts: [main, ...withQuota],
398
+ mainIdentityGeneration: mainSnapshotLive
399
+ ? fetchedMainGeneration
400
+ : captureMainAccountIdentityGeneration(),
401
+ };
398
402
  };
403
+ // Project the refusal and DTO in the same owned synchronous observation. A same-account grant
404
+ // replacement during earlier pool probes must not inherit the previous grant's refusal.
405
+ const projectionLease = tryAcquireNativeMainProfileClaim();
406
+ if (!projectionLease) return projectSnapshot();
407
+ try {
408
+ return await withNativeMainCredentialClaim(async () => projectSnapshot(isMainAccountRefreshGrantRejected()));
409
+ } catch (error) {
410
+ if (isNativeMainClaimUnavailable(error)) return projectSnapshot();
411
+ throw error;
412
+ } finally {
413
+ projectionLease.release();
414
+ }
399
415
  }
400
416
 
401
417
  /** One opted-in account's metadata; reuse the bounded WHAM 401 recovery and generation fence. */
@@ -406,16 +422,10 @@ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountI
406
422
  try {
407
423
  reconcileMainCodexAccountRuntimeState();
408
424
  if (isAccountNeedsReauth(accountId)) return;
409
- const identityGeneration = captureMainAccountIdentityGeneration();
410
- const writerGeneration = captureConfigGeneration();
411
425
  try {
412
- // Refresh may need an exclusive claim; prepare before WHAM takes its shared claim.
426
+ // Refresh owns credential-scoped refusal evidence; do not recreate a global quarantine.
413
427
  if (!await getValidMainAccountToken({ preserveReauth: true })) return;
414
- } catch (error) {
415
- if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
416
- && isMainAccountIdentityGenerationLive(identityGeneration)) {
417
- markAccountNeedsReauth(accountId, writerGeneration);
418
- }
428
+ } catch {
419
429
  return;
420
430
  }
421
431
  if (isAccountNeedsReauth(accountId)) return;
@@ -476,7 +486,7 @@ export async function pauseExhaustedCodexAccounts(
476
486
  }
477
487
  return {
478
488
  shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
479
- && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan),
489
+ && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan, codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)),
480
490
  checkedAccountCount: 1,
481
491
  failedAccountCount: 0,
482
492
  };
@@ -511,7 +521,7 @@ export async function pauseExhaustedCodexAccounts(
511
521
  continue;
512
522
  }
513
523
  checkedAccountCount += 1;
514
- if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
524
+ if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan, codexAccountUsesCreditsAfterLimit(config, account.id))) {
515
525
  exhaustedIds.push(account.id);
516
526
  }
517
527
  }