@bitkyc08/opencodex 2.7.36 → 2.7.38-preview.20260724

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.
@@ -27,7 +27,7 @@ import {
27
27
  } from "../../combos";
28
28
  import type { NormalizedComboConfig } from "../../combos/types";
29
29
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
30
- import { redactSecretString } from "../../lib/redact";
30
+ import { redactSecretString, redactUserPath } from "../../lib/redact";
31
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
32
 
33
33
 
@@ -37,6 +37,14 @@ import { UPSTREAM_NATIVE_ENTRIES } from "./metadata";
37
37
  import { loadBundledCodexCatalog } from "./bundled";
38
38
  import type { BundledCatalogDeps } from "./bundled";
39
39
  import { deriveEntry } from "./sync";
40
+ import {
41
+ formatClampLogLines,
42
+ formatRuntimeLogLine,
43
+ displayCodexRuntimePath,
44
+ persistEffortClamp,
45
+ resolveAndPersistCodexRuntime,
46
+ type EffortClampDiagnostic,
47
+ } from "../runtime";
40
48
 
41
49
  export function nativeEffortClamp(slug: string, effort: string | undefined): string | null {
42
50
  if (!effort || (effort !== "max" && effort !== "ultra")) return null;
@@ -257,7 +265,91 @@ export function clampEntryToCodexSupportedEfforts(entry: RawEntry, supported: Se
257
265
 
258
266
  export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: BundledCatalogDeps = {}): RawEntry[] {
259
267
  const supported = codexSupportedReasoningEfforts(deps);
260
- if (!supported) return models;
261
- for (const entry of models) clampEntryToCodexSupportedEfforts(entry, supported);
268
+ if (!supported) {
269
+ if (!deps.commandCandidates) persistEffortClamp(null, { configDir: deps.configDir });
270
+ return models;
271
+ }
272
+
273
+ const removed = new Set<string>();
274
+ const affected: string[] = [];
275
+ for (const entry of models) {
276
+ const before = new Set(
277
+ (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
278
+ .flatMap(level => typeof (level as { effort?: string })?.effort === "string"
279
+ ? [(level as { effort: string }).effort]
280
+ : []),
281
+ );
282
+ const beforeDefault = typeof entry.default_reasoning_level === "string"
283
+ ? entry.default_reasoning_level
284
+ : null;
285
+ clampEntryToCodexSupportedEfforts(entry, supported);
286
+ const after = new Set(
287
+ (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
288
+ .flatMap(level => typeof (level as { effort?: string })?.effort === "string"
289
+ ? [(level as { effort: string }).effort]
290
+ : []),
291
+ );
292
+ const afterDefault = typeof entry.default_reasoning_level === "string"
293
+ ? entry.default_reasoning_level
294
+ : null;
295
+ const lost = [...before].filter(effort => !after.has(effort));
296
+ const defaultClamped = Boolean(beforeDefault && beforeDefault !== afterDefault);
297
+ if (lost.length > 0 || defaultClamped) {
298
+ for (const effort of lost) removed.add(effort);
299
+ if (defaultClamped && beforeDefault) removed.add(beforeDefault);
300
+ if (typeof entry.slug === "string") affected.push(entry.slug);
301
+ }
302
+ }
303
+
304
+ let runtimePath = "codex";
305
+ let runtimeVersion: string | null = null;
306
+ if (!deps.commandCandidates) {
307
+ try {
308
+ const resolved = resolveAndPersistCodexRuntime({
309
+ execFileSync: deps.execFileSync,
310
+ configDir: deps.configDir,
311
+ env: deps.env,
312
+ platform: deps.platform,
313
+ existsSync: deps.existsSync,
314
+ readFileSync: deps.readFileSync,
315
+ now: deps.now,
316
+ discoverAlternatives: deps.discoverAlternatives,
317
+ });
318
+ runtimePath = resolved.runtime.command;
319
+ runtimeVersion = resolved.runtime.version;
320
+ process.stderr.write(`${formatRuntimeLogLine(resolved.runtime)}\n`);
321
+ if (resolved.persistError) {
322
+ console.warn(`[opencodex] Codex runtime selection could not be persisted; a later sync may pick a different binary.`);
323
+ }
324
+ if (
325
+ resolved.replacedConfigured
326
+ && resolved.replacedConfigured.from.command !== resolved.runtime.command
327
+ ) {
328
+ console.warn(`[opencodex] Preferred Codex runtime is unavailable.`);
329
+ console.warn(
330
+ `[opencodex] Falling back from ${displayCodexRuntimePath(resolved.replacedConfigured.from.command)} to ${displayCodexRuntimePath(runtimePath)}.`,
331
+ );
332
+ }
333
+ } catch (error) {
334
+ const message = error instanceof Error ? error.message : String(error);
335
+ const redacted = redactUserPath(redactSecretString(message)).slice(0, 200);
336
+ console.warn(`[opencodex] Codex runtime resolve failed during catalog clamp: ${redacted}`);
337
+ }
338
+ }
339
+
340
+ if (removed.size > 0) {
341
+ const diagnostic: EffortClampDiagnostic = {
342
+ runtimePath,
343
+ runtimeVersion,
344
+ removedEfforts: [...removed].sort(),
345
+ affectedModels: affected,
346
+ };
347
+ for (const line of formatClampLogLines(diagnostic)) console.warn(line);
348
+ if (!deps.commandCandidates) persistEffortClamp(diagnostic, { configDir: deps.configDir });
349
+ deps.onEffortClamp?.(diagnostic);
350
+ } else if (!deps.commandCandidates) {
351
+ persistEffortClamp(null, { configDir: deps.configDir });
352
+ }
353
+
262
354
  return models;
263
355
  }
@@ -179,7 +179,24 @@ export function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null
179
179
  ) ?? null;
180
180
  }
181
181
 
182
+ /**
183
+ * Native OpenAI slugs that do NOT support the Fast (priority) service tier.
184
+ * Upstream may advertise service_tiers for these models, but the tier is not
185
+ * actually available — strip it so the Codex UI does not offer a dead toggle.
186
+ */
187
+ const NO_FAST_TIER_NATIVE_SLUGS = new Set([
188
+ "gpt-5.3-codex-spark",
189
+ ]);
190
+
182
191
  export function normalizeServiceTiers(entry: RawEntry): RawEntry {
192
+ // Strip service tiers for models that do not actually support the Fast tier.
193
+ if (typeof entry.slug === "string" && NO_FAST_TIER_NATIVE_SLUGS.has(entry.slug)) {
194
+ delete entry.service_tier;
195
+ delete entry.service_tiers;
196
+ delete entry.default_service_tier;
197
+ delete entry.additional_speed_tiers;
198
+ return entry;
199
+ }
183
200
  // Codex stores the user-facing config spelling as "fast", but the catalog/request
184
201
  // service tier id is "priority" in current codex-rs. Keep legacy catalogs working.
185
202
  if (entry.service_tier === "fast") entry.service_tier = "priority";
@@ -0,0 +1,22 @@
1
+ import { commandInvocation, type ResolveDeps, type SpawnInvocation } from "../lib/win-exec";
2
+
3
+ export function isSpawnableCodexCandidate(path: string, platform: NodeJS.Platform = process.platform): boolean {
4
+ if (platform !== "win32") return true;
5
+ return /\.(cmd|bat|exe|com)$/i.test(path);
6
+ }
7
+
8
+ /**
9
+ * Platform-safe Codex launcher invocation.
10
+ *
11
+ * Windows `.cmd`/`.bat` must go through `cmd.exe`, but never via `shell: true`
12
+ * (Node does not escape cmd metacharacters there). Reuse the shared
13
+ * `commandInvocation` helper (`ComSpec /d /s /c` + cross-spawn escaping).
14
+ */
15
+ export function codexExecInvocation(
16
+ command: string,
17
+ args: readonly string[],
18
+ platform: NodeJS.Platform = process.platform,
19
+ deps: ResolveDeps = {},
20
+ ): SpawnInvocation {
21
+ return commandInvocation(command, args, platform, deps);
22
+ }
@@ -0,0 +1,513 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ import { atomicWriteFile, getConfigDir } from "../config";
5
+ import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation";
6
+ import { redactSecretString, redactUserPath } from "../lib/redact";
7
+
8
+ export type CodexRuntimeSource =
9
+ | "environment"
10
+ | "configured"
11
+ | "shim"
12
+ | "path"
13
+ | "fallback";
14
+
15
+ export interface ResolvedCodexRuntime {
16
+ command: string;
17
+ version: string | null;
18
+ source: CodexRuntimeSource;
19
+ }
20
+
21
+ export interface RuntimeProbeFailure {
22
+ command: string;
23
+ source: CodexRuntimeSource;
24
+ reason: string;
25
+ }
26
+
27
+ export interface EffortClampDiagnostic {
28
+ runtimePath: string;
29
+ runtimeVersion: string | null;
30
+ removedEfforts: string[];
31
+ affectedModels: string[];
32
+ }
33
+
34
+ export interface ResolveCodexRuntimeResult {
35
+ runtime: ResolvedCodexRuntime;
36
+ failures: RuntimeProbeFailure[];
37
+ replacedConfigured?: { from: ResolvedCodexRuntime; reason: string };
38
+ newerAvailable?: ResolvedCodexRuntime;
39
+ /** Set when the selected runtime could not be written to codex-runtime.json. */
40
+ persistError?: string;
41
+ }
42
+
43
+ export type RuntimeExecFile = (
44
+ file: string,
45
+ args: string[],
46
+ options: {
47
+ encoding: "utf8";
48
+ stdio: ["ignore", "pipe", "ignore"];
49
+ timeout: number;
50
+ windowsHide: boolean;
51
+ shell?: boolean;
52
+ windowsVerbatimArguments?: boolean;
53
+ },
54
+ ) => string;
55
+
56
+ export interface ResolveCodexRuntimeDeps {
57
+ env?: NodeJS.ProcessEnv;
58
+ platform?: NodeJS.Platform;
59
+ configDir?: string;
60
+ execFileSync?: RuntimeExecFile;
61
+ existsSync?: (path: string) => boolean;
62
+ readFileSync?: (path: string, encoding: "utf8") => string;
63
+ now?: () => number;
64
+ /**
65
+ * When false, stop after the first valid priority candidate (skip PATH-wide
66
+ * newerAvailable discovery). Use for hot UI/status paths.
67
+ */
68
+ discoverAlternatives?: boolean;
69
+ }
70
+
71
+ interface PersistedRuntimeState {
72
+ version: 1;
73
+ command: string;
74
+ source: CodexRuntimeSource;
75
+ selectedVersion: string | null;
76
+ updatedAt: string;
77
+ }
78
+
79
+ const PERSIST_FILE = "codex-runtime.json";
80
+ const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json";
81
+
82
+ export function codexRuntimeStatePath(configDir: string = getConfigDir()): string {
83
+ return join(configDir, PERSIST_FILE);
84
+ }
85
+
86
+ export function codexRuntimeClampStatePath(configDir: string = getConfigDir()): string {
87
+ return join(configDir, CLAMP_PERSIST_FILE);
88
+ }
89
+
90
+ interface PersistedClampState extends EffortClampDiagnostic {
91
+ version: 1;
92
+ updatedAt: string;
93
+ }
94
+
95
+ export function loadLastEffortClamp(
96
+ deps: ResolveCodexRuntimeDeps = {},
97
+ ): EffortClampDiagnostic | null {
98
+ const configDir = deps.configDir ?? getConfigDir();
99
+ const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding));
100
+ try {
101
+ const raw = JSON.parse(read(codexRuntimeClampStatePath(configDir), "utf8")) as PersistedClampState;
102
+ if (raw?.version !== 1 || !Array.isArray(raw.removedEfforts)) return null;
103
+ return {
104
+ runtimePath: typeof raw.runtimePath === "string" ? raw.runtimePath : "codex",
105
+ runtimeVersion: typeof raw.runtimeVersion === "string" ? raw.runtimeVersion : null,
106
+ removedEfforts: raw.removedEfforts.filter((item): item is string => typeof item === "string"),
107
+ affectedModels: Array.isArray(raw.affectedModels)
108
+ ? raw.affectedModels.filter((item): item is string => typeof item === "string")
109
+ : [],
110
+ };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ export function persistEffortClamp(
117
+ diagnostic: EffortClampDiagnostic | null,
118
+ deps: ResolveCodexRuntimeDeps = {},
119
+ ): void {
120
+ const configDir = deps.configDir ?? getConfigDir();
121
+ const path = codexRuntimeClampStatePath(configDir);
122
+ if (!diagnostic || diagnostic.removedEfforts.length === 0) {
123
+ try {
124
+ unlinkSync(path);
125
+ } catch {
126
+ /* absent is fine */
127
+ }
128
+ return;
129
+ }
130
+ const payload: PersistedClampState = {
131
+ version: 1,
132
+ updatedAt: new Date((deps.now ?? Date.now)()).toISOString(),
133
+ runtimePath: diagnostic.runtimePath,
134
+ runtimeVersion: diagnostic.runtimeVersion,
135
+ removedEfforts: diagnostic.removedEfforts,
136
+ affectedModels: diagnostic.affectedModels,
137
+ };
138
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
139
+ atomicWriteFile(path, `${JSON.stringify(payload, null, 2)}\n`);
140
+ }
141
+
142
+ export function displayCodexRuntimePath(command: string): string {
143
+ if (command === "codex") return "codex";
144
+ return redactUserPath(command);
145
+ }
146
+
147
+ export function parseCodexVersionOutput(raw: string): string | null {
148
+ const text = String(raw || "").trim();
149
+ if (!text) return null;
150
+ const match = text.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/);
151
+ return match?.[1] ?? null;
152
+ }
153
+
154
+ /** Compare dotted Codex versions. Returns negative if a < b. */
155
+ export function compareCodexVersions(a: string | null, b: string | null): number {
156
+ if (!a && !b) return 0;
157
+ if (!a) return -1;
158
+ if (!b) return 1;
159
+ const parse = (value: string) => {
160
+ const dash = value.indexOf("-");
161
+ const core = dash < 0 ? value : value.slice(0, dash);
162
+ const pre = dash < 0 ? "" : value.slice(dash + 1);
163
+ const parts = core.split(".").map(part => Number.parseInt(part, 10) || 0);
164
+ // SemVer prerelease identifiers are '.'-separated; keep hyphenated ids intact.
165
+ const preParts = pre
166
+ ? pre.split(".").map(part => (/^\d+$/.test(part) ? Number.parseInt(part, 10) : part))
167
+ : [];
168
+ return { parts, preParts };
169
+ };
170
+ const left = parse(a);
171
+ const right = parse(b);
172
+ const len = Math.max(left.parts.length, right.parts.length);
173
+ for (let i = 0; i < len; i += 1) {
174
+ const diff = (left.parts[i] ?? 0) - (right.parts[i] ?? 0);
175
+ if (diff !== 0) return diff;
176
+ }
177
+ if (left.preParts.length === 0 && right.preParts.length > 0) return 1;
178
+ if (left.preParts.length > 0 && right.preParts.length === 0) return -1;
179
+ const preLen = Math.max(left.preParts.length, right.preParts.length);
180
+ for (let i = 0; i < preLen; i += 1) {
181
+ const lp = left.preParts[i];
182
+ const rp = right.preParts[i];
183
+ if (lp === undefined) return -1;
184
+ if (rp === undefined) return 1;
185
+ if (typeof lp === "number" && typeof rp === "number") {
186
+ if (lp !== rp) return lp - rp;
187
+ continue;
188
+ }
189
+ if (typeof lp === "number") return -1; // numeric < non-numeric (SemVer)
190
+ if (typeof rp === "number") return 1;
191
+ if (lp < rp) return -1;
192
+ if (lp > rp) return 1;
193
+ }
194
+ return 0;
195
+ }
196
+
197
+ export function loadPersistedCodexRuntime(
198
+ deps: ResolveCodexRuntimeDeps = {},
199
+ ): PersistedRuntimeState | null {
200
+ const configDir = deps.configDir ?? getConfigDir();
201
+ const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding));
202
+ try {
203
+ const raw = JSON.parse(read(codexRuntimeStatePath(configDir), "utf8")) as PersistedRuntimeState;
204
+ if (raw?.version !== 1 || typeof raw.command !== "string" || !raw.command.trim()) return null;
205
+ return raw;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ export function persistCodexRuntime(
212
+ runtime: ResolvedCodexRuntime,
213
+ deps: ResolveCodexRuntimeDeps = {},
214
+ ): void {
215
+ const configDir = deps.configDir ?? getConfigDir();
216
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
217
+ const payload: PersistedRuntimeState = {
218
+ version: 1,
219
+ command: runtime.command,
220
+ source: runtime.source,
221
+ selectedVersion: runtime.version,
222
+ updatedAt: new Date((deps.now ?? Date.now)()).toISOString(),
223
+ };
224
+ atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`);
225
+ // Same-process consumers must re-resolve; catalog cache is keyed by runtime identity.
226
+ resolveCache = null;
227
+ }
228
+
229
+ function probeVersion(
230
+ command: string,
231
+ deps: ResolveCodexRuntimeDeps,
232
+ ): { ok: true; version: string } | { ok: false; reason: string } {
233
+ const platform = deps.platform ?? process.platform;
234
+ if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) {
235
+ const exists = deps.existsSync ?? existsSync;
236
+ if (!exists(command)) return { ok: false, reason: "path does not exist" };
237
+ if (!isSpawnableCodexCandidate(command, platform)) {
238
+ return { ok: false, reason: "not a spawnable Codex launcher on this platform" };
239
+ }
240
+ }
241
+ const execFile = deps.execFileSync ?? (execFileSync as unknown as RuntimeExecFile);
242
+ try {
243
+ const invocation = codexExecInvocation(command, ["--version"], platform, {
244
+ env: deps.env,
245
+ exists: deps.existsSync,
246
+ });
247
+ const output = execFile(invocation.file, invocation.args, {
248
+ encoding: "utf8",
249
+ stdio: ["ignore", "pipe", "ignore"],
250
+ timeout: 8_000,
251
+ windowsHide: true,
252
+ ...invocation.options,
253
+ });
254
+ const version = parseCodexVersionOutput(output);
255
+ if (!version) {
256
+ return { ok: false, reason: "unrecognized --version output" };
257
+ }
258
+ return { ok: true, version };
259
+ } catch (error) {
260
+ const message = error instanceof Error ? error.message : String(error);
261
+ const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
262
+ return { ok: false, reason: `failed --version (${redacted})` };
263
+ }
264
+ }
265
+
266
+ function shimCandidates(deps: ResolveCodexRuntimeDeps): string[] {
267
+ const configDir = deps.configDir ?? getConfigDir();
268
+ const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding));
269
+ const platform = deps.platform ?? process.platform;
270
+ try {
271
+ const state = JSON.parse(read(join(configDir, "codex-shim.json"), "utf8")) as {
272
+ wrapperPath?: unknown;
273
+ originalPath?: unknown;
274
+ backupPath?: unknown;
275
+ wrappers?: Array<{ wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown }>;
276
+ };
277
+ const files = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state];
278
+ const out: string[] = [];
279
+ for (const file of files) {
280
+ for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) {
281
+ if (typeof value !== "string" || value.length === 0) continue;
282
+ if (!isSpawnableCodexCandidate(value, platform)) continue;
283
+ out.push(value);
284
+ }
285
+ }
286
+ return [...new Set(out)];
287
+ } catch {
288
+ return [];
289
+ }
290
+ }
291
+
292
+ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] {
293
+ const env = deps.env ?? process.env;
294
+ const platform = deps.platform ?? process.platform;
295
+ const out: string[] = [];
296
+ for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
297
+ if (platform === "win32") {
298
+ out.push(join(dir, "codex.exe"), join(dir, "codex.cmd"));
299
+ } else {
300
+ out.push(join(dir, "codex"));
301
+ }
302
+ }
303
+ return [...new Set(out)];
304
+ }
305
+
306
+ interface RankedCandidate {
307
+ command: string;
308
+ source: CodexRuntimeSource;
309
+ }
310
+
311
+ function tryCandidate(
312
+ candidate: RankedCandidate,
313
+ failures: RuntimeProbeFailure[],
314
+ deps: ResolveCodexRuntimeDeps,
315
+ ): ResolvedCodexRuntime | null {
316
+ const probed = probeVersion(candidate.command, deps);
317
+ if (!probed.ok) {
318
+ failures.push({ command: candidate.command, source: candidate.source, reason: probed.reason });
319
+ return null;
320
+ }
321
+ return {
322
+ command: candidate.command,
323
+ version: probed.version,
324
+ source: candidate.source,
325
+ };
326
+ }
327
+
328
+ function sameRuntimeCommand(a: string, b: string): boolean {
329
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
330
+ }
331
+
332
+ /** True when a persisted clamp diagnostic still applies to the currently selected runtime. */
333
+ export function effortClampAppliesToRuntime(
334
+ diagnostic: EffortClampDiagnostic | null | undefined,
335
+ runtime: Pick<ResolvedCodexRuntime, "command" | "version">,
336
+ ): boolean {
337
+ if (!diagnostic || diagnostic.removedEfforts.length === 0) return false;
338
+ if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) return true;
339
+ return Boolean(
340
+ diagnostic.runtimeVersion
341
+ && runtime.version
342
+ && diagnostic.runtimeVersion === runtime.version,
343
+ );
344
+ }
345
+
346
+ const RESOLVE_CACHE_MS = 15_000;
347
+ let resolveCache: { key: string; at: number; value: ResolveCodexRuntimeResult } | null = null;
348
+
349
+ function persistedRuntimeCacheStamp(deps: ResolveCodexRuntimeDeps): string {
350
+ // Include on-disk selection so doctor --fix in another process busts this memo.
351
+ const configDir = deps.configDir ?? getConfigDir();
352
+ const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding));
353
+ try {
354
+ const raw = JSON.parse(read(codexRuntimeStatePath(configDir), "utf8")) as PersistedRuntimeState;
355
+ if (raw?.version !== 1 || typeof raw.command !== "string") return "";
356
+ return `${raw.command}|${raw.selectedVersion ?? ""}|${raw.updatedAt ?? ""}`;
357
+ } catch {
358
+ return "";
359
+ }
360
+ }
361
+
362
+ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
363
+ // Only memoize uninjected process-env resolves (settings/status hot paths).
364
+ if (deps.execFileSync || deps.existsSync || deps.readFileSync || deps.configDir || deps.now) {
365
+ return null;
366
+ }
367
+ const env = deps.env ?? process.env;
368
+ return JSON.stringify({
369
+ cli: env.CODEX_CLI_PATH ?? "",
370
+ path: env.PATH ?? "",
371
+ platform: deps.platform ?? process.platform,
372
+ discover: deps.discoverAlternatives !== false,
373
+ home: process.env.OPENCODEX_HOME ?? "",
374
+ persisted: persistedRuntimeCacheStamp(deps),
375
+ });
376
+ }
377
+
378
+ /**
379
+ * Resolve the single Codex runtime OpenCodex should use for sync, clamp, and probes.
380
+ */
381
+ export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult {
382
+ const cacheKey = resolveCacheKey(deps);
383
+ if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) {
384
+ return resolveCache.value;
385
+ }
386
+
387
+ const result = resolveCodexRuntimeUncached(deps);
388
+ if (cacheKey) resolveCache = { key: cacheKey, at: Date.now(), value: result };
389
+ return result;
390
+ }
391
+
392
+ /** Test-only: drop the short-lived process resolve cache. */
393
+ export function resetCodexRuntimeResolveCacheForTests(): void {
394
+ resolveCache = null;
395
+ }
396
+
397
+ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult {
398
+ const env = deps.env ?? process.env;
399
+ const failures: RuntimeProbeFailure[] = [];
400
+ const ordered: RankedCandidate[] = [];
401
+
402
+ const envPath = env.CODEX_CLI_PATH?.trim();
403
+ if (envPath) ordered.push({ command: envPath, source: "environment" });
404
+
405
+ const persisted = loadPersistedCodexRuntime(deps);
406
+ if (persisted?.command) {
407
+ ordered.push({ command: persisted.command, source: "configured" });
408
+ }
409
+
410
+ for (const command of shimCandidates(deps)) {
411
+ ordered.push({ command, source: "shim" });
412
+ }
413
+ for (const command of pathCandidates(deps)) {
414
+ ordered.push({ command, source: "path" });
415
+ }
416
+ ordered.push({ command: "codex", source: "fallback" });
417
+
418
+ const seen = new Set<string>();
419
+ const valid: ResolvedCodexRuntime[] = [];
420
+ for (const candidate of ordered) {
421
+ const key = candidate.command.toLowerCase();
422
+ if (seen.has(key)) continue;
423
+ seen.add(key);
424
+ const resolved = tryCandidate(candidate, failures, deps);
425
+ if (!resolved) continue;
426
+ valid.push(resolved);
427
+ if (deps.discoverAlternatives === false) break;
428
+ }
429
+
430
+ if (valid.length === 0) {
431
+ return {
432
+ runtime: { command: "codex", version: null, source: "fallback" },
433
+ failures,
434
+ };
435
+ }
436
+
437
+ // Prefer first valid in priority order (environment → configured → shim → path → fallback).
438
+ let selected = valid[0]!;
439
+ let replacedConfigured: ResolveCodexRuntimeResult["replacedConfigured"];
440
+
441
+ const envValid = envPath
442
+ ? valid.find(item => sameRuntimeCommand(item.command, envPath) && item.source === "environment")
443
+ : undefined;
444
+ if (envValid) selected = envValid;
445
+
446
+ if (persisted?.command) {
447
+ const configuredStillValid = valid.some(item => sameRuntimeCommand(item.command, persisted.command));
448
+ // Only emit replacement diagnostics when we actually searched alternatives or
449
+ // the preferred configured runtime was probed and rejected.
450
+ const configuredRejected = failures.some(item => sameRuntimeCommand(item.command, persisted.command));
451
+ if (!configuredStillValid && (configuredRejected || deps.discoverAlternatives !== false) && !envValid) {
452
+ replacedConfigured = {
453
+ from: {
454
+ command: persisted.command,
455
+ version: persisted.selectedVersion,
456
+ source: "configured",
457
+ },
458
+ reason: failures.find(item => sameRuntimeCommand(item.command, persisted.command))?.reason
459
+ ?? "configured runtime is no longer valid",
460
+ };
461
+ // Keep priority-selected replacement (already in `selected`).
462
+ } else if (!envValid && configuredStillValid) {
463
+ // Stick to configured even when a later PATH entry is also valid.
464
+ selected = valid.find(item => sameRuntimeCommand(item.command, persisted.command)) ?? selected;
465
+ }
466
+ }
467
+
468
+ const newerAvailable = valid
469
+ .filter(item => !sameRuntimeCommand(item.command, selected.command))
470
+ .sort((a, b) => compareCodexVersions(b.version, a.version))[0];
471
+ const newer =
472
+ newerAvailable && compareCodexVersions(newerAvailable.version, selected.version) > 0
473
+ ? newerAvailable
474
+ : undefined;
475
+
476
+ return {
477
+ runtime: selected,
478
+ failures,
479
+ replacedConfigured,
480
+ newerAvailable: newer,
481
+ };
482
+ }
483
+
484
+ /** Resolve and persist a successful selection (unless source is ephemeral fallback-only with no path). */
485
+ export function resolveAndPersistCodexRuntime(
486
+ deps: ResolveCodexRuntimeDeps = {},
487
+ ): ResolveCodexRuntimeResult {
488
+ const result = resolveCodexRuntime(deps);
489
+ if (result.runtime.command && result.runtime.source !== "fallback") {
490
+ try {
491
+ persistCodexRuntime(result.runtime, deps);
492
+ } catch (error) {
493
+ const message = error instanceof Error ? error.message : String(error);
494
+ const persistError = redactUserPath(redactSecretString(message)).slice(0, 200);
495
+ console.warn(`[opencodex] Failed to persist Codex runtime selection: ${persistError}`);
496
+ return { ...result, persistError };
497
+ }
498
+ }
499
+ return result;
500
+ }
501
+
502
+ export function formatRuntimeLogLine(runtime: ResolvedCodexRuntime): string {
503
+ const path = displayCodexRuntimePath(runtime.command);
504
+ return `[opencodex] Codex runtime: ${path} (version=${runtime.version ?? "unknown"}, source=${runtime.source})`;
505
+ }
506
+
507
+ export function formatClampLogLines(diagnostic: EffortClampDiagnostic): string[] {
508
+ const efforts = diagnostic.removedEfforts.join(", ");
509
+ return [
510
+ `[opencodex] Removed unsupported reasoning efforts: ${efforts}`,
511
+ "[opencodex] Run ocx doctor for diagnosis and recovery.",
512
+ ];
513
+ }