@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -1,9 +1,9 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
- import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
6
- import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
5
+ import { expandUserPath, readConfigDiagnostics, websocketsEnabled } from "../../config";
6
+ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths";
7
7
  import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
8
8
  import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
9
9
  import type { OcxConfig, OcxProviderConfig } from "../../types";
@@ -14,10 +14,9 @@ import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../..
14
14
  import { getProviderRegistryEntry } from "../../providers/registry";
15
15
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
16
16
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
17
- import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
17
+ import { identifyRoutedModel } from "../../adapters/identity";
18
18
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
19
19
  import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
20
- import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
21
20
  import {
22
21
  COMBO_NAMESPACE,
23
22
  comboModelId,
@@ -31,15 +30,31 @@ import { redactSecretString } from "../../lib/redact";
31
30
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
31
 
33
32
 
34
- import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
35
- import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing";
36
- import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, shouldUpgradeToUpstreamEntry, upstreamNativeEntry } from "./metadata";
37
- import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled";
33
+ import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
34
+ import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
35
+ import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata";
36
+ import {
37
+ bundledCatalogCacheState,
38
+ loadBundledCodexCatalog,
39
+ resetBundledCatalogCacheForTests,
40
+ } from "./bundled";
38
41
  import { isMultiAgentV2Enabled } from "../features";
39
42
  import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort";
40
43
  import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch";
41
- import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation";
44
+ import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce } from "./aggregation";
42
45
  import type { ComboCatalogOmission } from "./aggregation";
46
+ import {
47
+ withCatalogWriteSerialization,
48
+ type CatalogWritePermit,
49
+ } from "../catalog-write-serialization";
50
+ import {
51
+ publishHashedCodexCatalogBackup,
52
+ publishLegacyCodexCatalogBackup,
53
+ replaceActiveCodexCatalog,
54
+ replaceCodexModelsCache,
55
+ } from "../internal/catalog-writer";
56
+ import { codexRuntimeStatePath } from "../runtime";
57
+ import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";
43
58
 
44
59
  export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
45
60
 
@@ -88,21 +103,32 @@ export interface EffectiveSubagentRoster {
88
103
  excluded: SubagentRosterExclusion[];
89
104
  }
90
105
 
91
- export function configuredCatalogEntry(entries: RawEntry[], configured: string): RawEntry | undefined {
106
+ export function configuredCatalogEntry(entries: readonly RawEntry[], configured: string): RawEntry | undefined {
92
107
  return entries.find(entry => entry.slug === configured)
93
108
  ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug));
94
109
  }
95
110
 
111
+ function configuredSubagentModelMatchesEntry(configured: string, entry: RawEntry): boolean {
112
+ if (typeof entry.slug !== "string") return false;
113
+ if (slugsEquivalent(configured, entry.slug)) return true;
114
+ const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
115
+ return !configured.includes("/")
116
+ && nativeSlug !== undefined
117
+ && SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)
118
+ && slugsEquivalent(configured, nativeSlug);
119
+ }
120
+
96
121
  export function effectiveSubagentRoster(
97
122
  configuredModels: readonly string[],
98
123
  surface: SpawnAgentSurface,
124
+ catalogEntries?: readonly RawEntry[],
99
125
  ): EffectiveSubagentRoster {
100
126
  const configured = configuredModels
101
127
  .filter(model => model.trim().length > 0)
102
128
  .filter((model, index, all) =>
103
129
  !all.slice(0, index).some(previous => slugsEquivalent(previous, model))
104
130
  );
105
- const entries = readCatalog(readCodexCatalogPath())?.models ?? [];
131
+ const entries = catalogEntries ?? readCatalog(readCodexCatalogPath())?.models ?? [];
106
132
  const ordered = entries
107
133
  .map((entry, index) => ({ entry, index }))
108
134
  .filter(({ entry }) => typeof entry.slug === "string")
@@ -116,28 +142,43 @@ export function effectiveSubagentRoster(
116
142
  return leftPriority - rightPriority || left.index - right.index;
117
143
  })
118
144
  .slice(0, MAX_SPAWN_AGENT_MODEL_OVERRIDES);
145
+ const orderedEntries = new Set(ordered.map(({ entry }) => entry));
119
146
 
120
147
  const candidates = ordered.map(({ entry }) => ({
121
148
  model: entry.slug as string,
122
149
  efforts: catalogEntryEfforts(entry),
123
150
  }));
