@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,11 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { basename } from "node:path";
3
3
  import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation";
4
+ import {
5
+ listProcessSnapshots,
6
+ tokenizeCommandLine,
7
+ type ProcessSnapshot,
8
+ } from "./app-server-processes";
4
9
 
5
10
  const PROCESS_LIST_MAX_BUFFER = 16 * 1024 * 1024;
6
11
  const DIRECT_CODEX_BASENAMES = new Set(["codex", "codex.exe"]);
@@ -39,6 +44,114 @@ export type NativeCodexProcessProbe =
39
44
  | { status: "busy"; count: number }
40
45
  | { status: "unknown"; count: 0 };
41
46
 
47
+ export interface CodexClientProcess {
48
+ pid: number;
49
+ commandLine: string;
50
+ }
51
+
52
+ /**
53
+ * Synchronous Codex-CLI process list for routing-adoption (#4550).
54
+ *
55
+ * "enumerated" is a successful read, including the empty list, which means
56
+ * no matching client is running. "unavailable" means we could not name PIDs:
57
+ * a failed snapshot walk. The two must not collapse. An empty array is
58
+ * "none running" and would otherwise produce a false adopted. Windows is
59
+ * enumerable here: listProcessSnapshots already returns CLI command lines.
60
+ * Do not route through listCodexAppServerProcesses; that maps a failed
61
+ * walk to [] for the #476 kill contract.
62
+ */
63
+ export type CodexClientProcessList =
64
+ | { status: "enumerated"; processes: CodexClientProcess[] }
65
+ | { status: "unavailable" };
66
+
67
+ export interface ListCodexClientProcessesOptions {
68
+ platform?: NodeJS.Platform;
69
+ pid?: number;
70
+ getuid?: () => number | undefined;
71
+ /** Test seam: raw snapshots. Enumeration failure is thrown, not []. */
72
+ listSnapshots?: () => ProcessSnapshot[];
73
+ }
74
+
75
+ /**
76
+ * True when a ps comm field plus args string is a Codex CLI client.
77
+ *
78
+ * Direct "codex" / "codex.exe" basenames, or a known interpreter whose
79
+ * immediate entrypoint is a Codex CLI script. The busy-count probe and the
80
+ * routing-adoption lister both call this so the rules cannot drift (#2457).
81
+ */
82
+ export function isCodexClientProcess(command: string, args: string): boolean {
83
+ const comm = basename(command).toLowerCase();
84
+ const [rawArgv0 = "", rawEntrypoint = ""] = args.trim().split(/\s+/, 2);
85
+ const argv0 = basename(rawArgv0).toLowerCase();
86
+ const entrypoint = basename(rawEntrypoint).toLowerCase();
87
+ const isDirectCodex = DIRECT_CODEX_BASENAMES.has(comm)
88
+ || DIRECT_CODEX_BASENAMES.has(argv0);
89
+ const isInterpreterWrappedCodex = CODEX_INTERPRETER_BASENAMES.has(argv0)
90
+ && CODEX_ENTRYPOINT_BASENAMES.has(entrypoint);
91
+ return isDirectCodex || isInterpreterWrappedCodex;
92
+ }
93
+
94
+ function parseUnixPsLine(line: string): { pid: number; command: string; args: string } | null {
95
+ const match = line.trim().match(/^(\d+)\s+(\S+)\s*(.*)$/);
96
+ if (!match) return null;
97
+ const pid = Number(match[1]);
98
+ if (!Number.isSafeInteger(pid)) return null;
99
+ return { pid, command: match[2]!, args: match[3]! };
100
+ }
101
+
102
+ function unixCodexClientsFromPs(output: string, selfPid: number): CodexClientProcess[] {
103
+ const processes: CodexClientProcess[] = [];
104
+ for (const line of output.split("\n")) {
105
+ const parsed = parseUnixPsLine(line);
106
+ if (!parsed || parsed.pid === selfPid) continue;
107
+ if (!isCodexClientProcess(parsed.command, parsed.args)) continue;
108
+ processes.push({
109
+ pid: parsed.pid,
110
+ commandLine: parsed.args.trim() || parsed.command,
111
+ });
112
+ }
113
+ return processes;
114
+ }
115
+
116
+ /**
117
+ * Name running Codex CLI processes so status can tell a pre-injection client
118
+ * from one that had a chance to read the injected route (#4550).
119
+ *
120
+ * probeNativeCodexProcesses is async and count-only; collectStartupHealth is
121
+ * synchronous, so this path cannot reuse it. Snapshots come from
122
+ * listProcessSnapshots so Windows CLI PIDs are named too; a thrown walk
123
+ * stays unavailable rather than an empty adopted list.
124
+ */
125
+ export function listCodexClientProcesses({
126
+ platform = process.platform,
127
+ pid = process.pid,
128
+ getuid,
129
+ listSnapshots,
130
+ }: ListCodexClientProcessesOptions = {}): CodexClientProcessList {
131
+ try {
132
+ const snapshots = listSnapshots
133
+ ? listSnapshots()
134
+ : listProcessSnapshots({ platform, getuid });
135
+ const seen = new Set<number>();
136
+ const processes: CodexClientProcess[] = [];
137
+ for (const snapshot of snapshots) {
138
+ if (snapshot.pid === pid || seen.has(snapshot.pid)) continue;
139
+ if (!snapshotIsCodexClient(snapshot)) continue;
140
+ seen.add(snapshot.pid);
141
+ processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine });
142
+ }
143
+ return { status: "enumerated", processes };
144
+ } catch {
145
+ return { status: "unavailable" };
146
+ }
147
+ }
148
+
149
+ function snapshotIsCodexClient(snapshot: ProcessSnapshot): boolean {
150
+ if (isCodexClientProcess(snapshot.executable ?? "", snapshot.commandLine)) return true;
151
+ const argv0 = tokenizeCommandLine(snapshot.commandLine)[0] ?? "";
152
+ return argv0 !== "" && isCodexClientProcess(argv0, snapshot.commandLine);
153
+ }
154
+
42
155
  /** Async, shell-free child execution with runtime-enforced timeout and output bounds. */
