@bitkyc08/opencodex 2.54.0-preview.20260914 → 2.55.0-preview.20260914

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 (86) hide show
  1. package/gui/dist/assets/{index-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic-image-codec.ts +57 -0
  5. package/src/adapters/anthropic-image-normalize.ts +28 -1
  6. package/src/adapters/anthropic.ts +68 -6
  7. package/src/adapters/base.ts +8 -0
  8. package/src/adapters/coding-agent/protocol.ts +41 -16
  9. package/src/adapters/cursor/cursor-errors.ts +1 -1
  10. package/src/adapters/cursor/live-transport.ts +5 -1
  11. package/src/adapters/cursor/native-exec-fs.ts +10 -10
  12. package/src/adapters/cursor/native-exec-network.ts +2 -2
  13. package/src/adapters/cursor/native-exec-shell.ts +13 -12
  14. package/src/adapters/cursor/native-exec.ts +51 -10
  15. package/src/adapters/cursor/policy-error.ts +75 -0
  16. package/src/adapters/cursor/protobuf-request.ts +105 -1
  17. package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
  18. package/src/adapters/devin/live-models.ts +33 -2
  19. package/src/adapters/google-wire-compiler.ts +8 -0
  20. package/src/adapters/google.ts +46 -0
  21. package/src/adapters/input-media-guard.ts +45 -0
  22. package/src/adapters/kiro/adapter.ts +8 -0
  23. package/src/adapters/kiro/payload.ts +28 -6
  24. package/src/adapters/kiro-events.ts +25 -6
  25. package/src/adapters/kiro-images.ts +30 -0
  26. package/src/adapters/kiro-retry.ts +8 -0
  27. package/src/adapters/openai-chat.ts +33 -4
  28. package/src/adapters/openai-responses.ts +26 -0
  29. package/src/adapters/registry.ts +4 -0
  30. package/src/bridge.ts +163 -116
  31. package/src/chat/image-parts.ts +151 -0
  32. package/src/chat/inbound.ts +70 -33
  33. package/src/cli/connect.ts +30 -9
  34. package/src/cli/dispatch.ts +7 -3
  35. package/src/cli/index.ts +3 -0
  36. package/src/cli/runtime-api.ts +25 -0
  37. package/src/cli/status.ts +21 -19
  38. package/src/cli/system-restart-client.ts +25 -0
  39. package/src/clients/config-export.ts +14 -4
  40. package/src/codex/app-server-processes.ts +25 -0
  41. package/src/codex/auth-context.ts +8 -0
  42. package/src/codex/autostart-health.ts +36 -2
  43. package/src/codex/catalog/provider-fetch.ts +41 -0
  44. package/src/codex/catalog-auto-refresh.ts +182 -0
  45. package/src/codex/catalog-refresh-status.ts +93 -0
  46. package/src/codex/history-provider.ts +55 -0
  47. package/src/codex/model-entitlements.ts +78 -0
  48. package/src/codex/native-profile-processes.ts +114 -15
  49. package/src/codex/prompt-text-probe.ts +274 -41
  50. package/src/codex/routing-adoption.ts +189 -0
  51. package/src/codex/routing.ts +520 -48
  52. package/src/codex/runtime.ts +249 -7
  53. package/src/combos/failover.ts +45 -0
  54. package/src/config.ts +124 -4
  55. package/src/generated/compatibility-version.json +110 -74
  56. package/src/generated/model-metadata.ts +1 -0
  57. package/src/lib/request-execution-budget.ts +202 -0
  58. package/src/lib/upstream-retry.ts +95 -8
  59. package/src/lib/workflow-budget.ts +172 -0
  60. package/src/oauth/devin.ts +57 -12
  61. package/src/providers/quota.ts +37 -6
  62. package/src/providers/registry.ts +53 -6
  63. package/src/responses/input-media.ts +65 -0
  64. package/src/responses/parser-content.ts +42 -0
  65. package/src/responses/schema.ts +12 -2
  66. package/src/server/audio-live.ts +1 -2
  67. package/src/server/audio-transcriptions.ts +1 -2
  68. package/src/server/auth-cors.ts +1 -1
  69. package/src/server/background-lifecycle.ts +18 -0
  70. package/src/server/chat-completions.ts +23 -8
  71. package/src/server/chat-native.ts +17 -17
  72. package/src/server/index.ts +24 -0
  73. package/src/server/management/request-history-routes.ts +5 -0
  74. package/src/server/request-log.ts +8 -2
  75. package/src/server/responses/compact.ts +51 -3
  76. package/src/server/responses/core.ts +238 -28
  77. package/src/server/search.ts +7 -9
  78. package/src/types/config.ts +51 -6
  79. package/src/usage/log.ts +37 -0
  80. package/src/vision/eligibility.ts +37 -4
  81. package/src/vision/index.ts +1 -0
  82. package/src/vision/plan.ts +45 -10
  83. package/src/web-search/alpha-search.ts +324 -0
  84. package/src/web-search/index.ts +13 -22
  85. package/src/web-search/passthrough-bridge.ts +195 -22
  86. package/src/web-search/sidecar-providers.ts +22 -0
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from "node:fs";
3
- import { tmpdir } from "node:os";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
4
  import { delimiter, join } from "node:path";
5
5
  import { atomicWriteFile, getConfigDir } from "../config";
6
6
  import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation";
@@ -10,6 +10,7 @@ export type CodexRuntimeSource =
10
10
  | "environment"
11
11
  | "configured"
12
12
  | "shim"
13
+ | "installed"
13
14
  | "path"
14
15
  | "fallback";
15
16
 
@@ -42,6 +43,13 @@ export interface ResolveCodexRuntimeResult {
42
43
  readonly runtime: ResolvedCodexRuntime;
43
44
  readonly failures: readonly RuntimeProbeFailure[];
44
45
  readonly replacedConfigured?: Readonly<{ from: ResolvedCodexRuntime; reason: string }>;
46
+ /**
47
+ * Set when an unpinned, still-runnable persisted discovery is handed over to a
48
+ * strictly newer valid candidate. Distinct from replacedConfigured, which means
49
+ * the configured runtime became invalid; conflating "gone" with "superseded"
50
+ * would make the doctor output lie.
51
+ */
52
+ readonly supersededDiscovered?: Readonly<{ from: ResolvedCodexRuntime; to: ResolvedCodexRuntime; reason: string }>;
45
53
  readonly newerAvailable?: ResolvedCodexRuntime;
46
54
  /** Set when the selected runtime could not be written to codex-runtime.json. */
47
55
  readonly persistError?: string;
@@ -74,14 +82,47 @@ export interface ResolveCodexRuntimeDeps {
74
82
  * newerAvailable discovery). Use for hot UI/status paths.
75
83
  */
76
84
  discoverAlternatives?: boolean;
85
+ /**
86
+ * When false, select a spawnable candidate without running `codex --version`.
87
+ * The prompt probe needs a command it can spawn, not a version, and paying
88
+ * ~1s of blocking exec per candidate on a UI path is what made it report an
89
+ * absent candidate instead of the Windows Codex App install (issue 4458).
90
+ */
91
+ probeVersion?: boolean;
92
+ /**
93
+ * Directory listing used by Windows App-root discovery. Injected so tests can
94
+ * exercise the LOCALAPPDATA OpenAI/Codex/bin layout without a real Windows
95
+ * filesystem. Must be listed in resolveCacheKey's injection guard: an injected
96
+ * listing that leaked into the process memo would pin every later test in this
97
+ * file to a fake install.
98
+ */
99
+ readdirSync?: (path: string) => string[];
100
+ /**
101
+ * Stat used to order Windows App version directories by mtime. Same injection
102
+ * contract as readdirSync: a test-supplied impl must not populate the process
103
+ * memo.
104
+ */
105
+ statSync?: (path: string) => { mtimeMs: number; isDirectory(): boolean };
77
106
  }
78
107
 
108
+ /**
109
+ * How a `codex-runtime.json` record got onto disk.
110
+ *
111
+ * "pinned" is an intentional operator selection (doctor --fix). "discovered" is
112
+ * automatic resolve-and-persist. Absent is the pre-field shape and is treated
113
+ * as discovered, not pinned: every such file was written by
114
+ * resolveAndPersistCodexRuntime, so reading it as a pin would leave issue 4204
115
+ * unfixed on exactly the installs that have it.
116
+ */
117
+ export type CodexRuntimePinOrigin = "pinned" | "discovered";
118
+
79
119
  export interface PersistedCodexRuntimeState {
80
120
  readonly version: 1;
81
121
  readonly command: string;
82
122
  readonly source: CodexRuntimeSource;
83
123
  readonly selectedVersion?: string | null;
84
124
  readonly updatedAt: string;
125
+ readonly origin?: CodexRuntimePinOrigin;
85
126
  }
86
127
 
87
128
  const PERSIST_FILE = "codex-runtime.json";
@@ -89,6 +130,16 @@ const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json";
89
130
  /** Probe rejection for an absolute candidate whose file is gone. Matched when retiring a dead pin (#4035). */
90
131
  const PATH_MISSING_REASON = "path does not exist";
91
132
 
133
+ /**
134
+ * Probe rejection when the selected command cannot even be spawned. Distinct
135
+ * from PATH_MISSING_REASON (the absolute path was gone before spawn) and from
136
+ * the generic `failed --version (...)` string (the binary ran and failed).
137
+ * Exported because the prompt probe classifies this as program-not-found, so a
138
+ * PATH fallback that is simply not installed must not look like an execution
139
+ * failure (issue 4458).
140
+ */
141
+ export const CODEX_PROGRAM_NOT_FOUND_REASON = "program not found (ENOENT)";
142
+
92
143
  function cloneAndDeepFreeze<T>(value: T): DeepReadonly<T> {
93
144
  const clone = (current: unknown): unknown => {
94
145
  if (Array.isArray(current)) return current.map(clone);
@@ -111,10 +162,15 @@ function isCodexRuntimeSource(value: unknown): value is CodexRuntimeSource {
111
162
  return value === "environment"
112
163
  || value === "configured"
113
164
  || value === "shim"
165
+ || value === "installed"
114
166
  || value === "path"
115
167
  || value === "fallback";
116
168
  }
117
169
 
170
+ function isCodexRuntimePinOrigin(value: unknown): value is CodexRuntimePinOrigin {
171
+ return value === "pinned" || value === "discovered";
172
+ }
173
+
118
174
  export function codexRuntimeStatePath(configDir: string = getConfigDir()): string {
119
175
  return join(configDir, PERSIST_FILE);
120
176
  }
@@ -249,6 +305,9 @@ export function parsePersistedCodexRuntime(
249
305
  if (raw.selectedVersion !== undefined
250
306
  && raw.selectedVersion !== null
251
307
  && typeof raw.selectedVersion !== "string") return null;
308
+ // Absent origin is legal (pre-field files). A present value that is neither
309
+ // literal makes the whole record invalid, same as every other field.
310
+ if (raw.origin !== undefined && !isCodexRuntimePinOrigin(raw.origin)) return null;
252
311
  return cloneAndDeepFreeze(raw as PersistedCodexRuntimeState);
253
312
  } catch {
254
313
  return null;
@@ -267,9 +326,35 @@ export function loadPersistedCodexRuntime(
267
326
  }
268
327
  }
269
328
 
329
+ /**
330
+ * True only when the operator intentionally pinned this runtime.
331
+ *
332
+ * A record with no origin is NOT pinned: every such file predates this field
333
+ * and was written by resolveAndPersistCodexRuntime, which is auto-discovery.
334
+ * Reading a missing origin as an intentional pin would leave issue 4204
335
+ * unfixed on exactly the installs that have it — the still-runnable 0.135.0
336
+ * CLI that kept winning over a 0.153.4 Desktop runtime sitting right there.
337
+ */
338
+ export function persistedCodexRuntimeIsPinned(
339
+ state: DeepReadonly<PersistedCodexRuntimeState> | null | undefined,
340
+ ): boolean {
341
+ return state?.origin === "pinned";
342
+ }
343
+
344
+ /**
345
+ * Persist the selected Codex runtime.
346
+ *
347
+ * `origin` defaults to "pinned" ON PURPOSE: a direct call is a deliberate
348
+ * selection. src/cli/doctor.ts calls this from `doctor --fix`. The automatic
349
+ * discovery path is resolveAndPersistCodexRuntime, which passes "discovered"
350
+ * explicitly. Flipping the default would make doctor --fix look like an
351
+ * accident, and a later resolve would silently replace the operator's choice
352
+ * (issue 4204).
353
+ */
270
354
  export function persistCodexRuntime(
271
355
  runtime: ResolvedCodexRuntime,
272
356
  deps: ResolveCodexRuntimeDeps = {},
357
+ origin: CodexRuntimePinOrigin = "pinned",
273
358
  ): void {
274
359
  const configDir = deps.configDir ?? getConfigDir();
275
360
  mkdirSync(configDir, { recursive: true, mode: 0o700 });
@@ -279,6 +364,7 @@ export function persistCodexRuntime(
279
364
  source: runtime.source,
280
365
  selectedVersion: runtime.version,
281
366
  updatedAt: new Date((deps.now ?? Date.now)()).toISOString(),
367
+ origin,
282
368
  };
283
369
  // Invalidate process authority before the persisted replacement is visible.
284
370
  clearCodexRuntimeResolveCache();
@@ -313,7 +399,7 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}):
313
399
  function probeVersion(
314
400
  command: string,
315
401
  deps: ResolveCodexRuntimeDeps,
316
- ): { ok: true; version: string } | { ok: false; reason: string } {
402
+ ): { ok: true; version: string | null } | { ok: false; reason: string } {
317
403
  const platform = deps.platform ?? process.platform;
318
404
  if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) {
319
405
  const exists = deps.existsSync ?? existsSync;
@@ -322,6 +408,11 @@ function probeVersion(
322
408
  return { ok: false, reason: "not a spawnable Codex launcher on this platform" };
323
409
  }
324
410
  }
411
+ // The prompt probe needs a spawnable candidate, not a version. Running
412
+ // `codex --version` here is ~1s of blocking exec per candidate; on the
413
+ // dashboard probe that cost made Windows report Codex as missing even when
414
+ // the App install was sitting under LOCALAPPDATA/OpenAI/Codex/bin (issue 4458).
415
+ if (deps.probeVersion === false) return { ok: true, version: null };
325
416
  const execFile = deps.execFileSync ?? (execFileSync as unknown as RuntimeExecFile);
326
417
  // Sandbox the probe's CODEX_HOME: a real Codex CLI creates state (tmp/, logs) under
327
418
  // CODEX_HOME even for `--version`, and the probe inherits the caller's env — so a
@@ -350,6 +441,9 @@ function probeVersion(
350
441
  return { ok: true, version };
351
442
  } catch (error) {
352
443
  if (!probeHome) return { ok: false, reason: "probe sandbox unavailable" };
444
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
445
+ return { ok: false, reason: CODEX_PROGRAM_NOT_FOUND_REASON };
446
+ }
353
447
  const message = error instanceof Error ? error.message : String(error);
354
448
  const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
355
449
  return { ok: false, reason: `failed --version (${redacted})` };
@@ -401,6 +495,53 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] {
401
495
  return [...new Set(out)];
402
496
  }
403
497
 
498
+ /**
499
+ * Codex installs that PATH does not necessarily expose.
500
+ *
501
+ * The Windows Codex App writes codex.exe under
502
+ * LOCALAPPDATA/OpenAI/Codex/bin/<changing-version>/, which never appears on
503
+ * the service process PATH. The prompt probe used to hardcode four POSIX
504
+ * paths and miss that layout, then report an absent candidate (issue 4458).
505
+ * POSIX keeps those four paths so an install that resolved before this source
506
+ * existed still resolves.
507
+ */
508
+ function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] {
509
+ const platform = deps.platform ?? process.platform;
510
+ const env = deps.env ?? process.env;
511
+ if (platform === "win32") {
512
+ const localAppData = env.LOCALAPPDATA?.trim();
513
+ if (!localAppData) return [];
514
+ const root = join(localAppData, "OpenAI", "Codex", "bin");
515
+ const readDir = deps.readdirSync ?? ((path: string) => readdirSync(path));
516
+ const stat = deps.statSync ?? ((path: string) => statSync(path));
517
+ try {
518
+ const names = readDir(root);
519
+ const dirs: Array<{ name: string; directory: string; mtimeMs: number }> = [];
520
+ for (const name of names) {
521
+ const directory = join(root, name);
522
+ try {
523
+ const st = stat(directory);
524
+ if (!st.isDirectory()) continue;
525
+ dirs.push({ name, directory, mtimeMs: st.mtimeMs });
526
+ } catch {
527
+ continue;
528
+ }
529
+ }
530
+ dirs.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
531
+ return dirs.map(entry => join(entry.directory, "codex.exe"));
532
+ } catch {
533
+ return [];
534
+ }
535
+ }
536
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
537
+ return [
538
+ join(home, ".codex", "packages", "standalone", "current", "bin", "codex"),
539
+ join(home, ".local", "bin", "codex"),
540
+ "/usr/local/bin/codex",
541
+ "/opt/homebrew/bin/codex",
542
+ ];
543
+ }
544
+
404
545
  interface RankedCandidate {
405
546
  command: string;
406
547
  source: CodexRuntimeSource;
@@ -496,6 +637,20 @@ export type CodexRuntimeProcessCachePeek =
496
637
  let resolveCacheEpoch = 0;
497
638
  let resolveCache: ResolveCacheMemo | null = null;
498
639
 
640
+ /**
641
+ * Memo for probeVersion === false resolves. Kept separate from resolveCache
642
+ * because peekCodexRuntimeProcessCache is read by convergence and the bundled
643
+ * catalog as "what runtime are we on". Publishing a null version there would
644
+ * be read as "unknown version" and become process authority (issue 4458).
645
+ */
646
+ interface DeferredResolveCacheMemo {
647
+ readonly key: string;
648
+ readonly at: number;
649
+ readonly value: DeepReadonly<ResolveCodexRuntimeResult>;
650
+ }
651
+
652
+ let deferredResolveCache: DeferredResolveCacheMemo | null = null;
653
+
499
654
  /**
500
655
  * Bumped whenever persisted runtime state is replaced or process authority is cleared.
501
656
  *
@@ -522,6 +677,7 @@ function publishResolveCache(key: string, at: number, value: ResolveCodexRuntime
522
677
  function clearResolveCache(): void {
523
678
  resolveCacheEpoch += 1;
524
679
  resolveCache = null;
680
+ deferredResolveCache = null;
525
681
  }
526
682
 
527
683
  /** Clear process-local runtime authority without resolving a replacement. */
@@ -558,7 +714,15 @@ function persistedRuntimeCacheStamp(deps: ResolveCodexRuntimeDeps): string {
558
714
 
559
715
  function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
560
716
  // Only memoize uninjected process-env resolves (settings/status hot paths).
561
- if (deps.execFileSync || deps.existsSync || deps.readFileSync || deps.configDir || deps.now) {
717
+ if (
718
+ deps.execFileSync
719
+ || deps.existsSync
720
+ || deps.readFileSync
721
+ || deps.readdirSync
722
+ || deps.statSync
723
+ || deps.configDir
724
+ || deps.now
725
+ ) {
562
726
  return null;
563
727
  }
564
728
  const env = deps.env ?? process.env;
@@ -567,6 +731,10 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
567
731
  path: env.PATH ?? "",
568
732
  platform: deps.platform ?? process.platform,
569
733
  discover: deps.discoverAlternatives !== false,
734
+ probeVersion: deps.probeVersion !== false,
735
+ localAppData: env.LOCALAPPDATA?.trim() ?? "",
736
+ homeDir: env.HOME?.trim() ?? "",
737
+ userProfile: env.USERPROFILE?.trim() ?? "",
570
738
  home: process.env.OPENCODEX_HOME ?? "",
571
739
  persisted: persistedRuntimeCacheStamp(deps),
572
740
  });
@@ -577,6 +745,27 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
577
745
  */
578
746
  export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult {
579
747
  const cacheKey = resolveCacheKey(deps);
748
+ // A deferred selection has no validated version and must not publish into
749
+ // runtime authority. peekCodexRuntimeProcessCache would otherwise report
750
+ // "available" with version null, which catalog/convergence read as unknown.
751
+ if (deps.probeVersion === false) {
752
+ if (cacheKey
753
+ && deferredResolveCache
754
+ && deferredResolveCache.key === cacheKey
755
+ && Date.now() - deferredResolveCache.at < RESOLVE_CACHE_MS) {
756
+ return cloneAndDeepFreeze(deferredResolveCache.value);
757
+ }
758
+
759
+ const deferred = resolveCodexRuntimeUncached(deps);
760
+ if (!cacheKey) return cloneAndDeepFreeze(deferred);
761
+ deferredResolveCache = {
762
+ key: cacheKey,
763
+ at: Date.now(),
764
+ value: cloneAndDeepFreeze(deferred),
765
+ };
766
+ return cloneAndDeepFreeze(deferredResolveCache.value);
767
+ }
768
+
580
769
  if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) {
581
770
  return cloneAndDeepFreeze(resolveCache.value);
582
771
  }
@@ -623,18 +812,44 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
623
812
  for (const command of pathCandidates(deps)) {
624
813
  ordered.push({ command, source: "path" });
625
814
  }
815
+ for (const command of installedCodexCandidates(deps)) {
816
+ ordered.push({ command, source: "installed" });
817
+ }
626
818
  ordered.push({ command: "codex", source: "fallback" });
627
819
 
628
820
  const seen = new Set<string>();
629
821
  const valid: ResolvedCodexRuntime[] = [];
822
+ // A caller that declined PATH-wide discovery normally gets the first valid
823
+ // candidate and nothing else, which is right for a hot path and wrong for
824
+ // exactly one arrangement: an unpinned persisted selection sitting in front of
825
+ // a Codex App runtime that PATH never exposes.
826
+ //
827
+ // That arrangement is issue 4204. The catalog's bundled loader passes
828
+ // discoverAlternatives: false, so it stopped at a still-runnable codex-cli
829
+ // 0.135.0 and derived the reasoning ladder from it while the Desktop app was
830
+ // running 0.153.4 out of LOCALAPPDATA. Nothing downstream could notice,
831
+ // because the newer runtime was never probed.
832
+ //
833
+ // So the early stop keeps skipping PATH — which is the expensive part, 100+
834
+ // launcher probes on a dev machine — but still probes the `installed` roots,
835
+ // a bounded set with one entry per Codex App version directory. A pinned
836
+ // record skips even that: the operator's choice is not up for revision, and
837
+ // there is then nothing to compare it against.
838
+ const persistedIsUnpinned = Boolean(persisted?.command) && !persistedCodexRuntimeIsPinned(persisted);
630
839
  for (const candidate of ordered) {
631
840
  const key = candidate.command.toLowerCase();
632
841
  if (seen.has(key)) continue;
633
842
  seen.add(key);
843
+ if (
844
+ deps.discoverAlternatives === false
845
+ && valid.length > 0
846
+ && !(persistedIsUnpinned && candidate.source === "installed")
847
+ ) {
848
+ continue;
849
+ }
634
850
  const resolved = tryCandidate(candidate, failures, deps);
635
851
  if (!resolved) continue;
636
852
  valid.push(resolved);
637
- if (deps.discoverAlternatives === false) break;
638
853
  }
639
854
 
640
855
  if (valid.length === 0) {
@@ -644,9 +859,10 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
644
859
  };
645
860
  }