124
- const advertised = candidates.filter(candidate =>
125
- configured.some(model => slugsEquivalent(model, candidate.model))
126
- );
151
+ const advertised = ordered
152
+ .filter(({ entry }) => configured.some(model => configuredSubagentModelMatchesEntry(model, entry)))
153
+ .map(({ entry }) => ({
154
+ model: entry.slug as string,
155
+ efforts: catalogEntryEfforts(entry),
156
+ }));
127
157
  const excluded = configured.flatMap((model): SubagentRosterExclusion[] => {
128
- const entry = configuredCatalogEntry(entries, model);
129
- if (!entry) return [{ configured: model, reason: "missing_catalog_entry" }];
130
- const catalogModel = entry.slug as string;
131
- if (entry.visibility !== "list") {
132
- return [{ configured: model, catalogModel, reason: "picker_hidden" }];
133
- }
134
- if (surface === "v2" && !isEligibleV2SubagentEntry(entry)) {
135
- return [{ configured: model, catalogModel, reason: "surface_incompatible" }];
158
+ const matchingEntries = entries.filter(entry => configuredSubagentModelMatchesEntry(model, entry));
159
+ if (matchingEntries.some(entry => orderedEntries.has(entry))) return [];
160
+ if (matchingEntries.length === 0) return [{ configured: model, reason: "missing_catalog_entry" }];
161
+ const visibleCompatible = matchingEntries.find(entry =>
162
+ entry.visibility === "list"
163
+ && (surface !== "v2" || isEligibleV2SubagentEntry(entry))
164
+ );
165
+ if (visibleCompatible) {
166
+ return [{
167
+ configured: model,
168
+ catalogModel: visibleCompatible.slug as string,
169
+ reason: "outside_display_limit",
170
+ }];
136
171
  }
137
- if (!candidates.some(candidate => candidate.model === catalogModel)) {
138
- return [{ configured: model, catalogModel, reason: "outside_display_limit" }];
172
+ const visible = matchingEntries.find(entry => entry.visibility === "list");
173
+ if (visible) {
174
+ return [{
175
+ configured: model,
176
+ catalogModel: visible.slug as string,
177
+ reason: "surface_incompatible",
178
+ }];
139
179
  }
140
- return [];
180
+ const hidden = configuredCatalogEntry(entries, model) ?? matchingEntries[0]!;
181
+ return [{ configured: model, catalogModel: hidden.slug as string, reason: "picker_hidden" }];
141
182
  });
142
183
  return { candidates, advertised, excluded };
143
184
  }
@@ -188,16 +229,20 @@ export function deriveEntry(
188
229
  // Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity
189
230
  // and advertise the reasoning ladder Codex accepts.
190
231
  if (isRouted) {
232
+ // A routed model is NOT the native template: never inherit its context
233
+ // window when /models omits context metadata (#992). Known metadata
234
+ // restores exact values below; otherwise the strict-fields fallback
235
+ // supplies the conservative 128k triple.
236
+ delete e.context_window;
237
+ delete e.max_context_window;
238
+ delete e.auto_compact_token_limit;
191
239
  // Native id for identity text + metadata lookups — the slug may be an encoded
192
240
  // alias (`provider/vendor-model`); the model object carries the native id.
193
241
  const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1);
194
242
  if (typeof e.base_instructions === "string") {
195
243
  // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy
196
244
  // (leaking that into base_instructions is a non-first-party signature → ToS risk).
197
- e.base_instructions = e.base_instructions.replace(
198
- CODEX_GPT5_IDENTITY_LINE,
199
- `You are a coding agent powered by the ${modelName} model. Do not claim to be GPT-5 or made by OpenAI.`,
200
- );
245
+ e.base_instructions = identifyRoutedModel(e.base_instructions, modelName);
201
246
  }
202
247
  applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
203
248
  normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
@@ -255,13 +300,16 @@ export function buildCatalogEntries(
255
300
  wsEnabled = false,
256
301
  multiAgentMode: MultiAgentMode = "default",
257
302
  exactComboSlugs: ReadonlySet<string> = new Set(),
303
+ accountSelectors: readonly string[] = [],
258
304
  ): RawEntry[] {
259
305
  // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
260
306
  // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
261
307
  // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
262
308
  // it sorts to the front. This works for native gpt slugs AND routed slugs alike.
263
309
  const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const));
310
+ const priorityStride = Math.max(accountSelectors.length, 1);
264
311
  const out: RawEntry[] = [];
312
+ const nativeEntries: RawEntry[] = [];
265
313
  const collisionSkipped = resolveSlugAliasCollisions(goModels);
266
314
  const comboPublicSlugs = new Set(goModels
267
315
  .filter(model => model.provider === COMBO_NAMESPACE)
@@ -270,6 +318,26 @@ export function buildCatalogEntries(
270
318
  const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
271
319
  if (rank.has(slug)) e.priority = rank.get(slug)!;
272
320
  out.push(e);
321
+ nativeEntries.push(e);
322
+ }
323
+ for (const [selectorIndex, selector] of accountSelectors.entries()) {
324
+ for (const [nativeIndex, native] of nativeEntries.entries()) {
325
+ const e = JSON.parse(JSON.stringify(native)) as RawEntry;
326
+ const nativeSlug = String(native.slug);
327
+ const catalogSlug = `${selector}/${nativeSlug}`;
328
+ e.slug = catalogSlug;
329
+ e.display_name = accountBoundNativeDisplayName(selector, native);
330
+ // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged.
331
+ e.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND;
332
+ const exactRank = rank.get(catalogSlug);
333
+ const inheritedRank = rank.get(nativeSlug);
334
+ const featuredRank = exactRank ?? inheritedRank;
335
+ e.priority = featuredRank !== undefined
336
+ ? featuredRank * priorityStride + selectorIndex
337
+ : ((featured?.length ?? 0) + nativeIndex) * accountSelectors.length + selectorIndex;
338
+ e.visibility = "list";
339
+ out.push(e);
340
+ }
273
341
  }
274
342
  for (const m of goModels) {
275
343
  if (collisionSkipped.has(m)) continue;
@@ -290,7 +358,11 @@ export function buildCatalogEntries(
290
358
  );
291
359
  // Featured picks may be stored raw (legacy) or encoded — honor both.
292
360
  const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);
293
- if (rankHit !== undefined) e.priority = rankHit;
361
+ if (rankHit !== undefined) e.priority = rankHit * priorityStride;
362
+ else if (accountSelectors.length > 0) {
363
+ // Keep the generated account rows together in Codex's priority-sorted flat picker.
364
+ e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5);
365
+ }
294
366
  out.push(e);
295
367
  }
