@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -9,13 +9,16 @@ import { isCodexAccountUsable } from "./account-usability";
9
9
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
10
10
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
11
11
  import {
12
- getCodexAccountHealthSnapshot,
12
+ codexQuotaScopeForModel,
13
+ getCodexQuotaHealthSnapshot,
13
14
  releaseCodexQuotaProbeLease,
15
+ releaseCodexQuotaScopeProbeLease,
14
16
  tryAcquireCodexQuotaProbeLease,
15
- pickLowestUsageCodexAccount,
17
+ tryAcquireCodexQuotaScopeProbeLease,
18
+ pickAlternateCodexAccount,
16
19
  resolveCodexAccountForThreadDetailed,
17
20
  } from "./routing";
18
- import type { CodexCooldownSource } from "./routing";
21
+ import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
19
22
  import { maskAccountId } from "../lib/privacy";
20
23
  import { formatErrorResponse } from "../bridge";
21
24
  import { getAccountQuota } from "./quota";
@@ -34,8 +37,12 @@ export type CodexAuthContext =
34
37
  * Set when this request was admitted through an active quota cooldown as
35
38
  * the account's single probe. Must be echoed into the upstream outcome so
36
39
  * only this request can clear the cooldown (#433).
37
- */
40
+ */
38
41
  probeLeaseId?: string;
42
+ /** Native model quota group selected for this request, when known. */
43
+ quotaScope?: CodexQuotaScope;
44
+ /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
45
+ probeQuotaScope?: CodexQuotaScope;
39
46
  }
40
47
  | {
41
48
  // Main Codex account participating in rotation: token injected from ~/.codex/auth.json
@@ -46,6 +53,8 @@ export type CodexAuthContext =
46
53
  chatgptAccountId: string;
47
54
  /** See `pool.probeLeaseId`. */
48
55
  probeLeaseId?: string;
56
+ quotaScope?: CodexQuotaScope;
57
+ probeQuotaScope?: CodexQuotaScope;
49
58
  };
50
59
 
51
60
  /** Probe lease carried by this context, when it holds one. */
@@ -53,13 +62,20 @@ export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | u
53
62
  return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeLeaseId : undefined;
54
63
  }
55
64
 
65
+ /** Scope of a lease carried by this context, when it probes a model-specific quota. */
66
+ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQuotaScope | undefined {
67
+ return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined;
68
+ }
69
+
56
70
  /**
57
71
  * Hand back a probe lease for a request that will not reach upstream. Safe to
58
72
  * call with a context that holds no lease.
59
73
  */
60
74
  export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
61
75
  const leaseId = codexProbeLeaseId(ctx);
62
- if (ctx && leaseId) releaseCodexQuotaProbeLease(ctx.accountId!, leaseId);
76
+ if (!ctx || ctx.kind === "main" || !leaseId) return;
77
+ if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId);
78
+ else releaseCodexQuotaProbeLease(ctx.accountId!, leaseId);
63
79
  }
64
80
 