646
861
 
647
- // Prefer first valid in priority order (environment → configured → shim → path → fallback).
862
+ // Prefer first valid in priority order (environment → configured → shim → path → installed → fallback).
648
863
  let selected = valid[0]!;
649
864
  let replacedConfigured: ResolveCodexRuntimeResult["replacedConfigured"];
865
+ let supersededDiscovered: ResolveCodexRuntimeResult["supersededDiscovered"];
650
866
 
651
867
  const envValid = envPath
652
868
  ? valid.find(item => sameRuntimeCommand(item.command, envPath) && item.source === "environment")
@@ -672,6 +888,31 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
672
888
  } else if (!envValid && configuredStillValid) {
673
889
  // Stick to configured even when a later PATH entry is also valid.
674
890
  selected = valid.find(item => sameRuntimeCommand(item.command, persisted.command)) ?? selected;
891
+ // An explicit pin is the user's decision and this change must never
892
+ // silently replace it — issue 4204 says so in as many words. Stick.
893
+ // An unpinned record (missing origin, or origin "discovered") may hand
894
+ // over to a strictly newer valid candidate. Unknown (null) versions on
895
+ // either side are not evidence of an upgrade: compareCodexVersions treats
896
+ // null as less-than, which would otherwise make any known alternative
897
+ // look newer than a deferred probe. probeVersion === false yields null
898
+ // everywhere, so the comparison cannot fire there; equal versions stick.
899
+ if (!persistedCodexRuntimeIsPinned(persisted)) {
900
+ const newerDiscovered = valid
901
+ .filter(item =>
902
+ !sameRuntimeCommand(item.command, selected.command)
903
+ && typeof item.version === "string"
904
+ && typeof selected.version === "string"
905
+ && compareCodexVersions(item.version, selected.version) > 0)
906
+ .sort((a, b) => compareCodexVersions(b.version, a.version))[0];
907
+ if (newerDiscovered) {
908
+ supersededDiscovered = {
909
+ from: selected,
910
+ to: newerDiscovered,
911
+ reason: `discovered runtime ${selected.version} superseded by newer runtime ${newerDiscovered.version}`,
912
+ };
913
+ selected = newerDiscovered;
914
+ }
915
+ }
675
916
  }