296
368
  // Central capability override (phase 120.4): the advertised flag must match the implemented WS
@@ -315,6 +387,7 @@ export function resetCatalogRuntimeStateForTests(): void {
315
387
  comboCatalogWarningSignatures.clear();
316
388
  slugAliasCollisionWarnings.clear();
317
389
  comboMasqueradeCollisionWarnings.clear();
390
+ accountSelectorShadowCollisionWarnings.clear();
318
391
  clearLastComboCatalogOmissions();
319
392
  clearModelCache();
320
393
  clearGatherRoutedModelsInflight();
@@ -357,7 +430,7 @@ export function mergeCatalogEntriesForSync(
357
430
  wsEnabled: boolean,
358
431
  goIds: Set<string> = new Set(),
359
432
  template: RawEntry | null = null,
360
- disabledNative: Set<string> = new Set(),
433
+ disabledModels: ReadonlySet<string> = new Set(),
361
434
  gatheredProviderNames: Set<string> = new Set(routedEntries.flatMap(entry => {
362
435
  const slug = typeof entry.slug === "string" ? entry.slug : "";
363
436
  const slash = slug.indexOf("/");
@@ -367,6 +440,7 @@ export function mergeCatalogEntriesForSync(
367
440
  exactComboSlugs: ReadonlySet<string> = new Set(),
368
441
  hasPhysicalComboProvider = false,
369
442
  includeNativeOpenAi = true,
443
+ accountBoundEntries: readonly RawEntry[] = [],
370
444
  ): RawEntry[] {
371
445
  const rank = new Map(featured.map((slug, i) => [slug, i] as const));
372
446
  const native = includeNativeOpenAi
@@ -426,24 +500,45 @@ export function mergeCatalogEntriesForSync(
426
500
  }
427
501
  }
428
502
 
503
+ const nativeBySlug = new Map(native.flatMap(entry =>
504
+ typeof entry.slug === "string" ? [[entry.slug, entry] as const] : []
505
+ ));
506
+ const alignedAccountBoundEntries = accountBoundEntries.map(entry => {
507
+ const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
508
+ const source = nativeSlug === undefined ? undefined : nativeBySlug.get(nativeSlug);
509
+ if (!source) return entry;
510
+ const aligned = JSON.parse(JSON.stringify(source)) as RawEntry;
511
+ aligned.slug = entry.slug;
512
+ aligned.display_name = entry.display_name;
513
+ aligned.priority = entry.priority;
514
+ aligned.visibility = "list";
515
+ aligned.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND;
516
+ return aligned;
517
+ });
518
+
429
519
  const freshSlugs = new Set(
430
520
  routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
431
521
  );
432
522
  let finalRoutedEntries = routedEntries;
523
+ const existingRoutedEntries = catalogModels.filter(m =>
524
+ typeof m.slug === "string"
525
+ && m.slug.includes("/")
526
+ && trustedAccountBoundNativeCatalogSlug(m) === undefined
527
+ );
433
528
  const preservingExistingRouted = routedEntries.length === 0
434
- && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
529
+ && existingRoutedEntries.length > 0;
435
530
  if (preservingExistingRouted) {
436
531
  // #855: transient-fetch protection keeps existing rows, but rows OpenCodex
437
532
  // itself authored for a provider that is no longer configured are ghosts,
438
533
  // not protected foreign entries.
439
- finalRoutedEntries = catalogModels.filter(m => {
440
- if (typeof m.slug !== "string" || !(m.slug as string).includes("/")) return false;
534
+ finalRoutedEntries = existingRoutedEntries.filter(m => {
441
535
  const provider = (m.slug as string).slice(0, (m.slug as string).indexOf("/"));
442
536
  return !(isOcxAuthoredRoutedEntry(m) && !gatheredProviderNames.has(provider));
443
537
  });
444
538
  } else {
445
539
  const preservedForeignRouted = catalogModels.filter(m => {
446
540
  if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
541
+ if (trustedAccountBoundNativeCatalogSlug(m) !== undefined) return false;
447
542
  const provider = m.slug.slice(0, m.slug.indexOf("/"));
448
543
  if (gatheredProviderNames.has(provider) || freshSlugs.has(m.slug)) return false;
449
544
  // #855: an OpenCodex-authored row whose provider was deleted is a ghost;
@@ -469,11 +564,20 @@ export function mergeCatalogEntriesForSync(
469
564
  finalRoutedEntries = finalRoutedEntries.filter(entry =>
470
565
  typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug)
471
566
  );
567
+ const accountBoundSlugs = new Set(alignedAccountBoundEntries.flatMap(entry =>
568
+ typeof entry.slug === "string" ? [entry.slug] : []
569
+ ));
570
+ finalRoutedEntries = finalRoutedEntries.filter(entry => {
571
+ if (typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug)) return true;
572
+ if (freshSlugs.has(entry.slug)) warnAccountSelectorShadowedProviderOnce(entry.slug);
573
+ return false;
574
+ });
472
575
  if (preservingExistingRouted) {
473
576
  console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
474
577
  }
475
578
 
476
- const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
579
+ const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries];
580
+ const mergedEntries = [...native, ...managedEntries].map(m => {
477
581
  const normalized = normalizeServiceTiers(m);
478
582
  applyNativeOpenAiContextOverride(normalized);
479
583
  const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug);
@@ -502,35 +606,186 @@ export function mergeCatalogEntriesForSync(
502
606
  }
503
607
  return e;
504
608
  });
505
- // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the
506
- // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list.
507
- return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode, isMultiAgentV2Enabled());
609
+ // Native enable/disable runs as the LAST pass so the upstream-upgrade branch above can never
610
+ // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable
611
+ // only their generated account row.
612
+ return applyMultiAgentMode(
613
+ applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0),
614
+ multiAgentMode,
615
+ isMultiAgentV2Enabled(),
616
+ );
508
617
  }
509
618
 
510
- export async function syncCatalogModels(config: OcxConfig): Promise<{
619
+ interface RetainedCatalogSyncRead {
620
+ readonly catalogPath: string;
621
+ readonly catalog: RawCatalog;
622
+ readonly onDiskCatalog: RawCatalog | null;
623
+ readonly evidence: string;
624
+ /**
625
+ * Process-local epochs, baselined AFTER our own gather rather than with the
626
+ * filesystem bytes above. See `retainedCatalogProcessEvidence`.
627
+ */
628
+ readonly processEvidence: string;
629
+ }
630
+
631
+ interface RetainedCatalogSyncResult {
511
632
  added: number;
512
633
  path: string;
513
634
  catalogWritten: boolean;
514
635
  comboOmissions: ComboCatalogOmission[];
515
- }> {
636
+ }
637
+
638
+ interface RetainedCatalogSyncWrite {
639
+ readonly config: OcxConfig;
640
+ readonly goModels: CatalogModel[];
641
+ readonly comboOmissions: ComboCatalogOmission[];
642
+ readonly read: RetainedCatalogSyncRead;
643
+ readonly permit: CatalogWritePermit;
644
+ readonly owningCodexHome: string;
645
+ }
646
+
647
+ function optionalFileBytes(path: string): string | null {
648
+ try {
649
+ return readFileSync(path).toString("base64");
650
+ } catch (error) {
651
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null;
652
+ throw error;
653
+ }
654
+ }
655
+
656
+ function loadCatalogForRetainedSync(path: string): RawCatalog | null {
657
+ const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
658
+ if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
659
+ const active = readCatalog(path);
660
+ if (active && findNativeTemplate(active)) return active;
661
+ return readCatalog(catalogBackupPathFor(path))
662
+ ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
663
+ ?? readCatalog(activeCodexModelsCachePath())
664
+ ?? active;
665
+ }
666
+
667
+ function retainedCatalogSyncEvidence(
668
+ config: OcxConfig,
669
+ catalogPath: string,
670
+ catalog: RawCatalog,
671
+ ): string {
672
+ return JSON.stringify({
673
+ config,
674
+ catalogPath,
675
+ catalog,
676
+ catalogBytes: optionalFileBytes(catalogPath),
677
+ hashedBackupBytes: optionalFileBytes(catalogBackupPathFor(catalogPath)),
678
+ legacyBackupBytes: isDefaultCatalogPath(catalogPath)
679
+ ? optionalFileBytes(legacyCatalogBackupPath()) : null,
680
+ modelsCacheBytes: optionalFileBytes(activeCodexModelsCachePath()),
681
+ // The persisted runtime selection is a pre-await filesystem input, not a
682
+ // process epoch: another PROCESS can move runtime authority by rewriting this
683
+ // file, and that move is invisible to our in-process memo. Recorded PRESENT or
684
+ // ABSENT, because its absence is what makes the resolver fall back.
685
+ runtimeStateBytes: optionalFileBytes(codexRuntimeStatePath()),
686
+ });
687
+ }
688
+
689
+ /**
690
+ * The bundled-template half of the same evidence, observed separately.
691
+ *
692
+ * The runtime process memo is deliberately NOT here, and that exclusion took three
693
+ * attempts to get honest. Gathering resolves the Codex runtime lazily and under its
694
+ * own cache key, so this path cannot pre-settle that memo: baselining it before the
695
+ * await always detected our own side effect and refused every write, and baselining
696
+ * it after the await captured a runtime that ANOTHER process had moved as though it
697
+ * were ours — a catalog prepared from R1 committing after authority reached R2.
698
+ *
699
+ * Runtime authority is covered where it is actually durable instead: the persisted
700
+ * `codex-runtime.json` bytes sit in the pre-await filesystem evidence, PRESENT or
701
+ * ABSENT, so a cross-process runtime move is caught. What is left uncovered, and is
702
+ * written down rather than papered over, is a same-process in-memory runtime swap
703
+ * that never touches that file — WP11 owns the lock that makes that case decidable.
704
+ */
705
+ function retainedCatalogProcessEvidence(): string {
706
+ return JSON.stringify({
707
+ bundledCatalogCache: bundledCatalogCacheState(),
708
+ });
709
+ }
710
+
711
+ /**
712
+ * Capture every local catalog input the retained sync path consults before its
713
+ * provider await. The exact evidence is compared after K acquisition; a newer
714
+ * catalog/backup/cache or target selection makes this attempt a no-write.
715
+ */
716
+ function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | null {
516
717
  const catalogPath = readCodexCatalogPath();
517
- const catalog = loadCatalogForSync(catalogPath);
518
- if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] };
718
+ const catalog = loadCatalogForRetainedSync(catalogPath);
719
+ if (!catalog) return null;
519
720
 
520
721
  // The bundled catalog is a reliable native template on the default path, but it is not the
521
722
  // merge source. Preservation must inspect the file that this sync is about to overwrite;
522
723
  // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
523
724
  const onDiskCatalog = readCatalog(catalogPath);
524
- const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? [];
725
+ const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog);
726
+ // `processEvidence` is filled in after the provider await, not here.
727
+ return { catalogPath, catalog, onDiskCatalog, evidence, processEvidence: "" };
728
+ }
729
+
730
+ function revalidateRetainedCatalogSync(
731
+ config: OcxConfig,
732
+ prepared: RetainedCatalogSyncRead,
733
+ ): RetainedCatalogSyncRead | null {
734
+ const catalogPath = readCodexCatalogPath();
735
+ if (catalogPath !== prepared.catalogPath) return null;
736
+ const evidence = retainedCatalogSyncEvidence(config, catalogPath, prepared.catalog);
737
+ if (evidence !== prepared.evidence) return null;
738
+ if (retainedCatalogProcessEvidence() !== prepared.processEvidence) return null;
739
+ return {
740
+ catalogPath,
741
+ catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog,
742
+ onDiskCatalog: readCatalog(catalogPath),
743
+ evidence,
744
+ processEvidence: prepared.processEvidence,
745
+ };
746
+ }
525
747
 
748
+ function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null {
749
+ if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) {
750
+ try {
751
+ return readFileSync(read.catalogPath, "utf8");
752
+ } catch {
753
+ return null;
754
+ }
755
+ }
756
+ return catalogHasRoutedEntries(read.catalog)
757
+ ? null
758
+ : `${JSON.stringify(read.catalog, null, 2)}\n`;
759
+ }
760
+
761
+ function writeRetainedCatalogSync({
762
+ config,
763
+ goModels,
764
+ comboOmissions,
765
+ read,
766
+ permit,
767
+ owningCodexHome,
768
+ }: RetainedCatalogSyncWrite): RetainedCatalogSyncResult {
769
+ const { catalogPath, catalog, onDiskCatalog } = read;
770
+ const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? [];
526
771
  const template = findNativeTemplate(catalog);
527
772
 
528
- const comboOmissions: ComboCatalogOmission[] = [];
529
- const goModels = await gatherRoutedModels(config, { comboOmissions });
530
773
  try {
531
774
  // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
532
775
  // (later syncs would otherwise overwrite it with featured-modified priorities).
533
- ensureCatalogBackup(catalogPath, catalog);
776
+ const pristine = pristineCatalogBytes(read);
777
+ if (pristine !== null) {
778
+ publishHashedCodexCatalogBackup(permit, owningCodexHome, {
779
+ path: catalogBackupPathFor(catalogPath),
780
+ content: pristine,
781
+ });
782
+ if (isDefaultCatalogPath(catalogPath)) {
783
+ publishLegacyCodexCatalogBackup(permit, owningCodexHome, {
784
+ path: legacyCatalogBackupPath(),
785
+ content: pristine,
786
+ });
787
+ }
788
+ }
534
789
  } catch { /* backup best-effort */ }
535
790
 
536
791
  // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
@@ -541,7 +796,22 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
541
796
  const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
542
797
  const exactComboSlugs = exactComboCatalogSlugs(config);
543
798
  const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
544
- const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs);
799
+ const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
800
+ const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
801
+ const accountSelectors = includeAccountBoundNativeOpenAi
802
+ ? visibleCodexAccountSelectors(config)
803
+ : [];
804
+ const wsEnabled = websocketsEnabled(config);
805
+ const goEntries = buildCatalogEntries(
806
+ template ? JSON.parse(JSON.stringify(template)) : null,
807
+ [],
808
+ orderedGoModels,
809
+ featured,
810
+ wsEnabled,
811
+ multiAgentMode,
812
+ exactComboSlugs,
813
+ accountSelectors,
814
+ );
545
815
  // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
546
816
  // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
547
817
  // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
@@ -555,53 +825,210 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
555
825
  // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
556
826
  // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
557
827
  // native template can never leak supports_websockets while the flag is off.
558
- const wsEnabled = websocketsEnabled(config);
559
- const enabledProviders = Object.entries(config.providers ?? {})
560
- .filter(([, prov]) => prov.disabled !== true);
561
- const hasCanonicalOpenai = enabledProviders.some(([name, prov]) =>
562
- name === "openai" && isCanonicalOpenAiForwardProvider(prov),
563
- );
564
828
  // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise
565
829
  // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
566
830
  // providers are configured yet (fresh install / catalog bootstrap tests).
567
- const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai;
568
- catalog.models = mergeCatalogEntriesForSync(catalogModelsForMerge, goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
831
+ const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0
832
+ ? buildCatalogEntries(
833
+ template ? JSON.parse(JSON.stringify(template)) : null,
834
+ NATIVE_OPENAI_MODELS,
835
+ [],
836
+ featured,
837
+ wsEnabled,
838
+ multiAgentMode,
839
+ exactComboSlugs,
840
+ accountSelectors,
841
+ ).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined)
842
+ : [];
843
+ catalog.models = mergeCatalogEntriesForSync(
844
+ catalogModelsForMerge,
845
+ goEntries,
846
+ baseline,
847
+ featured,
848
+ wsEnabled,
849
+ goIds,
850
+ template,
851
+ new Set(config.disabledModels ?? []),
852
+ gatheredProviderNames,
853
+ multiAgentMode,
854
+ exactComboSlugs,
855
+ hasPhysicalComboProvider,
856
+ includeNativeOpenAi,
857
+ accountBoundEntries,
858
+ );
569
859
  clampCatalogModelsToCodexSupport(catalog.models);
570
860
 
571
- atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
572
- return { added: goEntries.length, path: catalogPath, catalogWritten: true, comboOmissions };
861
+ replaceActiveCodexCatalog(permit, owningCodexHome, {
862
+ path: catalogPath,
863
+ content: `${JSON.stringify(catalog, null, 2)}\n`,
864
+ });
865
+ return {
866
+ added: goEntries.length + accountBoundEntries.length,
867
+ path: catalogPath,
868
+ catalogWritten: true,
869
+ comboOmissions,
870
+ };
573
871
  }
