@bitkyc08/opencodex 2.42.0 → 2.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -1,3 +1,5 @@
1
+ import { effectiveProviderAlias } from "../../providers/default-aliases";
2
+ import { pendingModelSelectionProviders } from "../../providers/initial-model-selection";
1
3
  import { execFileSync } from "node:child_process";
2
4
  import { createHash } from "node:crypto";
3
5
  import { existsSync, readFileSync } from "node:fs";
@@ -73,7 +75,15 @@ import {
73
75
  } from "../internal/catalog-writer";
74
76
  import { codexRuntimeStatePath } from "../runtime";
75
77
  import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";
76
- import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./native-models";
78
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
79
+ import { observedReserveCatalogSource } from "./metadata";
80
+ import {
81
+ createReserveCatalogProjection,
82
+ isReserveCatalogProjection,
83
+ RESERVE_LUNA_METADATA_SOURCE,
84
+ RESERVE_SOURCE_CATALOG_FIELD,
85
+ type ReserveCatalogProjection,
86
+ } from "./reserve";
77
87
 
78
88
  export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
79
89
 
@@ -267,17 +277,30 @@ function isExactComboCatalogEntry(
267
277
  * ids differ by a single dash (`command-code` vs `commandcode`), so relabel them to the
268
278
  * lowercase-dash style the opencode presets use: `commandcode-auth/x` and `commandcode-api/x`.
269
279
  * The model-id portion also carries a redundant `<vendor>-` prefix (`deepseek-deepseek-v4-flash`)
270
- * that is dropped for display. All other providers keep the raw slug exactly as before.
280
+ * that is dropped for display. Google Antigravity is relabeled to the compact `agy/` prefix for
281
+ * the same reason: `google-antigravity/` alone consumes most of the picker row. That prefix comes
282
+ * from the row's own `providerAlias`, decided once per gather flight; `null` means a cross-provider
283
+ * collision suppressed it and the canonical slug stands. This is the raw-slug path only -- a
284
+ * configured `modelAliases` entry is labeled by the effective-alias path in
285
+ * catalog/provider-fetch.ts (#2960) and keeps the canonical provider name. All other providers
286
+ * keep the raw slug exactly as before.
271
287
  */
272
- function routedDisplayName(slug: string): string {
288
+ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick<OcxConfig, "providers">): string {
273
289
  const slash = slug.indexOf("/");
274
290
  if (slash <= 0) return slug;
275
291
  const provider = slug.slice(0, slash);
276
- let model = slug.slice(slash + 1);
292
+ let modelId = slug.slice(slash + 1);
293
+ if (provider === "google-antigravity") {
294
+ if (model?.providerAlias === null) return slug;
295
+ const alias = (typeof model?.providerAlias === "string" && model.providerAlias.trim().length > 0)
296
+ ? model.providerAlias.trim()
297
+ : effectiveProviderAlias(provider, undefined, config);
298
+ return alias ? `${alias}/${modelId}` : slug;
299
+ }
277
300
  if (provider === "command-code" || provider === "commandcode") {
278
- const m = model.match(/^([a-z0-9]+)-([a-z0-9]+(?:-[a-z0-9]+)+)$/i);
279
- if (m && model.startsWith(`${m[1]}-${m[1]}-`)) model = model.slice(m[1]!.length + 1);
280
- return `${provider === "command-code" ? "commandcode-auth" : "commandcode-api"}/${model}`;
301
+ const m = modelId.match(/^([a-z0-9]+)-([a-z0-9]+(?:-[a-z0-9]+)+)$/i);
302
+ if (m && modelId.startsWith(`${m[1]}-${m[1]}-`)) modelId = modelId.slice(m[1]!.length + 1);
303
+ return `${provider === "command-code" ? "commandcode-auth" : "commandcode-api"}/${modelId}`;
281
304
  }
282
305
  return slug;
283
306
  }
@@ -306,7 +329,7 @@ export function deriveEntry(
306
329
  if (template || codexForwardNativeCapabilityAlias) {
307
330
  const e = JSON.parse(JSON.stringify(codexForwardNativeCapabilityAlias ?? template)) as RawEntry;
308
331
  e.slug = slug;
309
- e.display_name = routedDisplayName(slug);
332
+ e.display_name = routedDisplayName(slug, model);
310
333
  e.description = desc;
311
334
  e.priority = priority;
312
335
  e.visibility = "list";
@@ -375,7 +398,7 @@ export function deriveEntry(
375
398
  // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar.
376
399
  const isCursorFallback = isRouted && model?.provider === "cursor";
377
400
  const entry: RawEntry = {
378
- slug, display_name: routedDisplayName(slug), description: desc,
401
+ slug, display_name: routedDisplayName(slug, model), description: desc,
379
402
  shell_type: "unified_exec", visibility: "list", supported_in_api: true,
380
403
  priority, base_instructions: "You are a helpful coding assistant.",
381
404
  ...(isRouted
@@ -422,6 +445,8 @@ export interface ObservedCatalogEntryBuildInput {
422
445
  readonly accountNativeSlugs?: readonly string[];
423
446
  /** Per-selector account ids; unknown observations must not be copied to unrelated accounts. */
424
447
  readonly accountNativeSlugsBySelector?: ReadonlyMap<string, readonly string[]>;
448
+ /** Codex-only manual selector metadata; deliberately independent of live permission. */
449
+ readonly reserve?: ReserveCatalogProjection;
425
450
  }
426
451
 
427
452
  /** Build entries with the process-observed Codex feature state. */
@@ -478,6 +503,7 @@ export function buildCatalogEntriesFromObservedState({
478
503
  openaiContextCap,
479
504
  accountNativeSlugs,
480
505
  accountNativeSlugsBySelector,
506
+ reserve,
481
507
  }: ObservedCatalogEntryBuildInput): RawEntry[] {
482
508
  // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
483
509
  // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
@@ -574,15 +600,17 @@ export function buildCatalogEntriesFromObservedState({
574
600
  const selectorNativeSlugs = accountNativeSlugsBySelector?.get(selector)
575
601
  ?? accountNativeSlugs
576
602
  ?? gptSlugs;
577
- const accountNativeEntries = selectorNativeSlugs.map(slug => (
603
+ const accountNativeEntries = selectorNativeSlugs.filter(slug => slug !== NATIVE_RESERVE_MODEL).map(slug => (
578
604
  nativeEntriesBySlug.get(slug)
579
605
  ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap)
580
606
  ));
607
+ if (reserve?.mainSelectors.includes(selector)) accountNativeEntries.push(reserve.source);
581
608
  for (const [nativeIndex, native] of accountNativeEntries.entries()) {
582
609
  const nativeSlug = String(native.slug);
583
610
  if (disabledNativeAccountSlugs.has(nativeSlug)) continue;
584
611
  const e = JSON.parse(JSON.stringify(native)) as RawEntry;
585
612
  const catalogSlug = `${selector}/${nativeSlug}`;
613
+ if (nativeSlug === NATIVE_RESERVE_MODEL && disabledNativeAccountSlugs.has(catalogSlug)) continue;
586
614
  e.slug = catalogSlug;
587
615
  e.display_name = accountBoundNativeDisplayName(selector, native);
588
616
  // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged.
@@ -656,6 +684,7 @@ export function buildCatalogEntriesFromObservedState({
656
684
  }
657
685
  return applyMultiAgentMode(out, multiAgentMode, multiAgentV2Enabled, {
658
686
  keepNativeChatGptOnV1,
687
+ preserveDefaultMultiAgentVersion: isReserveCatalogProjection,
659
688
  });
660
689
  }
661
690
 
@@ -761,6 +790,7 @@ export interface ObservedCatalogMergeInput {
761
790
  readonly disabledModels: ReadonlySet<string>;
762
791
  readonly selectedModelsByProvider: ReadonlyMap<string, ReadonlySet<string>>;
763
792
  readonly gatheredProviderNames: ReadonlySet<string>;
793
+ readonly pendingProviderNames?: ReadonlySet<string>;
764
794
  readonly degradedProviderNames: ReadonlySet<string>;
765
795
  readonly legacyCustomModelSlugs: ReadonlySet<string>;
766
796
  readonly multiAgentMode: MultiAgentMode;
@@ -792,6 +822,7 @@ export function mergeCatalogEntriesFromObservedState({
792
822
  disabledModels,
793
823
  selectedModelsByProvider,
794
824
  gatheredProviderNames,
825
+ pendingProviderNames = new Set(),
795
826
  degradedProviderNames,
796
827
  legacyCustomModelSlugs,
797
828
  multiAgentMode,
@@ -841,6 +872,7 @@ export function mergeCatalogEntriesFromObservedState({
841
872
  if (disabledModelKeys.has(key)) return false;
842
873
  const slash = slug.indexOf("/");
843
874
  const provider = slug.slice(0, slash);
875
+ if (pendingProviderNames.has(provider)) return false;
844
876
  const selected = selectedModelKeysByProvider.get(provider);
845
877
  if (selected !== undefined && !selected.has(key)) return false;
846
878
  return !gatheredProviderNames.has(provider) || degradedProviderNames.has(provider);
@@ -946,7 +978,7 @@ export function mergeCatalogEntriesFromObservedState({
946
978
  const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) });
947
979
  // Older natives kept from disk still need the mock top tiers (max + ultra always
948
980
  // for subagent max spawns; wire-clamped to the model's real top rung).
949
- if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved);
981
+ if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved);
950
982
  return preserved;
951
983
  })
952
984
  : [];
@@ -981,6 +1013,9 @@ export function mergeCatalogEntriesFromObservedState({
981
1013
  typeof entry.slug === "string" ? [[entry.slug, entry] as const] : []
982
1014
  ));
983
1015
  const alignedAccountBoundEntries = detachedAccountBoundEntries.map(entry => {
1016
+ // The explicit Reserve source is already chosen (actual row or documented Luna adaptation).
1017
+ // A generic native merge must not replace its provenance or capability ladder.
1018
+ if (isReserveCatalogProjection(entry)) return entry;
984
1019
  const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
985
1020
  const source = nativeSlug === undefined ? undefined : nativeSourceBySlug.get(nativeSlug);
986
1021
  if (!source) return entry;
@@ -1036,6 +1071,7 @@ export function mergeCatalogEntriesFromObservedState({
1036
1071
  if (freshExactComboEntries.has(entry)) return true;
1037
1072
  const slash = slug.indexOf("/");
1038
1073
  const provider = slug.slice(0, slash);
1074
+ if (pendingProviderNames.has(provider)) return false;
1039
1075
  const selected = selectedModelKeysByProvider.get(provider);
1040
1076
  return selected === undefined || selected.has(slugEquivalenceKey(slug));
1041
1077
  });
@@ -1087,17 +1123,18 @@ export function mergeCatalogEntriesFromObservedState({
1087
1123
  }));
1088
1124
  for (const slug of policy.nativeBackfillSlugs) observedNativeSlugs.add(slug);
1089
1125
  const mergedEntries = [...native, ...managedEntries].map(m => {
1090
- const normalized = normalizeServiceTiers(m);
1091
- if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap);
1126
+ const reserveProjection = isReserveCatalogProjection(m);
1127
+ const normalized = reserveProjection ? m : normalizeServiceTiers(m);
1128
+ if (!reserveProjection && !isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap);
1092
1129
  const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs);
1093
- const e = ensureStrictCatalogFields(normalized, {
1130
+ const e = reserveProjection ? normalized : ensureStrictCatalogFields(normalized, {
1094
1131
  preserveExactInputModalities: exactCombo,
1095
1132
  isRouted: finalRoutedEntrySet.has(m),
1096
1133
  });
1097
1134
  // Mock-max universality (260709): preserved routed entries from disk may predate
1098
1135
  // the max rung — ensure it here so subagent max spawns validate on every
1099
1136
  // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact.
1100
- if (!exactCombo) {
1137
+ if (!exactCombo && !reserveProjection) {
1101
1138
  const levels = Array.isArray(e.supported_reasoning_levels)
1102
1139
  ? e.supported_reasoning_levels as Array<{ effort?: string }>
1103
1140
  : [];
@@ -1122,7 +1159,7 @@ export function mergeCatalogEntriesFromObservedState({
1122
1159
  applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs),
1123
1160
  multiAgentMode,
1124
1161
  multiAgentV2Enabled,
1125
- { keepNativeChatGptOnV1 },
1162
+ { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection },
1126
1163
  );
1127
1164
  for (const entry of versionedEntries) {
1128
1165
  const kind = entry.opencodex_catalog_kind;
@@ -1610,6 +1647,36 @@ function writeRetainedCatalogSync({
1610
1647
  trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
1611
1648
  ];
1612
1649
  const accountTargets = new Map(codexAccountNamespaceEntries(config));
1650
+ const reserveMainSelectors = accountSelectors.filter(selector =>
1651
+ isMainCodexAccountTarget(accountTargets.get(selector) ?? ""));
1652
+ // The active file can own a bare source even when the bundled catalog is the build base.
1653
+ // A previously clamped qualified projection must not shorten a retained genuine ladder.
1654
+ const reserveObservations = [
1655
+ ...(onDiskCatalog?.models ?? []),
1656
+ ...(read.modelsCache?.models ?? []),
1657
+ ...(catalog.models ?? []),
1658
+ ];
1659
+ const retainedReserve = onDiskCatalog?.[RESERVE_SOURCE_CATALOG_FIELD];
1660
+ const retainedReserveSource = retainedReserve && typeof retainedReserve === "object" && !Array.isArray(retainedReserve)
1661
+ ? observedReserveCatalogSource([retainedReserve as RawEntry], [])
1662
+ : null;
1663
+ const observedReserveSource = observedReserveCatalogSource(
1664
+ // Cache invalidation carries historical bare observations alongside emitted models.
1665
+ // Only unmarked observations are fresh enough to supersede the retained source.
1666
+ reserveObservations.filter(entry => entry.slug === NATIVE_RESERVE_MODEL
1667
+ && entry.opencodex_account_observed_native === undefined), reserveMainSelectors,
1668
+ ) ?? retainedReserveSource ?? observedReserveCatalogSource(reserveObservations, reserveMainSelectors);
1669
+ // This root is read only by OCX. Upstream ModelsResponse ignores unknown root fields.
1670
+ // Retain before final runtime clamping: an omitted row must not turn into Luna next sync.
1671
+ if (observedReserveSource) catalog[RESERVE_SOURCE_CATALOG_FIELD] = structuredClone(observedReserveSource);
1672
+ else delete catalog[RESERVE_SOURCE_CATALOG_FIELD];
1673
+ const lunaSource = upstreamNativeEntry(RESERVE_LUNA_METADATA_SOURCE);
1674
+ const reserve = createReserveCatalogProjection(
1675
+ config,
1676
+ reserveMainSelectors,
1677
+ observedReserveSource,
1678
+ lunaSource ? finishUpstreamNativeEntry(lunaSource, 9, openaiContextCap) : null,
1679
+ );
1613
1680
  const accountNativeSlugsBySelector = accountSelectors.length > 0
1614
1681
  ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => {
1615
1682
  const target = accountTargets.get(selector);
@@ -1691,6 +1758,7 @@ function writeRetainedCatalogSync({
1691
1758
  openaiContextCap,
1692
1759
  accountNativeSlugs,
1693
1760
  accountNativeSlugsBySelector,
1761
+ reserve,
1694
1762
  }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined)
1695
1763
  : [];
1696
1764
  catalog.models = mergeCatalogEntriesFromObservedState({
@@ -1704,6 +1772,7 @@ function writeRetainedCatalogSync({
1704
1772
  disabledModels: new Set(config.disabledModels ?? []),
1705
1773
  selectedModelsByProvider,
1706
1774
  gatheredProviderNames,
1775
+ pendingProviderNames: pendingModelSelectionProviders(config),
1707
1776
  degradedProviderNames,
1708
1777
  legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config),
1709
1778
  multiAgentMode,
@@ -1806,6 +1875,10 @@ export async function syncCatalogModels(
1806
1875
  config: OcxConfig,
1807
1876
  options?: CodexCatalogSyncOptions,
1808
1877
  ): Promise<RetainedCatalogSyncResult> {
1878
+ if (pendingModelSelectionProviders(config).size) {
1879
+ const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime");
1880
+ await resolvePendingInitialModelSelection(config);
1881
+ }
1809
1882
  const owningCodexHome = getCodexHome();
1810
1883
  const preflightRead = readRetainedCatalogSync(config);
1811
1884
  if (preflightRead === null) {
@@ -429,6 +429,7 @@ export interface CatalogTrustedOpenAiApiPolicySnapshot {
429
429
  readonly models?: readonly string[];
430
430
  readonly modelContextWindows?: Readonly<Record<string, number>>;
431
431
  readonly modelMaxInputTokens?: Readonly<Record<string, number>>;
432
+ readonly modelMaxOutputTokens?: Readonly<Record<string, number>>;
432
433
  readonly virtualModels?: Readonly<Record<string, Readonly<{ wireModelId: string; reasoningMode: "pro" }>>>;
433
434
  readonly modelInputModalities?: Readonly<Record<string, readonly string[]>>;
434
435
  readonly modelReasoningEfforts?: Readonly<Record<string, readonly string[]>>;
@@ -2,6 +2,7 @@ import { join } from "node:path";
2
2
 
3
3
  import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
4
4
  import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy";
5
+ import { pendingModelSelectionProviders } from "../providers/initial-model-selection";
5
6
  import { COMBO_NAMESPACE } from "../combos";
6
7
  import { getAuthStorePath } from "../oauth/store";
7
8
  import type { OcxConfig } from "../types";
@@ -351,6 +352,7 @@ function prepareCatalog(
351
352
  disabledModels: new Set(config.disabledModels ?? []),
352
353
  selectedModelsByProvider,
353
354
  gatheredProviderNames,
355
+ pendingProviderNames: pendingModelSelectionProviders(config),
354
356
  degradedProviderNames,
355
357
  legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config),
356
358
  multiAgentMode,
@@ -1018,7 +1018,7 @@
1018
1018
  {
1019
1019
  "id": "priority",
1020
1020
  "name": "Fast",
1021
- "description": "1.5x speed, increased usage"
1021
+ "description": "2x speed, increased usage"
1022
1022
  }
1023
1023
  ],
1024
1024
  "additional_speed_tiers": [
@@ -0,0 +1,25 @@
1
+ /** Native request metadata shared by the HTTP adapter and WS preparation. */
2
+ export const CODEX_RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite";
3
+ export const CODEX_RESPONSES_LITE_METADATA_KEY = "ws_request_header_x_openai_internal_codex_responses_lite";
4
+ export const CODEX_ROUTING_HINT_HEADER = "x-codex-routing-hint";
5
+
6
+ const MAX_ROUTING_MODEL_BYTES = 256;
7
+ const MAX_ROUTING_TIER_BYTES = 64;
8
+
9
+ function isRoutingHintComponent(value: unknown, maxBytes: number): value is string {
10
+ // Printable non-whitespace ASCII makes code-unit length equal to byte length.
11
+ return typeof value === "string" && value.length > 0 && value.length <= maxBytes
12
+ && /^[\x21-\x7e]+$/.test(value) && !/[;=]/.test(value);
13
+ }
14
+
15
+ /** The final wire body is authoritative; an invalid component never revives a stale hint. */
16
+ export function applyCodexRoutingHint(headers: Headers, body: unknown): void {
17
+ headers.delete(CODEX_ROUTING_HINT_HEADER);
18
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return;
19
+ const record = body as Record<string, unknown>;
20
+ if (!isRoutingHintComponent(record.model, MAX_ROUTING_MODEL_BYTES)) return;
21
+ const tier = record.service_tier;
22
+ if (tier !== undefined && !isRoutingHintComponent(tier, MAX_ROUTING_TIER_BYTES)) return;
23
+ headers.set(CODEX_ROUTING_HINT_HEADER,
24
+ `model=${record.model}${tier === undefined ? "" : `;tier=${tier}`}`);
25
+ }
@@ -77,6 +77,9 @@ import {
77
77
  type ManagedSubagentDefaults,
78
78
  } from "./subagent-defaults";
79
79
  import type { OcxConfig } from "../types";
80
+ import { isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
81
+
82
+ export { isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
80
83
 
81
84
  // Ownership predicates live in `./injected-marker` so `journal.ts` can reach them
82
85
  // without importing this module back. Re-exported for existing external callers.
@@ -233,23 +236,6 @@ function configuredManagedSubagentDefaults(
233
236
  * whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex
234
237
  * never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider.
235
238
  */
236
- /**
237
- * True only for hostnames that bind loopback ONLY. Wildcard binds ("0.0.0.0", "::") are NOT
238
- * loopback: they expose the proxy on every interface and therefore require the admission token.
239
- * Do not use `providerBaseHost` for this decision — it folds wildcards to 127.0.0.1 because it
240
- * answers "what address do I dial", which is a different question from "is this exposed".
241
- */
242
- export function isLoopbackHostname(hostname: string | undefined): boolean {
243
- const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
244
- return (
245
- normalized === "" ||
246
- normalized === "localhost" ||
247
- normalized === "127.0.0.1" ||
248
- normalized === "::1" ||
249
- normalized === "[::1]"
250
- );
251
- }
252
-
253
239
  export function providerBaseHost(hostname: string | undefined): string {
254
240
  const trimmed = (hostname ?? "127.0.0.1").trim();
255
241
  const lower = trimmed.toLowerCase();
@@ -267,17 +253,6 @@ export function providerBaseHost(hostname: string | undefined): string {
267
253
  return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
268
254
  }
269
255
 
270
- export function shouldInjectApiAuthHeader(
271
- config: Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener"> | undefined,
272
- ): boolean {
273
- // The unauthenticated loopback listener is a loopback bind, so it admits without a
274
- // credential (#1102). Emitting the env header anyway would be worse than useless: the
275
- // directly-spawned app-server this exists for has no OPENCODEX_API_AUTH_TOKEN in its
276
- // environment, and Codex would send an empty header value.
277
- if (config?.unauthenticatedLoopbackListener?.enabled) return false;
278
- return !isLoopbackHostname(config?.hostname);
279
- }
280
-
281
256
  export function buildProviderTableBlock(
282
257
  port: number,
283
258
  supportsWebsockets?: boolean,
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveCodexHomeDir } from "./home";
4
+ import { parseTomlDocument } from "./project-config-warnings";
5
+ import { redactUserPath } from "../lib/redact";
6
+
7
+ /**
8
+ * Keys that are valid in model catalog data but not at the top level of the
9
+ * user config. Codex rejects them when strict config parsing is enabled.
10
+ */
11
+ const LEGACY_KEYS = ["persistent_instructions"] as const;
12
+
13
+ export type LegacyCodexConfigKey = (typeof LEGACY_KEYS)[number];
14
+
15
+ export interface LegacyCodexConfigKeyDiagnostic {
16
+ readonly path: string;
17
+ readonly code: LegacyCodexConfigKey;
18
+ readonly detail: string;
19
+ }
20
+
21
+ export type LegacyCodexConfigKeyDiagnosticsResult =
22
+ | { readonly status: "available"; readonly path: string; readonly diagnostics: readonly LegacyCodexConfigKeyDiagnostic[] }
23
+ | { readonly status: "unavailable"; readonly path: string; readonly reason: "read_failed" | "not_a_file" };
24
+
25
+ function resolveCodexConfigPath(codexConfigPath?: string): string {
26
+ if (codexConfigPath) return codexConfigPath;
27
+ return join(resolveCodexHomeDir(), "config.toml");
28
+ }
29
+
30
+ export function collectLegacyCodexConfigKeyDiagnostics(
31
+ options: { codexConfigPath?: string } = {},
32
+ ): LegacyCodexConfigKeyDiagnosticsResult {
33
+ const path = resolveCodexConfigPath(options.codexConfigPath);
34
+ if (!existsSync(path)) return { status: "available", path, diagnostics: [] };
35
+ let content: string;
36
+ try {
37
+ if (!statSync(path).isFile()) return { status: "unavailable", path, reason: "not_a_file" };
38
+ content = readFileSync(path, "utf-8");
39
+ } catch { // no-excuse-ok: catch -- optional doctor diagnostics must not fail on an unreadable file.
40
+ return { status: "unavailable", path, reason: "read_failed" };
41
+ }
42
+ const { root } = parseTomlDocument(content);
43
+ const found: LegacyCodexConfigKeyDiagnostic[] = [];
44
+ for (const key of LEGACY_KEYS) {
45
+ if (key in root) {
46
+ found.push({
47
+ path,
48
+ code: key,
49
+ detail: `top-level '${key}' is not a valid Codex config key. `
50
+ + "codex --strict-config rejects the whole file. Remove the key and put durable guidance in AGENTS.md.",
51
+ });
52
+ }
53
+ }
54
+ return { status: "available", path, diagnostics: found };
55
+ }
56
+
57
+ export function formatLegacyCodexConfigKeyDiagnosticsForDoctor(
58
+ result: LegacyCodexConfigKeyDiagnosticsResult,
59
+ ): string[] {
60
+ const displayPath = redactUserPath(result.path);
61
+ if (result.status === "unavailable") {
62
+ return [` -- Codex config at ${displayPath} could not be read (${result.reason}); legacy-key check skipped`];
63
+ }
64
+ if (result.diagnostics.length === 0) {
65
+ return [" ok no unsupported legacy top-level keys in the Codex config"];
66
+ }
67
+ return result.diagnostics.map(diagnostic => `[WARN] ${redactUserPath(diagnostic.path)}: ${diagnostic.detail}`);
68
+ }
@@ -0,0 +1,137 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ interface CurrentLogColumn {
4
+ name: string;
5
+ type: string;
6
+ notnull: number;
7
+ defaultValue: string | null;
8
+ pk: number;
9
+ }
10
+
11
+ // Pinned to Codex logs migration 0002. Keep this schema private: inspection reports
12
+ // compatibility, not column names, so sensitive payload-bearing fields never leak through
13
+ // the management API. Any additive/rebuilt future schema is monitor-only until reviewed.
14
+ const CURRENT_LOG_SCHEMA: readonly CurrentLogColumn[] = [
15
+ { name: "id", type: "INTEGER", notnull: 0, defaultValue: null, pk: 1 },
16
+ { name: "ts", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
17
+ { name: "ts_nanos", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
18
+ { name: "level", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
19
+ { name: "target", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
20
+ { name: "feedback_log_body", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
21
+ { name: "module_path", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
22
+ { name: "file", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
23
+ { name: "line", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 },
24
+ { name: "thread_id", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
25
+ { name: "process_uuid", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
26
+ { name: "estimated_bytes", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 },
27
+ ] as const;
28
+
29
+ const CURRENT_LOG_TABLE_SQL = `CREATE TABLE logs (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ ts INTEGER NOT NULL,
32
+ ts_nanos INTEGER NOT NULL,
33
+ level TEXT NOT NULL,
34
+ target TEXT NOT NULL,
35
+ feedback_log_body TEXT,
36
+ module_path TEXT,
37
+ file TEXT,
38
+ line INTEGER,
39
+ thread_id TEXT,
40
+ process_uuid TEXT,
41
+ estimated_bytes INTEGER NOT NULL DEFAULT 0
42
+ )`;
43
+
44
+ const CURRENT_LOG_INDEX_SQL = {
45
+ idx_logs_ts: "CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC)",
46
+ idx_logs_thread_id: "CREATE INDEX idx_logs_thread_id ON logs(thread_id)",
47
+ idx_logs_thread_id_ts: "CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC)",
48
+ idx_logs_process_uuid_threadless_ts: `CREATE INDEX idx_logs_process_uuid_threadless_ts
49
+ ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC)
50
+ WHERE thread_id IS NULL`,
51
+ } as const;
52
+
53
+
54
+ export interface ColumnRow {
55
+ cid: number;
56
+ name: string;
57
+ type: string;
58
+ notnull: number;
59
+ dflt_value: string | null;
60
+ pk: number;
61
+ }
62
+ interface SchemaObjectRow { name: string; type: string; sql: string | null }
63
+
64
+ function normalizeDeclaredType(type: string): string {
65
+ return String(type ?? "").trim().toUpperCase();
66
+ }
67
+
68
+ function normalizeDefault(value: string | null): string | null {
69
+ return value === null ? null : String(value).trim();
70
+ }
71
+
72
+ function normalizeSchemaSql(sql: string | null | undefined): string {
73
+ return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " ");
74
+ }
75
+
76
+ function sameColumns(columns: ColumnRow[]): boolean {
77
+ if (columns.length !== CURRENT_LOG_SCHEMA.length) return false;
78
+ return columns.every((column, index) => {
79
+ const expected = CURRENT_LOG_SCHEMA[index];
80
+ return column.cid === index
81
+ && column.name === expected.name
82
+ && normalizeDeclaredType(column.type) === expected.type
83
+ && Number(column.notnull) === expected.notnull
84
+ && normalizeDefault(column.dflt_value) === expected.defaultValue
85
+ && Number(column.pk) === expected.pk;
86
+ });
87
+ }
88
+
89
+ /**
90
+ * The authoritative compatibility predicate: exact table SQL, exact column
91
+ * metadata, and every canonical index.
92
+ *
93
+ * Exported because the mutation paths must apply the SAME test inside their
94
+ * write transaction. They used to check column NAMES only, which is strictly
95
+ * weaker than what the inspector reports, so a schema change landing between
96
+ * the outer inspection and the locked write let Protect install a row-dropping
97
+ * trigger and let Reclaim vacuum pages on a database the inspector classifies
98
+ * as monitor-only. The lock serializes OpenCodex against itself; it does not
99
+ * stop Codex or another SQLite writer, so that TOCTOU window is real.
100
+ */
101
+ export function hasCurrentLogsSchema(db: Database): boolean {
102
+ const columns = db.query<ColumnRow, []>("PRAGMA table_info(logs)").all();
103
+ return hasCurrentLogsTable(db, columns);
104
+ }
105
+
106
+ export function hasCurrentLogsTable(db: Database, columns: ColumnRow[]): boolean {
107
+ const table = db.query<SchemaObjectRow, []>(
108
+ "SELECT name, type, sql FROM sqlite_schema WHERE name = 'logs' LIMIT 1",
109
+ ).get();
110
+ if (table?.type !== "table"
111
+ || !sameColumns(columns)
112
+ || normalizeSchemaSql(table.sql) !== normalizeSchemaSql(CURRENT_LOG_TABLE_SQL)) {
113
+ return false;
114
+ }
115
+
116
+ const indexes = db.query<SchemaObjectRow, []>(`
117
+ SELECT name, type, sql FROM sqlite_schema
118
+ WHERE name IN (
119
+ 'idx_logs_ts',
120
+ 'idx_logs_thread_id',
121
+ 'idx_logs_thread_id_ts',
122
+ 'idx_logs_process_uuid_threadless_ts'
123
+ )
124
+ `).all();
125
+ const byName = new Map(indexes.map(row => [row.name, row]));
126
+ for (const [name, expectedSql] of Object.entries(CURRENT_LOG_INDEX_SQL)) {
127
+ const row = byName.get(name);
128
+ if (row?.type !== "index" || normalizeSchemaSql(row.sql) !== normalizeSchemaSql(expectedSql)) {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ // Extra indexes and triggers do not redefine the table contract. In particular,
134
+ // Protect intentionally installs OpenCodex-owned triggers and unrelated user triggers
135
+ // are supported, so compatibility is based on the canonical table plus required indexes.
136
+ return true;
137
+ }