676
917
  }
677
918
 
@@ -687,6 +928,7 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
687
928
  runtime: selected,
688
929
  failures,
689
930
  replacedConfigured,
931
+ supersededDiscovered,
690
932
  newerAvailable: newer,
691
933
  };
692
934
  }
@@ -707,7 +949,7 @@ export function resolveAndPersistCodexRuntime(
707
949
  && (persistedRuntime.selectedVersion ?? null) === (result.runtime.version ?? null);
708
950
  if (result.runtime.command && result.runtime.source !== "fallback" && !selectionUnchanged) {
709
951
  try {
710
- persistCodexRuntime(result.runtime, deps);
952
+ persistCodexRuntime(result.runtime, deps, "discovered");
711
953
  } catch (error) {
712
954
  const message = error instanceof Error ? error.message : String(error);
713
955
  const persistError = redactUserPath(redactSecretString(message)).slice(0, 200);
@@ -351,6 +351,49 @@ const PROVIDER_SCOPED_FAILURE_CODES = new Set([
351
351
  "insufficient_balance",
352
352
  ]);
353
353
 
354
+ /**
355
+ * Precise target-local request incompatibilities are request-local, not terminal for a combo.
356
+ * Require a bounded, intact provider envelope; never infer compatibility from echoed prompt text.
357
+ * Only OpenCodex's exact error wrapper may be unwrapped, with a fixed depth budget. Unknown or
358
+ * conflicting codes fail closed. No fields are removed here and no same-target replay is added.
359
+ * Image rejection requires `param: input` and an exact model-scoped prefix.
360
+ */
361
+ function isRequestLocalTargetIncompatibility(status: number, message: string, code?: string | null): boolean {
362
+ if (status !== 400 || message.length > 16_384) return false;
363
+ const genericCodes = new Set(["", "invalid_request_error", "unsupported_parameter", "unsupported_value"]);
364
+ if (!genericCodes.has(normalizedFailureCode(code))) return false;
365
+ let text = message.trim();
366
+ for (let depth = 0; depth < 3; depth += 1) {
367
+ if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length);
368
+ let payload: unknown;
369
+ try { payload = JSON.parse(text); } catch { return false; }
370
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
371
+ const error = (payload as Record<string, unknown>).error;
372
+ if (!error || typeof error !== "object" || Array.isArray(error)) return false;
373
+ const e = error as Record<string, unknown>;
374
+ if (e.code !== undefined && e.code !== null && typeof e.code !== "string") return false;
375
+ const errorCode = normalizedFailureCode(typeof e.code === "string" ? e.code : undefined);
376
+ if (!genericCodes.has(errorCode) || typeof e.message !== "string") return false;
377
+ if (e.type !== "invalid_request_error" && e.type !== "upstream_error") return false;
378
+ if (e.message.startsWith("Provider error 400: ") && e.param === undefined
379
+ && (errorCode === "" || errorCode === "invalid_request_error")) {
380
+ text = e.message;
381
+ continue;
382
+ }
383
+ if (e.type !== "invalid_request_error") return false;
384
+ if (e.message === "Unsupported parameter: user") {
385
+ return (e.param === undefined || e.param === "user") && errorCode !== "unsupported_value";
386
+ }
387
+ if (errorCode === "unsupported_value"
388
+ && (e.param === "reasoning.effort" || e.param === "reasoning_effort")
389
+ && e.message.startsWith("Unsupported value:") && e.message.includes("not supported")) return true;
390
+ return e.param === "input"
391
+ && (errorCode === "" || errorCode === "invalid_request_error")
392
+ && /^Model '[^']{1,256}' does not support image inputs\./.test(e.message);
393
+ }
394
+ return false;
395
+ }
396
+
354
397
  export function comboFailureCooldownScope(
355
398
  status: number,
356
399
  message: string,
@@ -363,6 +406,7 @@ export function comboFailureCooldownScope(
363
406
  || REQUEST_SHAPE_FAILURE_CODES.has(code)
364
407
  || isRequestLocalFreePromptCap(status, message, options?.code)
365
408
  || isProviderTargetContextOverflow(status, message, options?.code)
409
+ || isRequestLocalTargetIncompatibility(status, message, options?.code)
366
410
  ) return "none";
367
411
  if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider";
368
412
  // A rejected or unpaid credential is provider-wide evidence: every target that routes
@@ -465,6 +509,7 @@ export function comboFailureDecision(
465
509
  // `free_rate_limited` no longer routes through `isProviderScopedQuotaCap` (it is a
466
510
  // per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
467
511
  if (failureCode === "free_rate_limited") return "hop";
512
+ if (isRequestLocalTargetIncompatibility(status, message, options?.code)) return "hop";
468
513
  if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
469
514
  return "stop";
470
515
  }