574
872
 
575
- export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
873
+ function visibleAccountReplacementNatives(
874
+ models: readonly RawEntry[],
875
+ disabledModels: ReadonlySet<string> | null,
876
+ ): Map<string, boolean> {
877
+ const replacements = new Map<string, boolean>();
878
+ for (const entry of models) {
879
+ const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
880
+ if (nativeSlug === undefined || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue;
881
+ const exactSlug = typeof entry.slug === "string" ? entry.slug : "";
882
+ const visible = entry.visibility === "list"
883
+ || (disabledModels !== null
884
+ && (disabledModels.has(nativeSlug) || disabledModels.has(exactSlug)));
885
+ replacements.set(nativeSlug, (replacements.get(nativeSlug) ?? true) && visible);
886
+ }
887
+ return replacements;
888
+ }
889
+
890
+ function restoreAccountHiddenBareNatives(
891
+ entries: readonly RawEntry[],
892
+ replacementVisibility: ReadonlyMap<string, boolean>,
893
+ disabledModels: ReadonlySet<string> | null,
894
+ ): RawEntry[] {
895
+ return entries.map(entry => {
896
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
897
+ if (
898
+ entry.visibility !== "hide"
899
+ || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)
900
+ || replacementVisibility.get(slug) !== true
901
+ || disabledModels === null
902
+ || disabledModels.has(slug)
903
+ ) {
904
+ return entry;
905
+ }
906
+ return { ...entry, visibility: "list" };
907
+ });
908
+ }
909
+
910
+ function currentDisabledModelsForRestore(): Set<string> | null {
911
+ try {
912
+ const diagnostics = readConfigDiagnostics();
913
+ if (diagnostics.source === "fallback" || diagnostics.error !== null) return null;
914
+ return new Set(diagnostics.config.disabledModels ?? []);
915
+ } catch {
916
+ // An unreadable config cannot safely authorize a visibility change during restore.
917
+ return null;
918
+ }
919
+ }
920
+
921
+ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCatalogSyncResult> {
922
+ const owningCodexHome = getCodexHome();
923
+ const preflightRead = readRetainedCatalogSync(config);
924
+ if (preflightRead === null) {
925
+ return {
926
+ added: 0,
927
+ path: readCodexCatalogPath(),
928
+ catalogWritten: false,
929
+ comboOmissions: [],
930
+ };
931
+ }
932
+
933
+ const comboOmissions: ComboCatalogOmission[] = [];
934
+ // Settle the bundled template, then baseline, and only then await. Reading it
935
+ // here makes the memo ours before anyone else can move it, so a bundled swap
936
+ // during the await is an outside change rather than our own side effect.
937
+ //
938
+ // The persisted runtime selection is covered by the filesystem evidence above
939
+ // rather than by a process epoch; see `retainedCatalogProcessEvidence` for why
940
+ // the in-memory runtime memo cannot be baselined honestly from this path.
941
+ loadBundledCodexCatalog();
942
+ const prepared: RetainedCatalogSyncRead = {
943
+ ...preflightRead,
944
+ evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog),
945
+ processEvidence: retainedCatalogProcessEvidence(),
946
+ };
947
+ const goModels = await gatherRoutedModels(config, { comboOmissions });
948
+ const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
949
+ const current = revalidateRetainedCatalogSync(config, prepared);
950
+ if (current === null) return null;
951
+ return writeRetainedCatalogSync({
952
+ config,
953
+ goModels,
954
+ comboOmissions,
955
+ read: current,
956
+ permit,
957
+ owningCodexHome,
958
+ });
959
+ });
960
+ if (committed.kind === "completed" && committed.value !== null) return committed.value;
961
+ return {
962
+ added: 0,
963
+ path: prepared.catalogPath,
964
+ catalogWritten: false,
965
+ comboOmissions,
966
+ };
967
+ }
968
+
969
+ export function restoreCodexCatalogWithPermit(
970
+ permit: CatalogWritePermit,
971
+ owningCodexHome: string,
972
+ ): { removed: number; kept: number; path: string } {
576
973
  const catalogPath = readCodexCatalogPath();
577
974
  const catalog = readCatalog(catalogPath);
578
975
  if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
976
+ const disabledModels = currentDisabledModelsForRestore();
977
+ const replacementVisibility = visibleAccountReplacementNatives(catalog.models, disabledModels);
579
978
  const backup = readCatalogBackup(catalogPath);
580
979
  if (backup && Array.isArray(backup.models)) {
581
980
  const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" && m.slug.includes("/")).length;
582
981
  const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
583
- const userNativeAdditions = (catalog.models ?? []).filter(m =>
584
- typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
982
+ const userNativeAdditions = restoreAccountHiddenBareNatives(
983
+ (catalog.models ?? []).filter(m =>
984
+ typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
985
+ ),
986
+ replacementVisibility,
987
+ disabledModels,
585
988
  );
586
989
  const restored = {
587
990
  ...backup,
588
991
  models: [...backup.models, ...userNativeAdditions],
589
992
  };
590
- atomicWriteFile(catalogPath, JSON.stringify(restored, null, 2) + "\n");
993
+ replaceActiveCodexCatalog(permit, owningCodexHome, {
994
+ path: catalogPath,
995
+ content: `${JSON.stringify(restored, null, 2)}\n`,
996
+ });
591
997
  return { removed, kept: restored.models.length, path: catalogPath };
592
998
  }
593
999
  const before = catalog.models.length;
594
- const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/")));
1000
+ const native = restoreAccountHiddenBareNatives(
1001
+ catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/"))),
1002
+ replacementVisibility,
1003
+ disabledModels,
1004
+ );
595
1005
  const removed = before - native.length;
596
1006
  if (removed > 0) {
597
1007
  catalog.models = native;
598
- atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
1008
+ replaceActiveCodexCatalog(permit, owningCodexHome, {
1009
+ path: catalogPath,
1010
+ content: `${JSON.stringify(catalog, null, 2)}\n`,
1011
+ });
599
1012
  }
600
1013
  return { removed, kept: native.length, path: catalogPath };
601
1014
  }
602
1015
 
1016
+ export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
1017
+ const owningCodexHome = getCodexHome();
1018
+ const outcome = withCatalogWriteSerialization(
1019
+ owningCodexHome,
1020
+ permit => restoreCodexCatalogWithPermit(permit, owningCodexHome),
1021
+ );
1022
+ return outcome.kind === "completed"
1023
+ ? outcome.value
1024
+ : { removed: 0, kept: 0, path: readCodexCatalogPath() };
1025
+ }
1026
+
603
1027
  /** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */
