@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.
- package/gui/dist/assets/{index-ZmFopEYw.js → index-CprFnVjr.js} +8 -8
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +197 -2
- package/src/cli/index.ts +11 -1
- package/src/cli/status.ts +80 -0
- package/src/cli/v2.ts +14 -2
- package/src/codex/catalog/bundled.ts +83 -27
- package/src/codex/catalog/effort.ts +95 -3
- package/src/codex/catalog/parsing.ts +17 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/runtime.ts +513 -0
- package/src/config.ts +21 -1
- package/src/lib/bun-stream-caps.ts +88 -0
- package/src/lib/crash-guard.ts +3 -1
- package/src/responses/parser.ts +1 -0
- package/src/server/index.ts +9 -1
- package/src/server/management/config-routes.ts +79 -3
- package/src/server/management/shared.ts +6 -6
- package/src/server/management/system-routes.ts +65 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/memory-watchdog.ts +112 -0
- package/src/server/relay-eager.ts +199 -0
- package/src/server/relay.ts +131 -81
- package/src/server/responses/collaboration.ts +20 -3
- package/src/server/responses/core.ts +52 -1
- package/src/types.ts +11 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +19 -0
- package/src/usage/summary.ts +11 -8
|
@@ -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)
|
|
261
|
-
|
|
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
|
+
}
|