43
156
  export const executeNativeProcess: NativeProcessExecutor = (file, args, options) => new Promise((resolve, reject) => {
44
157
  execFile(file, args, {
@@ -87,21 +200,7 @@ async function unixProcessCount(run: NativeProcessExecutor, pid: number): Promis
87
200
  shell: false,
88
201
  killSignal: "SIGKILL",
89
202
  });
90
- let count = 0;
91
- for (const line of output.split("\n")) {
92
- const match = line.trim().match(/^(\d+)\s+(\S+)\s*(.*)$/);
93
- if (!match || Number(match[1]) === pid) continue;
94
- const command = basename(match[2]!).toLowerCase();
95
- const [rawArgv0 = "", rawEntrypoint = ""] = match[3]!.trim().split(/\s+/, 2);
96
- const argv0 = basename(rawArgv0).toLowerCase();
97
- const entrypoint = basename(rawEntrypoint).toLowerCase();
98
- const isDirectCodex = DIRECT_CODEX_BASENAMES.has(command)
99
- || DIRECT_CODEX_BASENAMES.has(argv0);
100
- const isInterpreterWrappedCodex = CODEX_INTERPRETER_BASENAMES.has(argv0)
101
- && CODEX_ENTRYPOINT_BASENAMES.has(entrypoint);
102
- if (isDirectCodex || isInterpreterWrappedCodex) count += 1;
103
- }
104
- return count;
203
+ return unixCodexClientsFromPs(output, pid).length;
105
204
  }
106
205
 
107
206
  /** Best-effort, read-only process probe. It never terminates a user process. */
@@ -19,8 +19,15 @@
19
19
  import { spawn } from "node:child_process";
20
20
  import { existsSync, statSync } from "node:fs";
21
21
  import { join } from "node:path";
22
- import { homedir } from "node:os";
22
+ import { codexExecInvocation } from "./exec-invocation";
23
23
  import { resolveCodexHomeDir } from "./home";
24
+ import {
25
+ CODEX_PROGRAM_NOT_FOUND_REASON,
26
+ displayCodexRuntimePath,
27
+ resolveCodexRuntime,
28
+ type CodexRuntimeSource,
29
+ type ResolveCodexRuntimeResult,
30
+ } from "./runtime";
24
31
 
25
32
  /**
26
33
  * Layer id -> the tag Codex actually renders it under.
@@ -79,27 +86,49 @@ export interface LayerText {
79
86
  sourcePath?: string;
80
87
  }
81
88
 
89
+ /**
90
+ * Why a probe failed, as a stable token the caller can branch on. The prose
91
+ * `detail` string is kept for display, but matching on it was never a contract:
92
+ * a caller that needs "is Codex installed at all" versus "Codex rejected the
93
+ * command" cannot get that from a sentence.
94
+ */
95
+ export type PromptProbeFailureKind =
96
+ | "program-not-found"
97
+ | "command-unsupported"
98
+ | "execution-failed"
99
+ | "output-invalid";
100
+
101
+ export interface PromptProbeFailure {
102
+ kind: PromptProbeFailureKind;
103
+ /** The command line that was attempted or resolved, for display. */
104
+ command: string;
105
+ /**
106
+ * A short fixed phrase plus the command - never captured process output.
107
+ * Codex stderr can carry the user's config path, model name, or environment
108
+ * details, and this response is served over the management API, so raw
109
+ * process output does not belong in it.
110
+ */
111
+ detail: string;
112
+ }
113
+
82
114
  export interface PromptTextProbe {
83
115
  ok: boolean;
84
116
  /** The Codex home the probe reported on. */
85
117
  codexHome: string;
86
118
  layers: Record<string, LayerText>;
119
+ /** The runtime the probe resolved and tried, when resolution produced one. */
120
+ runtime?: { command: string; source: CodexRuntimeSource };
121
+ /** Stable failure classification; `detail` remains the display string. */
122
+ failure?: PromptProbeFailure;
87
123
  detail?: string;
88
124
  }
89
125
 
90
- function resolveCodexBinary(): string | null {
91
- const candidates = [
92
- join(homedir(), ".codex/packages/standalone/current/bin/codex"),
93
- join(homedir(), ".local/bin/codex"),
94
- "/usr/local/bin/codex",
95
- "/opt/homebrew/bin/codex",
96
- ];
97
- return candidates.find(path => existsSync(path)) ?? null;
98
- }
99
-
100
126
  /** 8 MiB is far above any real prompt and far below anything that hurts the server. */
101
127
  const MAX_PROBE_OUTPUT_BYTES = 8 * 1024 * 1024;
102
128
 
129
+ /** stderr is captured only to classify the failure, never to echo back. */
130
+ const MAX_PROBE_STDERR_BYTES = 64 * 1024;
131
+
103
132
  interface ProbeCommand {
104
133
  binary: string;
105
134
  args: string[];
@@ -111,7 +140,7 @@ interface ProbeCommand {
111
140
  interface PromptProbeFlight {
112
141
  key: string;
113
142
  controller: AbortController;
114
- result: Promise<string | null>;
143
+ result: Promise<PromptProbeExecutionResult | null>;
115
144
  closed: Promise<void>;
116
145
  waiters: number;
117
146
  joinable: boolean;
@@ -120,17 +149,27 @@ interface PromptProbeFlight {
120
149
  }
121
150
 
122
151
  interface PromptProbeExecution {
123
- result: Promise<string | null>;
152
+ result: Promise<PromptProbeExecutionResult | null>;
124
153
  closed: Promise<void>;
125
154
  }
126
155
 
156
+ /**
157
+ * The process outcome travels with its classification so a shared flight hands
158
+ * every joined caller the same `failure`, not just the same null.
159
+ */
160
+ interface PromptProbeExecutionResult {
161
+ raw: string | null;
162
+ failure: PromptProbeFailure | null;
163
+ }
164
+
127
165
  type SharedPromptProbeOutcome =
128
166
  | { kind: "output"; raw: string }
129
- | { kind: "failed" }
167
+ | { kind: "failed"; failure?: PromptProbeFailure }
130
168
  | { kind: "busy" };
131
169
 
132
170
  let activePromptProbe: PromptProbeFlight | null = null;
133
171
  let probeCommandForTests: { binary: string; args: string[] } | null = null;
172
+ let probeRuntimeForTests: { command: string; source: CodexRuntimeSource } | null | undefined;
134
173
  let probeSpawnAttemptsForTests = 0;
135
174
  let probeCloseBarrierForTests: Promise<void> | null = null;
136
175
 
@@ -144,24 +183,85 @@ function commandKey(command: ProbeCommand): string {
144
183
  ]);
145
184
  }
146
185
 
147
- function completedExecution(value: string | null): PromptProbeExecution {
186
+ function completedExecution(value: PromptProbeExecutionResult | null): PromptProbeExecution {
148
187
  return { result: Promise.resolve(value), closed: Promise.resolve() };
149
188
  }
150
189
 
190
+ /**
191
+ * The command line as it may be shown to a caller.
192
+ *
193
+ * Redacted, because this response is served over the management API and a
194
+ * resolved Codex path is a user path: the Windows Codex App lives under the
195
+ * profile directory, so echoing the raw command would put the account name in
196
+ * a diagnostic. `displayCodexRuntimePath` is the same helper the runtime log
197
+ * line and doctor output already use, so the probe reports a path in the form
198
+ * the rest of the product reports it.
199
+ */
200
+ function commandDescription(command: ProbeCommand): string {
201
+ return [displayCodexRuntimePath(command.binary), ...command.args].join(" ");
202
+ }
203
+
204
+ function probeFailure(
205
+ command: ProbeCommand,
206
+ kind: PromptProbeFailureKind,
207
+ detail: string,
208
+ ): PromptProbeFailure {
209
+ return { kind, command: commandDescription(command), detail };
210
+ }
211
+
212
+ /**
213
+ * A non-zero exit whose stderr reports an unknown subcommand means the resolved
214
+ * binary is a Codex too old (or too new) for `debug prompt-input` - a different
215
+ * remedy than "the process died". The stderr text itself is used only for this
216
+ * check; it never enters the response.
217
+ */
218
+ function classifyProcessFailure(command: ProbeCommand, code: number | null, stderr: string): PromptProbeFailure {
219
+ const lower = stderr.toLowerCase();
220
+ const unsupported =
221
+ (/unrecognized|unknown|unexpected|invalid/.test(lower) && /subcommand|command|argument|option/.test(lower))
222
+ || /usage:/.test(lower);
223
+ const kind: PromptProbeFailureKind = unsupported ? "command-unsupported" : "execution-failed";
224
+ const phrase = unsupported
225
+ ? "codex does not support this probe command"
226
+ : `codex probe exited with code ${code ?? "unknown"}`;
227
+ return probeFailure(command, kind, `${phrase}: ${commandDescription(command)}`);
228
+ }
229
+
230
+ /**
231
+ * The resolver reports why each candidate lost. A candidate that is simply not
232
+ * there (issue 4458's repeated "path does not exist" on Windows) is a
233
+ * program-not-found; a candidate that exists but could not be probed is an
234
+ * execution problem on an installed program.
235
+ */
236
+ function classifyRuntimeFailure(result: ResolveCodexRuntimeResult): PromptProbeFailure {
237
+ const isNotFound = (reason: string) =>
238
+ reason === CODEX_PROGRAM_NOT_FOUND_REASON || /does not exist|not found|ENOENT/i.test(reason);
239
+ const representative = result.failures.find(item => !isNotFound(item.reason))
240
+ ?? result.failures[0];
241
+ const kind: PromptProbeFailureKind = !representative || isNotFound(representative.reason)
242
+ ? "program-not-found"
243
+ : "execution-failed";
244
+ // Same redaction obligation as commandDescription: a rejected candidate is a
245
+ // real filesystem path, and every one of them is reported to the caller.
246
+ const command = displayCodexRuntimePath(representative?.command ?? result.runtime.command);
247
+ const phrase = kind === "program-not-found" ? "codex program not found" : "codex runtime could not be probed";
248
+ return { kind, command, detail: `${phrase}: ${command}` };
249
+ }
250
+
151
251
  function runProbe(
152
252
  command: ProbeCommand,
153
253
  signal: AbortSignal,
154
254
  onStopping: () => void,
155
255
  ): PromptProbeExecution {
156
- if (signal.aborted) return completedExecution(null);
157
- let resolveResult!: (value: string | null) => void;
256
+ if (signal.aborted) return completedExecution({ raw: null, failure: null });
257
+ let resolveResult!: (value: PromptProbeExecutionResult | null) => void;
158
258
  let resolveClosed!: () => void;
159
- const result = new Promise<string | null>(resolve => { resolveResult = resolve; });
259
+ const result = new Promise<PromptProbeExecutionResult | null>(resolve => { resolveResult = resolve; });
160
260
  const closed = new Promise<void>(resolve => { resolveClosed = resolve; });
161
261
  let resultSettled = false;
162
262
  let closeSettled = false;
163
263
 
164
- const finishResult = (value: string | null) => {
264
+ const finishResult = (value: PromptProbeExecutionResult | null) => {
165
265
  if (resultSettled) return;
166
266
  resultSettled = true;
167
267
  resolveResult(value);
@@ -179,22 +279,39 @@ function runProbe(
179
279
  let child: ReturnType<typeof spawn>;
180
280
  try {
181
281
  if (probeCommandForTests) probeSpawnAttemptsForTests += 1;
182
- child = spawn(command.binary, command.args, {
282
+ // Route through the shared invocation helper: on Windows a resolved
283
+ // `codex.cmd` cannot be spawned directly and must go through cmd.exe,
284
+ // which `commandInvocation` does with correct metacharacter escaping.
285
+ const invocation = codexExecInvocation(command.binary, command.args, process.platform);
286
+ child = spawn(invocation.file, invocation.args, {
183
287
  cwd: command.cwd,
184
- stdio: ["ignore", "pipe", "ignore"],
288
+ stdio: ["ignore", "pipe", "pipe"],
289
+ windowsHide: true,
290
+ ...invocation.options,
291
+ });
292
+ } catch (error) {
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ finishResult({
295
+ raw: null,
296
+ failure: probeFailure(
297
+ command,
298
+ /ENOENT|not found/i.test(message) ? "program-not-found" : "execution-failed",
299
+ `codex probe could not start: ${commandDescription(command)}`,
300
+ ),
185
301
  });
186
- } catch {
187
- finishResult(null);
188
302
  finishClosed();
189
303
  return { result, closed };
190
304
  }
191
305
  const chunks: Buffer[] = [];
306
+ const errorChunks: Buffer[] = [];
192
307
  let size = 0;
308
+ let errorSize = 0;
193
309
  let settled = false;
194
310
  let stopping = false;
311
+ let stoppingFailure: PromptProbeFailure | null = null;
195
312
  let timer: ReturnType<typeof setTimeout> | undefined;
196
313
 
197
- const finish = (value: string | null) => {
314
+ const finish = (value: PromptProbeExecutionResult) => {
198
315
  if (settled) return;
199
316
  settled = true;
200
317
  if (timer) clearTimeout(timer);
@@ -205,9 +322,16 @@ function runProbe(
205
322
 
206
323
  // Keep the flight admitted until `close`: kill() only requests termination
207
324
  // and does not prove the exact child has released its process and stdio.
208
- const terminate = () => {
325
+ const terminate = (
326
+ failure = probeFailure(
327
+ command,
328
+ "execution-failed",
329
+ `codex probe was terminated: ${commandDescription(command)}`,
330
+ ),
331
+ ) => {
209
332
  if (settled || stopping) return;
210
333
  stopping = true;
334
+ stoppingFailure = failure;
211
335
  onStopping();
212
336
  if (timer) clearTimeout(timer);
213
337
  signal.removeEventListener("abort", onAbort);
@@ -215,7 +339,7 @@ function runProbe(
215
339
  // The caller is bounded even if OS termination later fails. Admission is
216
340
  // retained separately by `closed`, and later probes fail soft while this
217
341
  // exact child remains unproven terminal.
218
- finishResult(null);
342
+ finishResult({ raw: null, failure });
219
343
  if (child.exitCode !== null || child.signalCode !== null) return;
220
344
  try {
221
345
  child.kill("SIGKILL");
@@ -233,18 +357,50 @@ function runProbe(
233
357
  if (size > MAX_PROBE_OUTPUT_BYTES) { terminate(); return; }
234
358
  chunks.push(chunk);
235
359
  });
236
- child.on("error", () => {
360
+ child.stderr?.on("data", (chunk: Buffer) => {
361
+ errorSize += chunk.length;
362
+ if (errorSize <= MAX_PROBE_STDERR_BYTES) errorChunks.push(chunk);
363
+ });
364
+ child.on("error", error => {
237
365
  // No PID means spawn itself failed, so there is no live child to drain.
238
366
  if (child.pid === undefined) {
239
- finish(null);
367
+ const message = error instanceof Error ? error.message : String(error);
368
+ finish({
369
+ raw: null,
370
+ failure: probeFailure(
371
+ command,
372
+ /ENOENT|not found/i.test(message) ? "program-not-found" : "execution-failed",
373
+ `codex probe could not start: ${commandDescription(command)}`,
374
+ ),
375
+ });
240
376
  }
241
- else terminate();
377
+ else terminate(probeFailure(
378
+ command,
379
+ "execution-failed",
380
+ `codex probe process error: ${commandDescription(command)}`,
381
+ ));
242
382
  });
243
383
  child.on("close", code => {
244
384
  // Decode once, at the end: `String(chunk)` per chunk corrupts any UTF-8
245
385
  // character that straddles a chunk boundary.
246
386
  const recordClose = () => {
247
- finish(!stopping && code === 0 ? Buffer.concat(chunks).toString("utf8") : null);
387
+ if (stopping) {
388
+ finish({
389
+ raw: null,
390
+ failure: stoppingFailure ?? probeFailure(
391
+ command,
392
+ "execution-failed",
393
+ `codex probe was terminated: ${commandDescription(command)}`,
394
+ ),
395
+ });
396
+ } else if (code === 0) {
397
+ finish({ raw: Buffer.concat(chunks).toString("utf8"), failure: null });
398
+ } else {
399
+ finish({
400
+ raw: null,
401
+ failure: classifyProcessFailure(command, code, Buffer.concat(errorChunks).toString("utf8")),
402
+ });
403
+ }
248
404
  };
249
405
  const barrier = probeCloseBarrierForTests;
250
406
  if (barrier) void barrier.then(recordClose, recordClose);
@@ -253,7 +409,14 @@ function runProbe(
253
409
  // Close the race between the pre-spawn check and listener registration.
254
410
  if (signal.aborted) terminate();
255
411
  } catch {
256
- finishResult(null);
412
+ finishResult({
413
+ raw: null,
414
+ failure: probeFailure(
415
+ command,
416
+ "execution-failed",
417
+ `codex probe failed: ${commandDescription(command)}`,
418
+ ),
419
+ });
257
420
  finishClosed();
258
421
  }
259
422
  return { result, closed };
@@ -296,19 +459,26 @@ async function runSharedPromptProbe(
296
459
  if (signal?.aborted) return { kind: "failed" };
297
460
  const active = activePromptProbe;
298
461
  if (!active) {
299
- const raw = await waitForPromptProbeFlight(startPromptProbeFlight(command), signal);
300
- return raw === null ? { kind: "failed" } : { kind: "output", raw };
462
+ const result = await waitForPromptProbeFlight(startPromptProbeFlight(command), signal);
463
+ if (!result) return { kind: "failed" };
464
+ if (result.failure) return { kind: "failed", failure: result.failure };
465
+ return result.raw === null ? { kind: "failed" } : { kind: "output", raw: result.raw };
301
466
  }
302
467
  if (active.key === key && active.joinable && !active.controller.signal.aborted) {
303
- const raw = await waitForPromptProbeFlight(active, signal);
304
- return raw === null ? { kind: "failed" } : { kind: "output", raw };
468
+ const result = await waitForPromptProbeFlight(active, signal);
469
+ if (!result) return { kind: "failed" };
470
+ if (result.failure) return { kind: "failed", failure: result.failure };
471
+ return result.raw === null ? { kind: "failed" } : { kind: "output", raw: result.raw };
305
472
  }
306
473
  // A different or terminating flight still owns the sole process slot. Never
307
474
  // wait unboundedly for an unproven close and never launch beside it.
308
475
  return { kind: "busy" };
309
476
  }
310
477
 
311
- async function waitForPromptProbeFlight(flight: PromptProbeFlight, signal?: AbortSignal): Promise<string | null> {
478
+ async function waitForPromptProbeFlight(
479
+ flight: PromptProbeFlight,
480
+ signal?: AbortSignal,
481
+ ): Promise<PromptProbeExecutionResult | null> {
312
482
  if (signal?.aborted) {
313
483
  if (flight.waiters === 0 && !flight.settled) flight.controller.abort();
314
484
  return null;
@@ -317,7 +487,7 @@ async function waitForPromptProbeFlight(flight: PromptProbeFlight, signal?: Abor
317
487
  let onAbort: (() => void) | undefined;
318
488
  try {
319
489
  if (!signal) return await flight.result;
320
- const aborted = new Promise<null>(resolve => {
490
+ const aborted = new Promise<PromptProbeExecutionResult | null>(resolve => {
321
491
  onAbort = () => resolve(null);
322
492
  signal.addEventListener("abort", onAbort, { once: true });
323
493
  if (signal.aborted) onAbort();
@@ -390,9 +560,51 @@ export async function probePromptText(
390
560
  if (signal?.aborted) {
391
561
  return { ok: false, codexHome, layers: {}, detail: "prompt probe cancelled" };
392
562
  }
393
- const binary = probeCommandForTests?.binary ?? resolveCodexBinary();
563
+ // Resolve through the shared runtime resolver, not a private path list: the
564
+ // old four-path POSIX check could never match the Codex App's Windows install
565
+ // under %LOCALAPPDATA%\OpenAI\Codex\bin\<version>, so the probe reported
566
+ // "not found" on machines where Codex was plainly installed (issue 4458).
567
+ //
568
+ // Both flags are deliberate. This runs on a request path: discoverAlternatives
569
+ // would walk the whole PATH just to fill a newerAvailable diagnostic the probe
570
+ // never shows, and probeVersion would pay a blocking `--version` exec per
571
+ // candidate. The probe needs a command it can spawn; the version is irrelevant.
572
+ const resolved = probeCommandForTests || probeRuntimeForTests !== undefined
573
+ ? null
574
+ : resolveCodexRuntime({ discoverAlternatives: false, probeVersion: false });
575
+ const runtime: { command: string; source: CodexRuntimeSource } | undefined =
576
+ probeRuntimeForTests !== undefined
577
+ ? probeRuntimeForTests ?? undefined
578
+ : resolved && resolved.runtime.source !== "fallback"
579
+ ? { command: resolved.runtime.command, source: resolved.runtime.source }
580
+ : undefined;
581
+ // A `fallback` result is the resolver saying "nothing concrete was found" -
582
+ // its command is the bare word "codex", not a located binary. Reporting it as
583
+ // resolved would just relabel the same not-found as a spawn failure.
584
+ const binary = probeCommandForTests?.binary ?? runtime?.command ?? null;
585
+ // The response travels over the management API, so the reported runtime is
586
+ // redacted while `binary` keeps the real path the spawn needs. On Windows the
587
+ // Codex App install sits under the user's profile directory, so the raw
588
+ // command carries the account name.
589
+ const reportedRuntime = runtime
590
+ ? { command: displayCodexRuntimePath(runtime.command), source: runtime.source }
591
+ : undefined;
394
592
  if (!binary) {
395
- return { ok: false, codexHome, layers: {}, detail: "codex binary not found" };
593
+ const failure = resolved
594
+ ? classifyRuntimeFailure(resolved)
595
+ : probeFailure(
596
+ { binary: "codex", args: ["debug", "prompt-input"], cwd: codexHome, timeoutMs, promptStateFingerprint },
597
+ "program-not-found",
598
+ "codex program not found: codex debug prompt-input",
599
+ );
600
+ return {
601
+ ok: false,
602
+ codexHome,
603
+ layers: {},
604
+ ...(reportedRuntime ? { runtime: reportedRuntime } : {}),
605
+ failure,
606
+ detail: "codex binary not found",
607
+ };
396
608
  }
397
609
  const command: ProbeCommand = {
398
610
  binary,
@@ -407,6 +619,8 @@ export async function probePromptText(
407
619
  ok: false,
408
620
  codexHome,
409
621
  layers: {},
622
+ ...(reportedRuntime ? { runtime: reportedRuntime } : {}),
623
+ ...(outcome.kind === "failed" && outcome.failure ? { failure: outcome.failure } : {}),
410
624
  detail: signal?.aborted
411
625
  ? "prompt probe cancelled"
412
626
  : outcome.kind === "busy"
@@ -419,7 +633,18 @@ export async function probePromptText(
419
633
  if (sections.size === 0) {
420
634
  // Zero sections from a zero-exit probe means the output did not parse, which
421
635
  // is a failed read - not fifteen layers that each chose to send nothing.
422
- return { ok: false, codexHome, layers: {}, detail: "prompt output could not be parsed" };
636
+ return {
637
+ ok: false,
638
+ codexHome,
639
+ layers: {},
640
+ ...(reportedRuntime ? { runtime: reportedRuntime } : {}),
641
+ failure: probeFailure(
642
+ command,
643
+ "output-invalid",
644
+ `codex prompt output could not be parsed: ${commandDescription(command)}`,
645
+ ),
646
+ detail: "prompt output could not be parsed",
647
+ };
423
648
  }
424
649
  const layers: Record<string, LayerText> = {};
425
650
  for (const [layerId, tag] of Object.entries(LAYER_SECTION_TAGS)) {
@@ -453,7 +678,7 @@ export async function probePromptText(
453
678
  for (const id of UNMAPPED_LAYER_IDS) {
454
679
  layers[id] ??= { text: null, reason: "not-exposed", bytes: 0 };
455
680
  }
456
- return { ok: true, codexHome, layers };
681
+ return { ok: true, codexHome, layers, ...(reportedRuntime ? { runtime: reportedRuntime } : {}) };
457
682
  }
458
683
 
459
684
  /** Test-only command seam; production always resolves the installed Codex binary. */
@@ -461,6 +686,13 @@ export function setPromptTextProbeCommandForTests(command: { binary: string; arg
461
686
  probeCommandForTests = command ? { binary: command.binary, args: [...command.args] } : null;
462
687
  }
463
688
 
689
+ /** Test-only runtime seam: stands in for the shared resolver's answer. */
690
+ export function setPromptTextProbeRuntimeForTests(
691
+ runtime: { command: string; source: CodexRuntimeSource } | null | undefined,
692
+ ): void {
693
+ probeRuntimeForTests = runtime;
694
+ }
695
+
464
696
  /** Test-only process-start counter for proving admission without timing guesses. */
465
697
  export function promptTextProbeSpawnAttemptsForTests(): number {
466
698
  return probeSpawnAttemptsForTests;
@@ -484,6 +716,7 @@ export async function resetPromptTextProbeForTests(): Promise<void> {
484
716
  }
485
717
  if (activePromptProbe === active) activePromptProbe = null;
486
718
  probeCommandForTests = null;
719
+ probeRuntimeForTests = undefined;
487
720
  probeSpawnAttemptsForTests = 0;
488
721
  probeCloseBarrierForTests = null;
489
722
  }