604
- export function invalidateCodexModelsCache(): boolean {
1028
+ export function invalidateCodexModelsCacheWithPermit(
1029
+ permit: CatalogWritePermit,
1030
+ owningCodexHome: string,
1031
+ ): boolean {
605
1032
  try {
606
1033
  const catalogPath = readCodexCatalogPath();
607
1034
  if (!existsSync(catalogPath)) return false;
@@ -612,9 +1039,21 @@ export function invalidateCodexModelsCache(): boolean {
612
1039
  client_version: "0.0.0",
613
1040
  models,
614
1041
  };
615
- atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n");
1042
+ replaceCodexModelsCache(permit, owningCodexHome, {
1043
+ path: activeCodexModelsCachePath(),
1044
+ content: `${JSON.stringify(wrapper, null, 2)}\n`,
1045
+ });
616
1046
  return true;
617
1047
  } catch {
618
1048
  return false;
619
1049
  }
620
1050
  }
1051
+
1052
+ export function invalidateCodexModelsCache(): boolean {
1053
+ const owningCodexHome = getCodexHome();
1054
+ const outcome = withCatalogWriteSerialization(
1055
+ owningCodexHome,
1056
+ permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome),
1057
+ );
1058
+ return outcome.kind === "completed" && outcome.value;
1059
+ }