@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -97,20 +97,60 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
97
97
  }
98
98
 
99
99
  /**
100
- * Every thread keys as ITSELF, never as its parent (#4546, wp8).
100
+ * Whether a request carrying its OWN main credential still serves on main because the operator
101
+ * manually pinned it (#3166), split from the surrounding resolution so request preview can ask
102
+ * the identical question (#4850).
101
103
  *
102
- * The old rule preferred `x-codex-parent-thread-id`, so every child of one parent bound under
103
- * the RAW parent id -- one shared entry, unrelated to the root's own `app:HMAC(session, thread)`
104
- * binding -- and a grandchild keyed on its own parent landed on a key nobody had ever bound.
105
- * A child therefore started cold while its parent was being served warm somewhere, and no
106
- * child could hold a binding of its own.
104
+ * Exported for exactly one reason: two copies of this fence is how #4850 happened. Final
105
+ * authentication honoured the ownership boundary while request preview, computing its fence from
106
+ * recovery and drain state alone, still handed pool eligibility the default liveness probe and
107
+ * opened the physical `auth.json` twice per spawn. A predicate one caller can forget is a
108
+ * predicate the other caller will eventually disagree with.
107
109
  *
108
- * Now a request with a `thread-id` keys as HMAC(session ?? parent, thread). A root is
109
- * unchanged, a child gets an independent key, and a request naming only a parent rides the
110
- * parent's lane under HMAC(parent, parent) -- the same one-to-one lane it always had, minus
111
- * the caller-supplied identifier that used to sit in Pool state. Which requests produce no
112
- * key at all is unchanged. First placement for a child is what consults the family, through
113
- * `recordCodexThreadLineage` below and the placement hook in ./routing.
110
+ * Read-free by construction, which is what makes it usable on the fenced side. Every input is
111
+ * config, policy, or in-memory runtime state: the pin fields, the paused list, the cached quota
112
+ * score, and `callerMatchesObservedMain`, which compares HMAC digests against the observed
113
+ * credential record in `main-account-cache.ts`. Nothing here opens a file.
114
+ *
115
+ * `candidate` is the pin before the hard-lock question, because the caller still owes the
116
+ * pending-binding check that only final authentication can fail closed on.
117
+ */
118
+ export function requestOwnedMainPinState(
119
+ headers: Headers,
120
+ config: OcxConfig,
121
+ policy: CodexAuthPolicyConfig,
122
+ requestScopedMainCredential: boolean,
123
+ fixedAccountId: string | undefined,
124
+ ): { candidate: boolean; preserve: boolean } {
125
+ const candidate = requestScopedMainCredential
126
+ && fixedAccountId === undefined
127
+ && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
128
+ && isEffectiveCodexAccountPinned(config)
129
+ && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
130
+ && requestOwnedMainPinHasQuotaHeadroom(config);
131
+ return {
132
+ candidate,
133
+ preserve: candidate && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * One conversation tree keys as ONE cohort (#4780).
139
+ *
140
+ * Upstream keys its prompt cache on something the whole tree shares -- `prompt_cache_key` is the
141
+ * session id, or `{source}:{parent_thread_id}` for an internal session -- and one `AgentControl`
142
+ * whose `session_id` is the root thread's id is shared with every sub-agent. While the proxy
143
+ * keyed per thread, two requests could carry an identical `prompt_cache_key` and be served by
144
+ * different accounts, so the split member asserted a warm prefix that was deterministically cold
145
+ * on its account. Nothing failed; the prompt was replayed in full and the tokens burned.
146
+ *
147
+ * THIS IS NOT A REVERT OF #4546 wp8. wp8 fixed a different defect -- a child bound under the RAW
148
+ * parent id, an identity unrelated to the root's own binding, so siblings shared an entry the
149
+ * root was not on and a grandchild landed on a key nobody had bound. A cohort key has no such
150
+ * incoherence, because the root's own binding IS the cohort key. What wp8 additionally gave each
151
+ * thread, a binding of its own, is what this deliberately gives up.
152
+ *
153
+ * Which requests produce no key at all is unchanged, through both units.
114
154
  *
115
155
  * The derivation itself lives in ./lineage so a lineage record's conversation key and the key
116
156
  * the thread actually binds under can never drift apart.
@@ -816,19 +856,15 @@ export async function resolveCodexAuthContext(
816
856
  throw new CodexReserveUnavailableError();
817
857
  }
818
858
  const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
819
- const requestOwnedMainPinCandidate = requestScopedMainCredential
820
- && fixedAccountId === undefined
821
- && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
822
- && isEffectiveCodexAccountPinned(config)
823
- && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
824
- && requestOwnedMainPinHasQuotaHeadroom(config);
859
+ const {
860
+ candidate: requestOwnedMainPinCandidate,
861
+ preserve: preserveRequestOwnedMainPin,
862
+ } = requestOwnedMainPinState(headers, config, policy, requestScopedMainCredential, fixedAccountId);
825
863
  // During an owned startup, equality cannot be established until recovery and the
826
864
  // memory-only policy binding finish. This read-only fence never probes a foreign home.
827
865
  if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) {
828
866
  throw new CodexMainProfileDrainingError();
829
867
  }
830
- const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate
831
- && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy));
832
868
  if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