65
81
  export type OcxRuntimeProviderConfig = OcxProviderConfig & {
@@ -99,13 +115,20 @@ export class CodexAccountCooldownError extends Error {
99
115
  accountId: string;
100
116
  cooldownUntil: number;
101
117
  cooldownSource?: CodexCooldownSource;
118
+ quotaScope?: CodexQuotaScope;
102
119
 
103
- constructor(accountId: string, cooldownUntil: number, cooldownSource?: CodexCooldownSource) {
120
+ constructor(
121
+ accountId: string,
122
+ cooldownUntil: number,
123
+ cooldownSource?: CodexCooldownSource,
124
+ quotaScope?: CodexQuotaScope,
125
+ ) {
104
126
  super("Selected Codex account is cooling down");
105
127
  this.name = "CodexAccountCooldownError";
106
128
  this.accountId = accountId;
107
129
  this.cooldownUntil = cooldownUntil;
108
130
  this.cooldownSource = cooldownSource;
131
+ this.quotaScope = quotaScope;
109
132
  }
110
133
  }
111
134
 
@@ -127,7 +150,12 @@ export function cooldownAccountLabel(accountId: string): string {
127
150
  */
128
151
  export function cooldownErrorMessage(err: CodexAccountCooldownError): string {
129
152
  const until = new Date(err.cooldownUntil).toISOString();
130
- return `Selected Codex account (${cooldownAccountLabel(err.accountId)}) is cooling down until ${until}`
153
+ const scope = err.quotaScope === "spark"
154
+ ? "Spark quota"
155
+ : err.quotaScope === "shared"
156
+ ? "shared native quota"
157
+ : null;
158
+ return `Selected Codex account (${cooldownAccountLabel(err.accountId)})${scope ? ` ${scope} is` : " is"} cooling down until ${until}`
131
159
  + ` (source: ${err.cooldownSource ?? "default"}).`
132
160
  + ` Run 'ocx account list openai' to find the id, then`
133
161
  + ` 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'.`;
@@ -157,6 +185,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
157
185
 
158
186
  export interface ResolveCodexAuthContextOptions {
159
187
  excludeAccountId?: string;
188
+ /** Final native model selected for this request, used to select its quota group. */
189
+ modelId?: string;
160
190
  }
161
191
 
162
192
  export async function resolveCodexAuthContext(
@@ -171,16 +201,17 @@ export async function resolveCodexAuthContext(
171
201
  }
172
202
  reconcileMainCodexAccountRuntimeState();
173
203
  const threadId = headers.get("x-codex-parent-thread-id");
204
+ const quotaScope = codexQuotaScopeForModel(options.modelId);
174
205
  const resolution = options.excludeAccountId
175
206
  ? (() => {
176
- const accountId = pickLowestUsageCodexAccount(config, options.excludeAccountId);
207
+ const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!, Date.now(), quotaScope);
177
208
  return accountId
178
209
  ? { status: "selected" as const, accountId }
179
210
  : { status: "none" as const };
180
211
  })()
181
- : resolveCodexAccountForThreadDetailed(threadId, config);
212
+ : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope);
182
213
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
183
- const accountId = resolution.status === "selected" ? resolution.accountId : null;
214
+ let accountId = resolution.status === "selected" ? resolution.accountId : null;
184
215
  if (!accountId) throw new CodexPoolAuthenticationError();
185
216
  // Lazy prime: if the selected account has no quota yet, the pool is likely
186
217
  // unprimed (dashboard never opened, or startup prime was blocked). Kick a
@@ -194,15 +225,21 @@ export async function resolveCodexAuthContext(
194
225
  }
195
226
  // Snapshot (not just the deadline) so a refused request can report WHY it is cooled:
196
227
  // a literal Retry-After reads very differently to a user than a reset-derived guess.
197
- const cooldown = getCodexAccountHealthSnapshot(accountId);
228
+ const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope);
198
229
  const cooldownUntil = cooldown?.cooldownUntil;
199
230
  // A cooled-down account never sends traffic, so upstream recovery can never be
200
231
  // observed and the cooldown outlives the real limit. Admit one probe per
201
232
  // interval; its outcome decides whether the cooldown ends (#433).
202
233
  let probeLeaseId: string | undefined;
234
+ let probeQuotaScope: CodexQuotaScope | undefined;
203
235
  if (cooldownUntil) {
204
- probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
205
- if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource);
236
+ probeQuotaScope = cooldown?.quotaScope;
237
+ probeLeaseId = probeQuotaScope
238
+ ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined
239
+ : tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
240
+ if (!probeLeaseId) {
241
+ throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
242
+ }
206
243
  }
207
244
 
208
245
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
@@ -210,7 +247,8 @@ export async function resolveCodexAuthContext(
210
247
  const token = getMainAccountToken();
211
248
  if (!token) {
212
249
  // Nothing will reach upstream, so give the probe back instead of burning it.
213
- if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
250
+ if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
251
+ else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
214
252
  throw new CodexPoolAuthenticationError();
215
253
  }
216
254
  return {
@@ -218,7 +256,9 @@ export async function resolveCodexAuthContext(
218
256
  accountId,
219
257
  accessToken: token.accessToken,
220
258
  chatgptAccountId: token.chatgptAccountId,
259
+ ...(quotaScope ? { quotaScope } : {}),
221
260
  ...(probeLeaseId ? { probeLeaseId } : {}),
261
+ ...(probeQuotaScope ? { probeQuotaScope } : {}),
222
262
  };
223
263
  }
224
264
 
@@ -230,10 +270,13 @@ export async function resolveCodexAuthContext(
230
270
  generation: token.generation,
231
271
  accessToken: token.accessToken,
232
272
  chatgptAccountId: token.chatgptAccountId,
273
+ ...(quotaScope ? { quotaScope } : {}),
233
274
  ...(probeLeaseId ? { probeLeaseId } : {}),
275
+ ...(probeQuotaScope ? { probeQuotaScope } : {}),
234
276
  };
235
277
  } catch (cause) {
236
- if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
278
+ if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
279
+ else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
237
280
  if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
238
281
  markAccountNeedsReauth(accountId);
239
282
  }
@@ -245,9 +288,9 @@ export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefine
245
288
  if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
246
289
  // A context holding the probe lease was deliberately admitted through the cooldown.
247
290
  if (ctx.probeLeaseId) return;
248
- const cooldown = getCodexAccountHealthSnapshot(ctx.accountId);
291
+ const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope);
249
292
  if (cooldown?.cooldownUntil) {
250
- throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource);
293
+ throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope);
251
294
  }
252
295
  }
253
296
 
@@ -151,14 +151,21 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatal
151
151
  let cacheKey: string | null = null;
152
152
  const candidates = deps.commandCandidates?.() ?? (() => {
153
153
  const resolved = resolveAndPersistCodexRuntime({
154
- execFileSync: execFile,
154
+ // Forward an INJECTED execFileSync only. Passing the real one unconditionally made
155
+ // resolveCacheKey() bail out (it refuses to memoize injected-dep resolves), so every
156
+ // catalog read re-ran the ~1s `codex --version` probe even on a warm cache hit.
157
+ ...(deps.execFileSync ? { execFileSync: deps.execFileSync } : {}),
155
158
  configDir: deps.configDir,
156
159
  env: deps.env,
157
160
  platform: deps.platform,
158
161
  existsSync: deps.existsSync,
159
162
  readFileSync: deps.readFileSync,
160
163
  now: deps.now,
161
- discoverAlternatives: deps.discoverAlternatives,
164
+ // Catalog loading only consumes `resolved.runtime.command`, never `newerAvailable`.
165
+ // Full PATH discovery probes every candidate launcher (100+ on a dev machine, ~1.2s),
166
+ // which alone can exceed the 3s budget `ocx claude` allows /api/claude-code. Priority
167
+ // selection is identical either way; callers wanting discovery diagnostics opt in.
168
+ discoverAlternatives: deps.discoverAlternatives ?? false,
162
169
  });
163
170
  if (useCache) {
164
171
  cacheKey = [
@@ -110,6 +110,8 @@ export interface CatalogModel {
110
110
  /** Whether Codex may send Responses text.verbosity for this routed model. */
111
111
  supportsVerbosity?: boolean;
112
112
  supportsReasoningSummaries?: boolean;
113
+ /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
114
+ capabilities?: string[];
113
115
  }
114
116
 
115
117
  export type RawEntry = Record<string, unknown>;
@@ -141,9 +143,24 @@ export function isMediaGenerationModelId(id: string): boolean {
141
143
  return MEDIA_GEN_ID_RE.test(id);
142
144
  }
143
145
 
146
+ /**
147
+ * Gemini image-capable chat models produce inline images within text responses
148
+ * via the Responses API. Explicit allowlist only — a broad `/gemini/ && /image/`
149
+ * heuristic resurrects standalone media-gen IDs (e.g. gemini-3-pro-image).
150
+ */
151
+ const GEMINI_IMAGE_CHAT_MODEL_IDS = new Set([
152
+ "gemini-3.1-flash-image",
153
+ "gemini-2.0-flash-preview-image-generation",
154
+ "gemini-3-pro-image-preview",
155
+ ]);
156
+
157
+ function isGeminiImageChatModel(id: string): boolean {
158
+ return GEMINI_IMAGE_CHAT_MODEL_IDS.has(id);
159
+ }
160
+
144
161
  export function shouldExposeRoutedModel(model: CatalogModel): boolean {
145
162
  if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false;
146
- if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
163
+ if (isGeminiImageChatModel(model.id)) return true;
147
164
  return !isMediaGenerationModelId(model.id);
148
165
  }
149
166
 
@@ -257,6 +274,16 @@ export function ensureStrictCatalogFields(
257
274
  if (!Array.isArray(entry.input_modalities) && !options.preserveExactInputModalities) {
258
275
  entry.input_modalities = ["text"];
259
276
  }
277
+ // Codex parses `input_modalities` as a closed enum. One out-of-enum value (zenmux advertises
278
+ // "video") makes its config loader reject the entire catalog, which takes down plugins, apps and
279
+ // MCP servers — not just that model. Normalize at the single point every entry passes through,
280
+ // because provider metadata, jawcode metadata and effort sync each write this field.
281
+ if (Array.isArray(entry.input_modalities)) {
282
+ const accepted = entry.input_modalities.filter(value =>
283
+ value === "text" || value === "image" || value === "audio");
284
+ // Never leave it empty: an entry with no modality at all is worse than a text-only one.
285
+ entry.input_modalities = accepted.length > 0 ? accepted : ["text"];
286
+ }
260
287
  const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 ? entry.context_window : 128000;
261
288
  entry.context_window = contextWindow;
262
289
  if (
@@ -273,7 +300,18 @@ export function ensureStrictCatalogFields(
273
300
 
274
301
  export type MultiAgentMode = "v1" | "default" | "v2";
275
302
 
276
- export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode): RawEntry[] {
303
+ /**
304
+ * @param v2FeatureEnabled When the native multi_agent_v2 feature is on, "default"
305
+ * mode stamps unpinned entries as "v2" instead of deleting the key. The native
306
+ * binary validates spawn_agent models against THIS catalog with its own
307
+ * `multi_agent_version == Some(V2)` test (codex-rs multi_agents_common.rs), so an
308
+ * absent pin means a clean refusal at spawn time — exactly the cross-provider
309
+ * spawns opencodex exists to enable (option B, devlog
310
+ * 260730_codex_rs_upstream_v2_live_handoff/060). Upstream pins are always
311
+ * preserved: a genuine "v1" pin is a real capability statement and stays excluded.
312
+ * With the feature off the output is byte-identical to the historical behavior.
313
+ */
314
+ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v2FeatureEnabled = false): RawEntry[] {
277
315
  if (mode === "default") {
278
316
  // Restore upstream defaults: clear any stale forced multi_agent_version and
279
317
  // re-apply upstream pins from the snapshot for native entries that have one.
@@ -283,6 +321,8 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode):
283
321
  const upstreamPin = upstream?.multi_agent_version;
284
322
  if (typeof upstreamPin === "string") {
285
323
  entry.multi_agent_version = upstreamPin;
324
+ } else if (v2FeatureEnabled) {
325
+ entry.multi_agent_version = "v2";
286
326
  } else {
287
327
  delete entry.multi_agent_version;
288
328
  }