833
869
  throw new Error("Codex auth context cannot select and exclude an account simultaneously");
834
870
  }
@@ -1385,6 +1421,7 @@ export function materializeCodexUpstreamAuth(
1385
1421
  if (!stored?.accessToken || !isMainAccountTokenLive()) {
1386
1422
  throw new CodexMainSubstitutionUnavailableError();
1387
1423
  }
1424
+ selected.delete("chatgpt-account-id");
1388
1425
  selected.set("authorization", `Bearer ${stored.accessToken}`);
1389
1426
  if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
1390
1427
  observeSelectedMainCredential(stored, writer);
@@ -1462,6 +1499,7 @@ export async function materializeCodexUpstreamAuthAsync(
1462
1499
  ...(options.nativeMainRefreshDependencies ?? {}),
1463
1500
  });
1464
1501
  if (!stored?.accessToken) throw new CodexMainSubstitutionUnavailableError();
1502
+ selected.delete("chatgpt-account-id");
1465
1503
  selected.set("authorization", `Bearer ${stored.accessToken}`);
1466
1504
  if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
1467
1505
  observeSelectedMainCredential(stored, writer);
@@ -26,7 +26,7 @@ import {
26
26
  } from "./metadata";
27
27
  import { resetBundledCatalogCacheForTests } from "./bundled";
28
28
  import { isMultiAgentV2Enabled } from "../features";
29
- import { ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort";
29
+ import { clampedDefaultEffort, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort";
30
30
  import { clearGatherRoutedModelsInflight, lastDropWarnSignature } from "./provider-fetch";
31
31
  import {
32
32
  accountSelectorShadowCollisionWarnings,
@@ -490,10 +490,14 @@ export interface ObservedCatalogMergeInput {
490
490
  readonly includeNativeOpenAi: boolean;
491
491
  readonly accountBoundEntries: readonly RawEntry[];
492
492
  readonly suppressedBareNativeSlugs?: ReadonlySet<string>;
493
+ /** Routed slugs that must not gain a missing synthetic max rung during retained-row repair. */
494
+ readonly suppressedSyntheticMaxSlugs?: ReadonlySet<string>;
493
495
  readonly policy: ObservedCatalogMergePolicy;
494
496
  readonly openaiContextCap?: NativeContextLimitsInput;
495
497
  /** Exact display-only labels for bare native OpenAI models. */
496
498
  readonly nativeDisplayNames?: Readonly<Record<string, string>>;
499
+ /** Pristine installed-catalog multi-agent pins; see MultiAgentModeOptions.nativeDefaults. */
500
+ readonly nativeMultiAgentDefaults?: ReadonlyMap<string, string | null>;
497
501
  }
498
502
 
499
503
  /**
@@ -526,9 +530,11 @@ export function mergeCatalogEntriesFromObservedState({
526
530
  includeNativeOpenAi,
527
531
  accountBoundEntries,
528
532
  suppressedBareNativeSlugs = new Set(),
533
+ suppressedSyntheticMaxSlugs = new Set(),
529
534
  policy,
530
535
  openaiContextCap,
531
536
  nativeDisplayNames,
537
+ nativeMultiAgentDefaults,
532
538
  }: ObservedCatalogMergeInput): RawEntry[] {
533
539
  // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at
534
540
  // the observed-core boundary so callers can safely retain evidence objects or repeat the merge.
@@ -860,16 +866,29 @@ export function mergeCatalogEntriesFromObservedState({
860
866
  });
861
867
  // Mock-max universality (260709): preserved routed entries from disk may predate
862
868
  // the max rung — ensure it here so subagent max spawns validate on every
863
- // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact.
869
+ // reasoning-capable entry. A suppressed preserved row that already contains max keeps it;
870
+ // without persisted provenance, only a healthy provider rebuild can distinguish and remove
871
+ // an older synthetic rung from a real provider-declared rung. max only: 5.6 exact ladders
872
+ // (luna: no ultra) stay intact.
864
873
  if (!freshCustomEntries.has(m) && !exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) {
865
874
  const levels = Array.isArray(e.supported_reasoning_levels)
866
875
  ? e.supported_reasoning_levels as Array<{ effort?: string }>
867
876
  : [];
868
- if (levels.length > 0 && !levels.some(level => level.effort === "max")) {
877
+ if (levels.length > 0
878
+ && !suppressedSyntheticMaxSlugs.has(String(e.slug ?? ""))
879
+ && !levels.some(level => level.effort === "max")) {
869
880
  levels.push(CODEX_REASONING_LEVELS.find(level => level.effort === "max")
870
881
  ?? { effort: "max", description: "Maximum reasoning depth for the hardest problems" });
871
882
  e.supported_reasoning_levels = levels;
872
883
  }
884
+ if (suppressedSyntheticMaxSlugs.has(String(e.slug ?? ""))
885
+ && typeof e.default_reasoning_level === "string"
886
+ && !levels.some(level => level.effort === e.default_reasoning_level)) {
887
+ e.default_reasoning_level = clampedDefaultEffort(
888
+ e.default_reasoning_level,
889
+ levels.flatMap(level => typeof level.effort === "string" ? [level.effort] : []),
890
+ );
891
+ }
873
892
  }
874
893
  if (wsEnabled) e.supports_websockets = true;
875
894
  else {
@@ -886,7 +905,7 @@ export function mergeCatalogEntriesFromObservedState({
886
905
  applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs),
887
906
  multiAgentMode,
888
907
  multiAgentV2Enabled,
889
- { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection },
908
+ { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection, nativeDefaults: nativeMultiAgentDefaults },
890
909
  );
891
910
  applyFullModelPickerOrder(versionedEntries, modelPickerOrder);
892
911
  for (const entry of versionedEntries) {
@@ -937,6 +956,7 @@ export function mergeCatalogEntriesForSync(
937
956
  ),
938
957
  openaiContextCap?: NativeContextLimitsInput,
939
958
  keepNativeChatGptOnV1 = false,
959
+ nativeMultiAgentDefaults?: ReadonlyMap<string, string | null>,
940
960
  ): RawEntry[] {
941
961
  // Retained for source compatibility with the original helper contract. Raw provider ids must
942
962
  // not suppress same-named native rows; actual admitted combo entries own that decision now.
@@ -973,6 +993,7 @@ export function mergeCatalogEntriesForSync(
973
993
  accountBoundEntries,
974
994
  suppressedBareNativeSlugs,
975
995
  openaiContextCap,
996
+ nativeMultiAgentDefaults,
976
997
  policy: {
977
998
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
978
999
  warningPolicy: "emit",
@@ -160,6 +160,7 @@ export function deriveEntry(
160
160
  preserveExactReasoning
161
161
  || codexForwardNativeCapabilityAlias !== null
162
162
  || preservePinnedNativeCustomReasoning(model),
163
+ model?.suppressSyntheticMax === true,
163
164
  );
164
165
  // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned
165
166
  // native tool/search/responses-lite contract while preserving the routed slug and wire id.
@@ -208,7 +209,13 @@ export function deriveEntry(
208
209
  };
209
210
  if (isRouted) {
210
211
  applyRoutedCodexToolMode(entry, model?.codexToolMode);
211
- applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning || preservePinnedNativeCustomReasoning(model));
212
+ applyReasoningLevels(
213
+ entry,
214
+ model?.reasoningEfforts,
215
+ model?.defaultReasoningEffort,
216
+ preserveExactReasoning || preservePinnedNativeCustomReasoning(model),
217
+ model?.suppressSyntheticMax === true,
218
+ );
212
219
  }
213
220
  else {
214
221
  applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
@@ -226,10 +226,12 @@ export function applyReasoningLevels(
226
226
  effortsOverride?: string[],
227
227
  defaultOverride?: string,
228
228
  preserveExact = false,
229
+ suppressSyntheticMax = false,
229
230
  ): void {
230
231
  let efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort);
231
- // Mock top tiers (user decision 260709): every reasoning-capable model advertises `max`
232
- // even when the provider ladder stops lower — subagent spawns pass `max` DIRECTLY
232
+ // Mock top tiers (user decision 260709): reasoning-capable routed models advertise `max`
233
+ // even when the provider ladder stops lower, unless the model opts out of that synthesis.
234
+ // Subagent spawns pass `max` DIRECTLY
233
235
  // (no ultra->max client conversion) and codex-rs validates it by catalog membership,
234
236
  // so a missing max rung hard-fails spawn_agent effort overrides. The wire stays honest:
235
237
  // routed adapters clamp via clampToSupportedCodexEffort and natives via
@@ -237,7 +239,7 @@ export function applyReasoningLevels(
237
239
  // reasoning-capable, so it must not grow synthetic top rungs.
238
240
  if (!preserveExact && efforts.length > 0 && efforts.some(effort => effort !== "none" && effort !== "minimal")) {
239
241
  const additions: string[] = [];
240
- if (!efforts.includes("max")) additions.push("max");
242
+ if (!suppressSyntheticMax && !efforts.includes("max")) additions.push("max");
241
243
  if (!efforts.includes("ultra")) additions.push("ultra");
242
244
  if (additions.length > 0) efforts = sanitizeCodexReasoningEfforts([...efforts, ...additions]) ?? efforts;
243
245
  }
@@ -258,9 +260,11 @@ export function applyReasoningLevels(
258
260
  }
259
261
  entry.default_reasoning_level = defaultOverride && efforts.includes(defaultOverride)
260
262
  ? defaultOverride
261
- : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high"
262
- // Sentinels never become the implicit default when real rungs are declared.
263
- : efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0];
263
+ : suppressSyntheticMax && defaultOverride === "max"
264
+ ? clampedDefaultEffort(defaultOverride, efforts)
265
+ : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high"
266
+ // Sentinels never become the implicit default when real rungs are declared.
267
+ : efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0];
264
268
  }
265
269
 
266
270
  /**
@@ -499,6 +499,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco
499
499
  inMod: prov.modelInputModalities ?? null,
500
500
  capabilities: prov.modelCapabilities ?? null,
501
501
  re: prov.modelReasoningEfforts ?? null,
502
+ suppressMax: prov.modelSuppressSyntheticMax ?? null,
502
503
  defRe: prov.modelDefaultReasoningEfforts ?? null,
503
504
  rsSum: prov.modelSupportsReasoningSummaries ?? null,
504
505
  verbosity: prov.modelSupportsVerbosity ?? null,
@@ -296,6 +296,7 @@ export function applyProviderConfigHints(
296
296
  inputModalities = base.includes("image") ? [...base] : [...base, "image"];
297
297
  }
298
298
  const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
299
+ const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) === true;
299
300
  const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
300
301
  const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id);
301
302
  const supportsVerbosity = configuredVerbositySupport(name, prov, model.id);
@@ -305,6 +306,7 @@ export function applyProviderConfigHints(
305
306
  supportsServiceTier: _staleServiceTier,
306
307
  fastTierDescription: _staleFastTierDescription,
307
308
  providerAlias: _staleProviderAlias,
309
+ suppressSyntheticMax: _staleSuppressSyntheticMax,
308
310
  ...modelWithoutServiceTier
309
311
  } = model;
310
312
  // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。
@@ -321,6 +323,7 @@ export function applyProviderConfigHints(
321
323
  ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}),
322
324
  ...(inputModalities ? { inputModalities } : {}),
323
325
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
326
+ ...(suppressSyntheticMax ? { suppressSyntheticMax: true } : {}),
324
327
  ...(configuredMaxInput !== undefined
325
328
  ? {
326
329
  maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0
@@ -394,6 +397,31 @@ export function applyConfigHintsToCachedModels(
394
397
  ): CatalogModel[] {
395
398
  return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias));
396
399
  }
400
+
401
+ /** Catalog slugs whose configured model must not gain a missing synthetic max rung. */
402
+ export function suppressedSyntheticMaxCatalogSlugs(
403
+ config: Pick<OcxConfig, "providers">,
404
+ models: readonly CatalogModel[],
405
+ observedEntries: readonly { slug?: unknown }[] = [],
406
+ ): ReadonlySet<string> {
407
+ const slugs = new Set(models
408
+ .filter(model => model.suppressSyntheticMax === true)
409
+ .map(catalogModelSlug));
410
+ for (const [provider, providerConfig] of Object.entries(config.providers)) {
411
+ const configured = providerConfig.modelSuppressSyntheticMax ?? {};
412
+ const encoded = Object.fromEntries(Object.entries(configured)
413
+ .map(([modelId, suppress]) => [routedSlug(provider, modelId).slice(provider.length + 1), suppress]));
414
+ for (const [modelId, suppress] of Object.entries(configured)) {
415
+ if (suppress === true) slugs.add(routedSlug(provider, modelId));
416
+ }
417
+ for (const entry of observedEntries) {
418
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
419
+ if (!slug.startsWith(`${provider}/`)) continue;
420
+ if (modelRecordValue(encoded, slug.slice(provider.length + 1)) === true) slugs.add(slug);
421
+ }
422
+ }
423
+ return slugs;
424
+ }
397
425
  export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
398
426
 
399
427
  export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly<Record<string, ReadonlySet<string>>> = {
@@ -582,10 +610,11 @@ function discoveredPricingRate(value: unknown): number | undefined {
582
610
  /**
583
611
  * Cost class for one discovered row, read from the provider's own `pricing` object (#3666).
584
612
  *
585
- * Fail closed. Only a complete pair of non-negative numeric rates classifies at all; a missing,
586
- * one-sided, non-numeric, or negative rate is "unknown" and therefore excluded from a free-only
587
- * filter. Showing a paid model under a Free filter spends the user's money, while hiding a free
588
- * one costs a click.
613
+ * Fail closed. Any positive numeric component proves the model is paid. Calling it free requires
614
+ * a complete prompt/completion pair and every published pricing component to be a non-negative
615
+ * numeric zero; an unsupported component is "unknown" because it may describe another charge.
616
+ * Showing a paid model under a Free filter spends the user's money, while hiding a free one costs
617
+ * a click.
589
618
  *
590
619
  * Two things that look like evidence and are not. A `:free` id suffix is an OpenRouter naming
591
620
  * convention, not a price — Nous ships `:free` slugs on a provider whose `freeTier` is false on
@@ -598,10 +627,13 @@ function discoveredPricingRate(value: unknown): number | undefined {
598
627
  export function discoveredPricingStatus(item: ProviderModelsApiItem): "free" | "paid" | "unknown" {
599
628
  const pricing = plainRecord(item.pricing) ?? plainRecord(plainRecord(item.metadata)?.pricing);
600
629
  if (!pricing) return "unknown";
630
+ const rates = Object.values(pricing).map(discoveredPricingRate);
631
+ if (rates.some(rate => rate !== undefined && rate > 0)) return "paid";
632
+ if (rates.some(rate => rate === undefined)) return "unknown";
601
633
  const prompt = discoveredPricingRate(pricing.prompt ?? pricing.input);
602
634
  const completion = discoveredPricingRate(pricing.completion ?? pricing.output);
603
635
  if (prompt === undefined || completion === undefined) return "unknown";
604
- return prompt === 0 && completion === 0 ? "free" : "paid";
636
+ return "free";
605
637
  }
606
638
 
607
639
  export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
@@ -112,6 +112,8 @@ export interface CatalogModel {
112
112
  displayName?: string;
113
113
  owned_by?: string;
114
114
  reasoningEfforts?: string[];
115
+ /** Suppress only catalog synthesis of a missing max rung; provider-declared max survives. */
116
+ suppressSyntheticMax?: boolean;
115
117
  defaultReasoningEffort?: string;
116
118
  contextWindow?: number;
117
119
  maxInputTokens?: number;
@@ -255,12 +257,19 @@ export function readCodexCatalogPath(): string {
255
257
  return activeDefaultCatalogPath();
256
258
  }
257
259
 
258
- /** Resolve the configured catalog without consulting ambient CODEX_HOME again. */
259
- export function readCodexCatalogPathForHome(codexHome: string): string {
260
+ /**
261
+ * Resolve the configured catalog without consulting ambient CODEX_HOME again.
262
+ *
263
+ * `configText` is for a caller that has already read that same `config.toml` under
264
+ * its own constraints - the prompt-text probe reads it bounded, on the request
265
+ * thread - so resolving the catalog does not cost a second, unbounded read of the
266
+ * file the caller is holding. Omitting it keeps the original behaviour.
267
+ */
268
+ export function readCodexCatalogPathForHome(codexHome: string, configText?: string): string {
260
269
  try {
261
270
  const configPath = join(codexHome, "config.toml");
262
- if (existsSync(configPath)) {
263
- const toml = readFileSync(configPath, "utf-8");
271
+ if (configText !== undefined || existsSync(configPath)) {
272
+ const toml = configText ?? readFileSync(configPath, "utf-8");
264
273
  const path = readRootTomlString(toml, "model_catalog_json");
265
274
  if (path) return resolve(codexHome, path);
266
275
  }
@@ -284,6 +293,30 @@ export function readConfiguredAutoReviewModel(): string | null {
284
293
  return null;
285
294
  }
286
295
 
296
+ /**
297
+ * Read the root `model` pin from Codex's config.toml (issue #4646).
298
+ *
299
+ * Codex starts every new session on this id, and nothing in opencodex checks that the id is one
300
+ * the proxy actually exposes: the pin lives in Codex's config, while exposure is decided here by
301
+ * `disabledModels`, provider `selectedModels`, and account entitlements. When the two disagree
302
+ * every turn fails and no surface says why, which is what the `ocx doctor` section added for
303
+ * #4646 reports.
304
+ *
305
+ * Read-only, and deliberately the same shape and the same swallow-and-return-null error policy as
306
+ * `readConfiguredAutoReviewModel` above: a diagnostic must degrade to "unknown" on an unreadable
307
+ * or absent config rather than throw out of the surface that called it.
308
+ */
309
+ export function readConfiguredDefaultModel(): string | null {
310
+ try {
311
+ const configPath = activeCodexConfigPath();
312
+ if (existsSync(configPath)) {
313
+ const toml = readFileSync(configPath, "utf-8");
314
+ return readRootTomlString(toml, "model");
315
+ }
316
+ } catch { /* ignore */ }
317
+ return null;
318
+ }
319
+
287
320
  export function parseCatalogJson(raw: string): RawCatalog | null {
288
321
  try {
289
322
  const cat = JSON.parse(raw);
@@ -627,6 +660,14 @@ export interface MultiAgentModeOptions {
627
660
  * so they can still spawn Grok/Claude — ChatGPT encrypts v2 NEW_TASK bodies.
628
661
  */
629
662
  keepNativeChatGptOnV1?: boolean;
663
+ /**
664
+ * Pristine installed-catalog pins keyed by bare native slug. When provided, the
665
+ * backup — not the bundled snapshot — is authoritative for the rows it contains,
666
+ * and a preserved live/native row outside it keeps the pin it already carries:
667
+ * an absent baseline entry cannot distinguish a stale forced stamp from a
668
+ * legitimate user- or provider-preserved pin, so the non-destructive read wins.
669
+ */
670
+ nativeDefaults?: ReadonlyMap<string, string | null>;
630
671
  }
631
672
 
632
673
  /** Catalog rows that run on the ChatGPT backend (encrypt v2 child tasks). */
@@ -707,13 +748,34 @@ export function applyMultiAgentMode(
707
748
  && hasNativeOpenAiCapabilityMetadata(routedNativeSlug)
708
749
  ? routedNativeSlug
709
750
  : undefined;
751
+ const accountBoundNativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
752
+ const nativeLookupSlug = accountBoundNativeSlug ?? slug;
753
+ // The baseline is built from bare native slugs only, so "absent from the
754
+ // baseline" is not evidence about a routed row — it is guaranteed. Only a
755
+ // native row can carry a pin the baseline legitimately failed to mention;
756
+ // a routed row keeps the documented default-mode normalization.
757
+ const isNativeCatalogEntry = accountBoundNativeSlug !== undefined || !slug.includes("/");
758
+ const hasNativeDefault = !nativeAlias
759
+ && codexForwardCapabilityAlias === undefined
760
+ && options.nativeDefaults?.has(nativeLookupSlug) === true;
710
761
  const upstreamPin = nativeAlias
711
762
  ? nativeMultiAgentVersion(slug)
712
763
  : codexForwardCapabilityAlias
713
764
  ? nativeMultiAgentVersion(codexForwardCapabilityAlias)
714
- : UPSTREAM_NATIVE_ENTRIES.get(trustedAccountBoundNativeCatalogSlug(entry) ?? slug)?.multi_agent_version;
765
+ : hasNativeDefault
766
+ ? options.nativeDefaults?.get(nativeLookupSlug)
767
+ : options.nativeDefaults === undefined
768
+ ? UPSTREAM_NATIVE_ENTRIES.get(nativeLookupSlug)?.multi_agent_version
769
+ : undefined;
715
770
  if (typeof upstreamPin === "string") {
716
771
  entry.multi_agent_version = upstreamPin;
772
+ } else if (options.nativeDefaults !== undefined
773
+ && !nativeAlias
774
+ && codexForwardCapabilityAlias === undefined
775
+ && isNativeCatalogEntry
776
+ && !hasNativeDefault
777
+ && typeof entry.multi_agent_version === "string") {
778
+ continue;
717
779
  } else if (v2FeatureEnabled) {
718
780
  entry.multi_agent_version = "v2";
719
781
  } else {
@@ -888,3 +950,19 @@ export function readNativeBaseline(catalogPath: string): Map<string, number> {
888
950
  }
889
951
  return out;
890
952
  }
953
+
954
+ /**
955
+ * Extract the pristine baseline's per-slug multi-agent pins. A bare native row that
956
+ * carried no pin maps to null so "baseline says unpinned" stays distinguishable
957
+ * from "baseline never contained this row".
958
+ */
959
+ export function nativeMultiAgentDefaults(
960
+ models: readonly Readonly<Record<string, unknown>>[] | null | undefined,
961
+ ): Map<string, string | null> {
962
+ const out = new Map<string, string | null>();
963
+ for (const entry of models ?? []) {
964
+ if (typeof entry.slug !== "string" || entry.slug.includes("/")) continue;
965
+ out.set(entry.slug, typeof entry.multi_agent_version === "string" ? entry.multi_agent_version : null);
966
+ }
967
+ return out;
968
+ }
@@ -0,0 +1,96 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import { isEffectiveCodexDesktopAuthless } from "../loopback-target";
3
+ import { NATIVE_RESERVE_MODEL } from "./native-models";
4
+
5
+ /**
6
+ * #4811: why an opted-in install still gets no Luna Reserve row.
7
+ *
8
+ * The Reserve projection is account-qualified — its slug is `<selector>/gpt-reserve` — so it
9
+ * cannot be written without a selector that targets the main Codex account. On a fresh authless
10
+ * install `codexAccountNamespaces` is empty, the account picker is therefore off, no selectors
11
+ * exist, and `createReserveCatalogProjection` returns `undefined`. Nothing downstream can
12
+ * explain that: an omitted row has no place to carry a reason, which is exactly the shape of
13
+ * #4212 for account-gated natives, and the same answer applies — say it once, here, while the
14
+ * inputs that produced the omission are still in scope.
15
+ *
16
+ * Deliberately scoped to an operator who asked for the feature. A default install has
17
+ * `codexDesktopAuthless` unset, wants no Reserve row, and must not be told about one. An install
18
+ * that stores the flag on a bind where it cannot take effect is a different silence, already
19
+ * reported as `inertReason` by `describeCodexDesktopSwitches`, and is not repeated here.
20
+ */
21
+ export type ReserveCatalogSuppressionReason =
22
+ | "no-canonical-openai-provider"
23
+ | "account-picker-disabled"
24
+ | "no-account-namespaces"
25
+ | "no-main-account-selector";
26
+
27
+ type ReserveSuppressionConfig = Pick<
28
+ OcxConfig,
29
+ | "runtimeRole"
30
+ | "hostname"
31
+ | "unauthenticatedLoopbackListener"
32
+ | "codexDesktopAuthless"
33
+ | "codexAccountNamespaces"
34
+ | "codexAccountPickerEnabled"
35
+ >;
36
+
37
+ export interface ReserveSuppressionInput {
38
+ /** Already resolved by the caller; passed in so this module stays off the metadata graph. */
39
+ includeAccountBoundNativeOpenAi: boolean;
40
+ /** Selectors that resolved to the main Codex account. A non-empty list means no suppression. */
41
+ mainSelectors: readonly string[];
42
+ }
43
+
44
+ export function reserveCatalogSuppressionReason(
45
+ config: ReserveSuppressionConfig,
46
+ input: ReserveSuppressionInput,
47
+ ): ReserveCatalogSuppressionReason | undefined {
48
+ if (!isEffectiveCodexDesktopAuthless(config) || input.mainSelectors.length > 0) return undefined;
49
+ if (!input.includeAccountBoundNativeOpenAi) return "no-canonical-openai-provider";
50
+ // Checked before the empty-map case: when an operator has explicitly turned the picker off,
51
+ // naming the empty map would send them to hand-author selectors that the opt-in generates.
52
+ if (config.codexAccountPickerEnabled === false) return "account-picker-disabled";
53
+ if (Object.keys(config.codexAccountNamespaces ?? {}).length === 0) return "no-account-namespaces";
54
+ return "no-main-account-selector";
55
+ }
56
+
57
+ const SUPPRESSION_TEXT: Readonly<Record<ReserveCatalogSuppressionReason, {
58
+ cause: string;
59
+ action: string;
60
+ }>> = {
61
+ "no-canonical-openai-provider": {
62
+ cause: "no enabled canonical OpenAI provider can serve account-qualified native routes",
63
+ action: "Enable the built-in \"openai\" provider with forwarded ChatGPT auth.",
64
+ },
65
+ "account-picker-disabled": {
66
+ cause: "the Codex account picker is turned off (codexAccountPickerEnabled is false)",
67
+ action: "Set codexAccountPickerEnabled to true to generate the default account selectors.",
68
+ },
69
+ "no-account-namespaces": {
70
+ cause: "no Codex account selectors are configured (codexAccountNamespaces is empty)",
71
+ action: "Enable the Codex account picker once to generate the default account selectors.",
72
+ },
73
+ "no-main-account-selector": {
74
+ cause: "no configured selector in codexAccountNamespaces targets the main Codex account",
75
+ action: "Map one selector to the main Codex account.",
76
+ },
77
+ };
78
+
79
+ const warnedReserveSuppression = new Set<string>();
80
+
81
+ /** Test seam: the warn-once memory is process-global, so a case needs to be able to clear it. */
82
+ export function resetReserveSuppressionWarningsForTests(): void {
83
+ warnedReserveSuppression.clear();
84
+ }
85
+
86
+ export function warnReserveSuppressedOnce(reason: ReserveCatalogSuppressionReason): void {
87
+ if (warnedReserveSuppression.has(reason)) return;
88
+ warnedReserveSuppression.add(reason);
89
+ const text = SUPPRESSION_TEXT[reason];
90
+ console.warn(
91
+ `[opencodex] catalog sync: authless Codex Desktop routing is on, but the Luna Reserve row `
92
+ + `(<selector>/${NATIVE_RESERVE_MODEL}) is not being written because ${text.cause}. `
93
+ + `The Reserve projection is account-qualified and needs a selector for the main Codex `
94
+ + `account. ${text.action}`,
95
+ );
96
+ }