@bitkyc08/opencodex 2.7.36 → 2.7.37

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 (66) hide show
  1. package/README.ja.md +8 -1
  2. package/README.ko.md +7 -1
  3. package/README.md +7 -1
  4. package/README.ru.md +7 -1
  5. package/README.zh-CN.md +7 -1
  6. package/gui/dist/assets/index-BhUTxmCy.js +52 -0
  7. package/gui/dist/assets/index-oOZcqVmj.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +22 -2
  11. package/src/adapters/cursor/live-transport.ts +7 -0
  12. package/src/adapters/cursor/message-mapper.ts +3 -0
  13. package/src/adapters/cursor/protobuf-request.ts +223 -27
  14. package/src/adapters/cursor/request-builder.ts +41 -15
  15. package/src/adapters/cursor/thread-continuity.ts +67 -0
  16. package/src/adapters/cursor/types.ts +3 -1
  17. package/src/adapters/cursor.ts +44 -9
  18. package/src/adapters/google.ts +115 -62
  19. package/src/adapters/kiro.ts +3 -17
  20. package/src/adapters/openai-chat.ts +16 -5
  21. package/src/adapters/openai-responses.ts +56 -1
  22. package/src/adapters/run-turn-queue.ts +11 -1
  23. package/src/bridge.ts +139 -69
  24. package/src/chat/outbound.ts +135 -73
  25. package/src/cli/codex-shim-autorestore.ts +45 -0
  26. package/src/cli/doctor.ts +197 -2
  27. package/src/cli/index.ts +17 -3
  28. package/src/cli/status.ts +80 -0
  29. package/src/cli/v2.ts +14 -2
  30. package/src/codex/auth-context.ts +18 -2
  31. package/src/codex/catalog/bundled.ts +83 -27
  32. package/src/codex/catalog/effort.ts +95 -3
  33. package/src/codex/catalog/parsing.ts +17 -0
  34. package/src/codex/catalog/provider-fetch.ts +31 -8
  35. package/src/codex/exec-invocation.ts +22 -0
  36. package/src/codex/model-cache.ts +44 -0
  37. package/src/codex/runtime.ts +529 -0
  38. package/src/codex/shim.ts +608 -10
  39. package/src/combos/resolve.ts +7 -2
  40. package/src/config.ts +32 -1
  41. package/src/lib/bun-stream-caps.ts +88 -0
  42. package/src/lib/crash-guard.ts +3 -1
  43. package/src/lib/sse-decoder.ts +25 -6
  44. package/src/responses/parser.ts +2 -1
  45. package/src/responses/state.ts +10 -2
  46. package/src/server/auth-cors.ts +4 -1
  47. package/src/server/index.ts +191 -1
  48. package/src/server/live.ts +491 -0
  49. package/src/server/management/config-routes.ts +79 -3
  50. package/src/server/management/provider-routes.ts +2 -0
  51. package/src/server/management/shared.ts +6 -6
  52. package/src/server/management/system-routes.ts +65 -0
  53. package/src/server/management-api.ts +3 -1
  54. package/src/server/memory-watchdog.ts +112 -0
  55. package/src/server/relay-eager.ts +199 -0
  56. package/src/server/relay.ts +131 -81
  57. package/src/server/responses/collaboration.ts +20 -3
  58. package/src/server/responses/core.ts +236 -21
  59. package/src/server/responses/encrypted-payload.ts +118 -41
  60. package/src/server/ws-bridge.ts +7 -0
  61. package/src/types.ts +25 -0
  62. package/src/usage/cost.ts +0 -0
  63. package/src/usage/expected-prices.ts +19 -0
  64. package/src/usage/summary.ts +11 -8
  65. package/gui/dist/assets/index-BpX-hoSd.css +0 -1
  66. package/gui/dist/assets/index-ZmFopEYw.js +0 -52
package/src/cli/doctor.ts CHANGED
@@ -10,13 +10,22 @@
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
- import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, resolveEnvValue } from "../config";
13
+ import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config";
14
+ import { gracefulStopHost } from "../lib/process-control";
15
+ import { loadServiceTokenFromFile } from "../lib/service-secrets";
14
16
  import { readCodexTokens } from "../codex/auth-collision";
15
17
  import { resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
16
18
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
17
19
  import { countPendingOpencodexHistory } from "../codex/history-provider";
18
20
  import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings";
19
21
  import { collectStartupHealth, startupHealthSummary } from "../codex/autostart-health";
22
+ import {
23
+ displayCodexRuntimePath,
24
+ loadLastEffortClamp,
25
+ persistCodexRuntime,
26
+ resolveAndPersistCodexRuntime,
27
+ resolveCodexRuntime,
28
+ } from "../codex/runtime";
20
29
  export { resolveCodexHomeDir } from "../codex/home";
21
30
 
22
31
  const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
@@ -319,9 +328,149 @@ export async function probeWham(fetchImpl: typeof fetch = fetch): Promise<WhamPr
319
328
  }
320
329
  }
321
330
 
322
- export async function runDoctor(): Promise<void> {
331
+ /**
332
+ * Service-process memory/runtime introspection (#314 WP4).
333
+ *
334
+ * Doctor runs in its OWN Bun process; the only honest source for the SERVICE
335
+ * process identity (Bun version, RSS, stream-mode gate decision) is the
336
+ * authed management endpoint added in WP3. Observe-only: failures render as
337
+ * honest status lines, never as fake data, and never fail the command.
338
+ */
339
+ export type ServiceMemoryData = {
340
+ pid: number;
341
+ bunVersion: string;
342
+ platform: string;
343
+ rss: number;
344
+ heapUsed: number;
345
+ jscHeap: { heapSize: number } | null;
346
+ streamMode: string;
347
+ eagerRelay: { useEagerRelay: boolean; reason: string } | null;
348
+ watchdog: { warnThresholdBytes: number; lastWarnAt: number | null } | null;
349
+ };
350
+
351
+ export type ServiceMemoryReport =
352
+ | { status: "ok"; data: ServiceMemoryData }
353
+ | { status: "unauthorized" }
354
+ | { status: "unreachable"; error: string };
355
+
356
+ const SERVICE_MEMORY_TIMEOUT_MS = 2000;
357
+ const DEFAULT_MEMORY_THRESHOLD_BYTES = 4 * 1024 ** 3;
358
+
359
+ export async function fetchServiceMemory(
360
+ host: string,
361
+ port: number,
362
+ token: string | null,
363
+ fetchImpl: typeof fetch = fetch,
364
+ ): Promise<ServiceMemoryReport> {
365
+ try {
366
+ const res = await fetchImpl(`http://${host}:${port}/api/system/memory`, {
367
+ headers: token ? { "x-opencodex-api-key": token } : {},
368
+ signal: AbortSignal.timeout(SERVICE_MEMORY_TIMEOUT_MS),
369
+ });
370
+ if (res.status === 401 || res.status === 403) return { status: "unauthorized" };
371
+ if (!res.ok) return { status: "unreachable", error: `http ${res.status}` };
372
+ const body = await res.json() as Partial<ServiceMemoryData>;
373
+ if (typeof body.pid !== "number" || typeof body.bunVersion !== "string" || typeof body.rss !== "number") {
374
+ return { status: "unreachable", error: "malformed response" };
375
+ }
376
+ return {
377
+ status: "ok",
378
+ data: {
379
+ pid: body.pid,
380
+ bunVersion: body.bunVersion,
381
+ platform: typeof body.platform === "string" ? body.platform : "unknown",
382
+ rss: body.rss,
383
+ heapUsed: typeof body.heapUsed === "number" ? body.heapUsed : 0,
384
+ jscHeap: body.jscHeap && typeof body.jscHeap.heapSize === "number" ? { heapSize: body.jscHeap.heapSize } : null,
385
+ streamMode: typeof body.streamMode === "string" ? body.streamMode : "auto",
386
+ eagerRelay: body.eagerRelay && typeof body.eagerRelay.reason === "string"
387
+ ? { useEagerRelay: body.eagerRelay.useEagerRelay === true, reason: body.eagerRelay.reason }
388
+ : null,
389
+ watchdog: body.watchdog && typeof body.watchdog.warnThresholdBytes === "number"
390
+ ? { warnThresholdBytes: body.watchdog.warnThresholdBytes, lastWarnAt: body.watchdog.lastWarnAt ?? null }
391
+ : null,
392
+ },
393
+ };
394
+ } catch (err) {
395
+ return { status: "unreachable", error: err instanceof Error ? err.name : "fetch failed" };
396
+ }
397
+ }
398
+
399
+ const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`;
400
+
401
+ /** Render the doctor "Memory / runtime" section lines (testable without console capture). */
402
+ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] {
403
+ const lines: string[] = [];
404
+ lines.push(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
405
+ if (report.status === "unauthorized") {
406
+ lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_API_AUTH_TOKEN to match the service");
407
+ return lines;
408
+ }
409
+ if (report.status === "unreachable") {
410
+ lines.push(` -- proxy not reachable (not running?) [${report.error}]`);
411
+ return lines;
412
+ }
413
+ const d = report.data;
414
+ lines.push(` ok service pid ${d.pid}: Bun ${d.bunVersion} on ${d.platform}`);
415
+ lines.push(` rss=${mb(d.rss)}, heapUsed=${mb(d.heapUsed)}${d.jscHeap ? `, jscHeap=${mb(d.jscHeap.heapSize)}` : ""}`);
416
+ lines.push(` streamMode=${d.streamMode}${d.eagerRelay ? ` (eager relay: ${d.eagerRelay.useEagerRelay ? "on" : "off"}, ${d.eagerRelay.reason})` : ""}`);
417
+ if (d.watchdog) {
418
+ lines.push(` watchdog threshold=${mb(d.watchdog.warnThresholdBytes)}${d.watchdog.lastWarnAt ? `, last warn ${new Date(d.watchdog.lastWarnAt).toISOString()}` : ", no warnings"}`);
419
+ }
420
+ // Interpretation rule (devlog 040): reuse the watchdog's own threshold so
421
+ // doctor and watchdog never disagree about "high"; jsShare discriminates
422
+ // JS-heap growth from native runtime growth (the #314 shape).
423
+ const threshold = d.watchdog?.warnThresholdBytes ?? DEFAULT_MEMORY_THRESHOLD_BYTES;
424
+ const jsShare = d.rss > 0 ? Math.max(d.heapUsed, d.jscHeap?.heapSize ?? 0) / d.rss : 0;
425
+ if (d.rss < threshold) {
426
+ lines.push(" memory usage looks normal");
427
+ } else if (jsShare < 0.25) {
428
+ lines.push(" !! high RSS with a small JS heap — native-side growth (Bun runtime buffers/handles). See docs: troubleshooting/windows-memory");
429
+ } else if (jsShare >= 0.5) {
430
+ lines.push(" !! high RSS dominated by the JS heap — likely an opencodex bug; please report it");
431
+ } else {
432
+ lines.push(" !! high RSS, indeterminate split — capture two doctor runs over time to see the trend");
433
+ }
434
+ // Version-claiming (never binary-claiming): the endpoint cannot distinguish
435
+ // the bundled binary from an OPENCODEX_BUN_PATH override of the same version.
436
+ if (d.platform === "win32" && d.eagerRelay?.reason === "auto-known-bad") {
437
+ lines.push(` service is running Bun ${d.bunVersion} on Windows — a version affected by the upstream Bun memory issue.`);
438
+ lines.push(" Options: wait for a bundled runtime update, or set OPENCODEX_BUN_PATH to a runtime you trust (unvalidated — own risk),");
439
+ lines.push(" or opt into streamMode \"eager-relay\" via PUT /api/settings (crash risk on this runtime; see docs).");
440
+ }
441
+ return lines;
442
+ }
443
+
444
+ export async function runDoctor(args: string[] = []): Promise<void> {
445
+ if (args.includes("--fix-codex-runtime")) {
446
+ const resolved = resolveCodexRuntime();
447
+ if (!resolved.newerAvailable) {
448
+ console.log("No newer Codex runtime found; keeping current selection.");
449
+ const current = resolveAndPersistCodexRuntime();
450
+ console.log(`Selected: ${displayCodexRuntimePath(current.runtime.command)} (${current.runtime.version ?? "unknown"})`);
451
+ return;
452
+ }
453
+ if (resolved.runtime.source === "environment") {
454
+ console.log("CODEX_CLI_PATH currently overrides configured runtimes.");
455
+ console.log(`Unset or update CODEX_CLI_PATH to use ${displayCodexRuntimePath(resolved.newerAvailable.command)} (${resolved.newerAvailable.version ?? "unknown"}).`);
456
+ console.log("Then run ocx sync.");
457
+ return;
458
+ }
459
+ persistCodexRuntime({
460
+ command: resolved.newerAvailable.command,
461
+ version: resolved.newerAvailable.version,
462
+ source: "configured",
463
+ });
464
+ console.log(`Updated Codex runtime to ${displayCodexRuntimePath(resolved.newerAvailable.command)} (${resolved.newerAvailable.version ?? "unknown"}).`);
465
+ console.log("Run ocx sync to refresh the catalog against this runtime.");
466
+ return;
467
+ }
468
+
323
469
  console.log("opencodex doctor\n");
324
470
 
471
+ // Ordering note: the memory/runtime section renders after "Running proxy
472
+ // process proxy env" below; helpers live above runDoctor for testability.
473
+
325
474
  const paths = collectPaths();
326
475
  const mounts = readMounts();
327
476
  console.log("Paths");
@@ -337,6 +486,35 @@ export async function runDoctor(): Promise<void> {
337
486
  console.log(` ${startup.rebootSafe ? "ok " : "!! "} ${startupHealthSummary(startup)}`);
338
487
  console.log(` routing=${startup.routingKind}, service=${startup.serviceViable ? "viable" : startup.serviceInstalled ? "installed-but-unhealthy" : "absent"}, shim=${startup.shimHealthy ? "healthy" : startup.shimInstalled ? "stale" : "absent"}`);
339
488
 
489
+ console.log("\nCodex runtime selection");
490
+ {
491
+ const resolved = resolveCodexRuntime();
492
+ const selected = resolved.runtime;
493
+ console.log(` ok Selected runtime: ${displayCodexRuntimePath(selected.command)} (${selected.version ?? "unknown"}, source=${selected.source})`);
494
+ const envFailures = resolved.failures.filter(item => item.source === "environment");
495
+ for (const failure of envFailures) {
496
+ console.log(` !! Invalid CODEX_CLI_PATH: ${failure.reason}`);
497
+ }
498
+ const shimFailures = resolved.failures.filter(item => item.source === "shim");
499
+ if (shimFailures.length > 0) {
500
+ console.log(` !! Stale shim target rejected (${shimFailures.length})`);
501
+ }
502
+ if (resolved.replacedConfigured) {
503
+ console.log(` !! Preferred runtime unavailable; fell back to ${displayCodexRuntimePath(selected.command)}`);
504
+ }
505
+ if (resolved.newerAvailable) {
506
+ console.log(` !! Multiple Codex installations found.`);
507
+ console.log(` ok Newer usable runtime found: ${displayCodexRuntimePath(resolved.newerAvailable.command)} (${resolved.newerAvailable.version ?? "unknown"})`);
508
+ console.log(" Suggested: set CODEX_CLI_PATH to the desired binary and run ocx sync.");
509
+ console.log(" Optional: ocx doctor --fix-codex-runtime");
510
+ }
511
+ const lastClamp = loadLastEffortClamp();
512
+ if (lastClamp && lastClamp.removedEfforts.length > 0) {
513
+ console.log(` !! ${lastClamp.removedEfforts.join(" and ")} were removed during catalog sync.`);
514
+ console.log(" Suggested: set CODEX_CLI_PATH to a newer Codex binary and run ocx sync.");
515
+ }
516
+ }
517
+
340
518
  const currentProxyEnv = collectProxyEnv();
341
519
  const configuredProxy = collectConfiguredProxy();
342
520
  const runningProxyEnv = collectRunningProxyEnv();
@@ -361,6 +539,23 @@ export async function runDoctor(): Promise<void> {
361
539
  }
362
540
  }
363
541
 
542
+ // #314: service-process memory/runtime identity via the authed management
543
+ // endpoint. readPid() FIRST (liveness), then the pid-scoped runtime record —
544
+ // readRuntimePort alone can serve a stale file pointing at a foreign port.
545
+ console.log("\nMemory / runtime");
546
+ {
547
+ const livePid = readPid();
548
+ const runtime = livePid ? readRuntimePort(livePid) : null;
549
+ if (!runtime) {
550
+ console.log(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
551
+ console.log(" -- no running ocx proxy found (no live pid/runtime record)");
552
+ } else {
553
+ const token = process.env.OPENCODEX_API_AUTH_TOKEN ?? loadServiceTokenFromFile(process.env);
554
+ const report = await fetchServiceMemory(gracefulStopHost(runtime.hostname), runtime.port, token);
555
+ for (const line of formatServiceMemoryLines(report)) console.log(line);
556
+ }
557
+ }
558
+
364
559
  console.log("\nWHAM reachability");
365
560
  const probe = await probeWham();
366
561
  const detail = probe.status !== null ? `status=${probe.status}` : `error=${probe.classification}`;
package/src/cli/index.ts CHANGED
@@ -36,6 +36,7 @@ import { buildDesktop3pRegistry } from "../claude/desktop-3p";
36
36
  import { installShellHook, uninstallShellHook } from "../server/system-env";
37
37
  import { startTokenGuardian } from "../oauth/token-guardian";
38
38
  import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
39
+ import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
39
40
  import { maybeShowStarPrompt } from "./star-prompt";
40
41
  import { maybeShowUpdatePrompt } from "../update/notify";
41
42
  import { syncModelsToCodex } from "../codex/sync";
@@ -49,8 +50,9 @@ if (command === "--version" || command === "-v" || command === "version") {
49
50
  process.exit(0);
50
51
  }
51
52
 
52
- if (command === "help" && args[1]) {
53
- printSubcommandUsage(args[1]);
53
+ if (command === undefined || command === "help" || command === "--help" || command === "-h") {
54
+ if (command === "help" && args[1]) printSubcommandUsage(args[1]);
55
+ else printUsage();
54
56
  process.exit(0);
55
57
  }
56
58
 
@@ -59,6 +61,8 @@ if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) {
59
61
  process.exit(0);
60
62
  }
61
63
 
64
+ maybeAutoRestoreCodexShim(command, args);
65
+
62
66
  function parsePortOption(): number | undefined {
63
67
  if (args.length === 1) return undefined;
64
68
  if (args.length !== 3 || args[1] !== "--port") {
@@ -507,6 +511,16 @@ async function handleStatus() {
507
511
  console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`);
508
512
  console.log(` Service: ${status.json.service.summary}`);
509
513
  console.log(` ${status.json.codexShim.summary}`);
514
+ console.log(` Codex runtime: ${status.json.codexRuntime.path}`);
515
+ console.log(` Codex version: ${status.json.codexRuntime.version ?? "unknown"}`);
516
+ console.log(` Codex source: ${status.json.codexRuntime.source}`);
517
+ console.log(` Catalog clamp: ${status.json.codexRuntime.catalogClamp.active ? "active" : "inactive"}`);
518
+ if (status.json.codexRuntime.catalogClamp.removedEfforts.length > 0) {
519
+ console.log(` Removed efforts: ${status.json.codexRuntime.catalogClamp.removedEfforts.join(", ")}`);
520
+ }
521
+ if (status.json.codexRuntime.warning) {
522
+ console.log(` ⚠️ ${status.json.codexRuntime.warning}`);
523
+ }
510
524
  if (status.json.codexPlugins.applicable) {
511
525
  const icon = status.json.codexPlugins.stale ? "⚠️ " : "✅";
512
526
  console.log(` ${icon} Codex bundled plugins: ${status.json.codexPlugins.summary}`);
@@ -581,7 +595,7 @@ switch (command) {
581
595
  break;
582
596
  case "doctor": {
583
597
  const { runDoctor } = await import("./doctor");
584
- await runDoctor();
598
+ await runDoctor(args.slice(1));
585
599
  break;
586
600
  }
587
601
  case "debug": {
package/src/cli/status.ts CHANGED
@@ -7,6 +7,8 @@ import { diagnoseService } from "../service";
7
7
  import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
8
8
  import { getCodexRoutingKind } from "../codex/inject";
9
9
  import { diagnoseCodexShim } from "../codex/shim";
10
+ import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";
11
+ import { redactSecretString, redactUserPath } from "../lib/redact";
10
12
 
11
13
  type HealthCheck = {
12
14
  ok: boolean;
@@ -51,6 +53,18 @@ export type CliStatusJson = {
51
53
  service: { summary: string };
52
54
  codexShim: { summary: string };
53
55
  codexPlugins: CodexPluginsDiagnostic;
56
+ codexRuntime: {
57
+ path: string;
58
+ version: string | null;
59
+ source: string;
60
+ newerAvailable: { path: string; version: string | null } | null;
61
+ warning: string | null;
62
+ catalogClamp: {
63
+ active: boolean;
64
+ removedEfforts: string[];
65
+ runtimeVersion: string | null;
66
+ };
67
+ };
54
68
  };
55
69
 
56
70
  export type CliStatusView = {
@@ -129,6 +143,71 @@ export async function collectStatus(): Promise<CliStatusView> {
129
143
  routingKind: getCodexRoutingKind(),
130
144
  });
131
145
  const codexPlugins = diagnoseCodexBundledPlugins();
146
+ const resolvedRuntime = (() => {
147
+ try {
148
+ return resolveCodexRuntime();
149
+ } catch (error) {
150
+ const message = error instanceof Error ? error.message : String(error);
151
+ const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
152
+ return {
153
+ runtime: { command: "codex", version: null, source: "fallback" as const },
154
+ failures: [{
155
+ command: "codex",
156
+ source: "fallback" as const,
157
+ reason: `resolve threw: ${redacted}`,
158
+ }],
159
+ replacedConfigured: undefined,
160
+ newerAvailable: undefined,
161
+ };
162
+ }
163
+ })();
164
+ const lastClamp = loadLastEffortClamp();
165
+ const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime);
166
+ const warningParts: string[] = [];
167
+ if (
168
+ resolvedRuntime.replacedConfigured
169
+ && resolvedRuntime.replacedConfigured.from.command !== resolvedRuntime.runtime.command
170
+ ) {
171
+ warningParts.push(
172
+ `Preferred Codex runtime is unavailable; using ${displayCodexRuntimePath(resolvedRuntime.runtime.command)} instead. Run ocx doctor for diagnosis and recovery.`,
173
+ );
174
+ } else if (
175
+ resolvedRuntime.runtime.source === "fallback"
176
+ && resolvedRuntime.failures.length > 0
177
+ && !resolvedRuntime.runtime.version
178
+ ) {
179
+ const detail = resolvedRuntime.failures[0]?.reason;
180
+ warningParts.push(
181
+ detail
182
+ ? `No validated Codex runtime found (${detail}); falling back to \`codex\`. Run ocx doctor for diagnosis and recovery.`
183
+ : "No validated Codex runtime found; falling back to `codex`. Run ocx doctor for diagnosis and recovery.",
184
+ );
185
+ }
186
+ if (resolvedRuntime.newerAvailable) {
187
+ warningParts.push("OpenCodex is using an older Codex binary. Run ocx doctor for diagnosis and recovery.");
188
+ }
189
+ if (clampActive) {
190
+ warningParts.push(
191
+ `Catalog clamp removed: ${lastClamp!.removedEfforts.join(", ")}. Run ocx doctor for diagnosis and recovery.`,
192
+ );
193
+ }
194
+ const codexRuntime = {
195
+ path: displayCodexRuntimePath(resolvedRuntime.runtime.command),
196
+ version: resolvedRuntime.runtime.version,
197
+ source: resolvedRuntime.runtime.source,
198
+ newerAvailable: resolvedRuntime.newerAvailable
199
+ ? {
200
+ path: displayCodexRuntimePath(resolvedRuntime.newerAvailable.command),
201
+ version: resolvedRuntime.newerAvailable.version,
202
+ }
203
+ : null,
204
+ warning: warningParts.length > 0 ? warningParts.join(" ") : null,
205
+ catalogClamp: {
206
+ active: clampActive,
207
+ removedEfforts: clampActive ? (lastClamp?.removedEfforts ?? []) : [],
208
+ runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null,
209
+ },
210
+ };
132
211
  const proxyLabel = pid && health.ok
133
212
  ? `running (PID ${pid})`
134
213
  : pid
@@ -176,6 +255,7 @@ export async function collectStatus(): Promise<CliStatusView> {
176
255
  service: { summary: serviceSummary },
177
256
  codexShim: { summary: codexShimSummary },
178
257
  codexPlugins,
258
+ codexRuntime,
179
259
  },
180
260
  };
181
261
  }
package/src/cli/v2.ts CHANGED
@@ -15,6 +15,7 @@ import { getLogicalMaxThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, trans
15
15
 
16
16
  import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
17
17
  import { loadConfig, saveConfig } from "../config";
18
+ import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime";
18
19
 
19
20
  export interface V2CliDeps {
20
21
  execFile?: (file: string, args: string[], options?: SpawnInvocation["options"]) => void;
@@ -24,6 +25,10 @@ export interface V2CliDeps {
24
25
  log?: Pick<Console, "log" | "error">;
25
26
  }
26
27
 
28
+ export type CodexFeaturesInvocationDeps =
29
+ & Parameters<typeof commandInvocation>[3]
30
+ & Pick<ResolveCodexRuntimeDeps, "existsSync" | "execFileSync" | "configDir" | "readFileSync">;
31
+
27
32
  /**
28
33
  * Shared invocation for `codex features enable|disable multi_agent_v2` — the single
29
34
  * source of truth for the CLI and the management API fallback. Windows npm installs
@@ -33,9 +38,16 @@ export interface V2CliDeps {
33
38
  export function codexFeaturesInvocation(
34
39
  action: "enable" | "disable",
35
40
  platform: NodeJS.Platform = process.platform,
36
- deps: Parameters<typeof commandInvocation>[3] = {},
41
+ deps: CodexFeaturesInvocationDeps = {},
37
42
  ): SpawnInvocation {
38
- const command = (deps.env ?? process.env).CODEX_CLI_PATH?.trim() || "codex";
43
+ const command = resolveAndPersistCodexRuntime({
44
+ env: deps.env ?? process.env,
45
+ platform,
46
+ existsSync: deps.existsSync,
47
+ execFileSync: deps.execFileSync,
48
+ configDir: deps.configDir,
49
+ readFileSync: deps.readFileSync,
50
+ }).runtime.command || "codex";
39
51
  return commandInvocation(command, ["features", action, "multi_agent_v2"], platform, deps);
40
52
  }
41
53
 
@@ -7,7 +7,11 @@ import {
7
7
  import { markAccountNeedsReauth } from "./account-runtime-state";
8
8
  import { isCodexAccountUsable } from "./account-usability";
9
9
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
10
- import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./routing";
10
+ import {
11
+ getCodexAccountCooldownUntil,
12
+ pickLowestUsageCodexAccount,
13
+ resolveCodexAccountForThreadDetailed,
14
+ } from "./routing";
11
15
  import { getAccountQuota } from "./quota";
12
16
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
13
17
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
@@ -89,17 +93,29 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
89
93
  return !(cause instanceof CodexCredentialGenerationConflictError) && !(cause instanceof CodexCredentialRefreshLockTimeoutError);
90
94
  }
91
95
 
96
+ export interface ResolveCodexAuthContextOptions {
97
+ excludeAccountId?: string;
98
+ }
99
+
92
100
  export async function resolveCodexAuthContext(
93
101
  headers: Headers,
94
102
  config: OcxConfig,
95
103
  mode: CodexAccountMode,
104
+ options: ResolveCodexAuthContextOptions = {},
96
105
  ): Promise<CodexAuthContext> {
97
106
  if (mode === "direct") {
98
107
  if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
99
108
  return { kind: "main", accountId: null };
100
109
  }
101
110
  const threadId = headers.get("x-codex-parent-thread-id");
102
- const resolution = resolveCodexAccountForThreadDetailed(threadId, config);
111
+ const resolution = options.excludeAccountId
112
+ ? (() => {
113
+ const accountId = pickLowestUsageCodexAccount(config, options.excludeAccountId);
114
+ return accountId
115
+ ? { status: "selected" as const, accountId }
116
+ : { status: "none" as const };
117
+ })()
118
+ : resolveCodexAccountForThreadDetailed(threadId, config);
103
119
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
104
120
  const accountId = resolution.status === "selected" ? resolution.accountId : null;
105
121
  if (!accountId) throw new CodexPoolAuthenticationError();
@@ -33,16 +33,31 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
33
33
 
34
34
  import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing";
35
35
  import type { RawCatalog, RawEntry } from "./parsing";
36
+ import { codexExecInvocation, isSpawnableCodexCandidate } from "../exec-invocation";
37
+ import { resolveAndPersistCodexRuntime } from "../runtime";
38
+ import type { EffortClampDiagnostic } from "../runtime";
39
+
40
+ export { isSpawnableCodexCandidate, codexExecInvocation } from "../exec-invocation";
36
41
 
37
42
  export const BUNDLED_CATALOG_CACHE_MS = 60_000;
38
43
 
39
- export let bundledCatalogCache: { expiresAt: number; value: RawCatalog | null } | null = null;
44
+ export let bundledCatalogCache: {
45
+ /** Selected runtime identity; must change when doctor/sync picks a different binary. */
46
+ key: string;
47
+ expiresAt: number;
48
+ value: RawCatalog | null;
49
+ } | null = null;
40
50
 
41
51
  /** Test-only: clear the bundled-catalog cache (owned here; sync.ts calls this instead of assigning the import). */
42
52
  export function resetBundledCatalogCacheForTests(): void {
43
53
  bundledCatalogCache = null;
44
54
  }
45
55
 
56
+ /** Drop the process-local bundled catalog memo (e.g. after runtime selection changes). */
57
+ export function invalidateBundledCatalogCache(): void {
58
+ bundledCatalogCache = null;
59
+ }
60
+
46
61
  export type ExecFile = (
47
62
  file: string,
48
63
  args: string[],
@@ -52,12 +67,21 @@ export type ExecFile = (
52
67
  timeout: number;
53
68
  windowsHide: boolean;
54
69
  shell?: boolean;
70
+ windowsVerbatimArguments?: boolean;
55
71
  },
56
72
  ) => string;
57
73
 
58
74
  export interface BundledCatalogDeps {
59
75
  commandCandidates?: () => string[];
60
76
  execFileSync?: ExecFile;
77
+ onEffortClamp?: (diagnostic: EffortClampDiagnostic) => void;
78
+ configDir?: string;
79
+ env?: NodeJS.ProcessEnv;
80
+ platform?: NodeJS.Platform;
81
+ existsSync?: (path: string) => boolean;
82
+ readFileSync?: (path: string, encoding: "utf8") => string;
83
+ now?: () => number;
84
+ discoverAlternatives?: boolean;
61
85
  }
62
86
 
63
87
  export function unique(values: string[]): string[] {
@@ -77,11 +101,6 @@ export function codexCommandCandidates(): string[] {
77
101
  return unique(candidates);
78
102
  }
79
103
 
80
- export function isSpawnableCodexCandidate(path: string, platform: NodeJS.Platform = process.platform): boolean {
81
- if (platform !== "win32") return true;
82
- return /\.(cmd|bat|exe|com)$/i.test(path);
83
- }
84
-
85
104
  export function codexShimCommandCandidates(): string[] {
86
105
  try {
87
106
  const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
@@ -105,45 +124,82 @@ export function codexShimCommandCandidates(): string[] {
105
124
  }
106
125
  }
107
126
 
108
- export function codexExecInvocation(
127
+ export function runCodexDebugModels(
109
128
  command: string,
110
- platform: NodeJS.Platform = process.platform,
111
- ): { file: string; shell: boolean } {
112
- if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
113
- return { file: `"${command.replace(/"/g, "")}"`, shell: true };
114
- }
115
- return { file: command, shell: false };
116
- }
117
-
118
- export function runCodexDebugModels(command: string, execFile: ExecFile): string {
129
+ execFile: ExecFile,
130
+ deps: Pick<BundledCatalogDeps, "env" | "platform" | "existsSync"> = {},
131
+ ): string {
119
132
  const args = ["debug", "models", "--bundled"];
120
- const invocation = codexExecInvocation(command);
121
- return execFile(invocation.file, args, {
133
+ const invocation = codexExecInvocation(command, args, deps.platform ?? process.platform, {
134
+ env: deps.env,
135
+ exists: deps.existsSync,
136
+ });
137
+ return execFile(invocation.file, invocation.args, {
122
138
  encoding: "utf8" as const,
123
139
  stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
124
140
  timeout: 10_000,
125
141
  windowsHide: true,
126
- shell: invocation.shell,
142
+ ...invocation.options,
127
143
  });
128
144
  }
129
145
 
130
146
  export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatalog | null {
131
- const useCache = !deps.commandCandidates && !deps.execFileSync;
132
- if (useCache && bundledCatalogCache && bundledCatalogCache.expiresAt > Date.now()) {
147
+ const useCache = !deps.commandCandidates && !deps.execFileSync && !deps.configDir && !deps.env;
148
+ const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
149
+ // Prefer the single resolved runtime so sync/clamp never probe a different binary
150
+ // than OpenCodex will launch. Tests may inject commandCandidates to stub probing.
151
+ let cacheKey: string | null = null;
152
+ const candidates = deps.commandCandidates?.() ?? (() => {
153
+ const resolved = resolveAndPersistCodexRuntime({
154
+ execFileSync: execFile,
155
+ configDir: deps.configDir,
156
+ env: deps.env,
157
+ platform: deps.platform,
158
+ existsSync: deps.existsSync,
159
+ readFileSync: deps.readFileSync,
160
+ now: deps.now,
161
+ discoverAlternatives: deps.discoverAlternatives,
162
+ });
163
+ if (useCache) {
164
+ cacheKey = [
165
+ resolved.runtime.command,
166
+ resolved.runtime.version ?? "",
167
+ process.env.OPENCODEX_HOME ?? "",
168
+ ].join("\0");
169
+ }
170
+ return [resolved.runtime.command];
171
+ })();
172
+ if (
173
+ useCache
174
+ && cacheKey
175
+ && bundledCatalogCache
176
+ && bundledCatalogCache.key === cacheKey
177
+ && bundledCatalogCache.expiresAt > Date.now()
178
+ ) {
133
179
  return bundledCatalogCache.value;
134
180
  }
135
- const candidates = deps.commandCandidates?.() ?? codexCommandCandidates();
136
- const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
137
- for (const command of candidates) {
181
+ for (const command of unique(candidates)) {
138
182
  try {
139
- const catalog = parseCatalogJson(runCodexDebugModels(command, execFile));
183
+ const catalog = parseCatalogJson(runCodexDebugModels(command, execFile, deps));
140
184
  if (catalog && findNativeTemplate(catalog)) {
141
- if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: catalog };
185
+ if (useCache && cacheKey) {
186
+ bundledCatalogCache = {
187
+ key: cacheKey,
188
+ expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS,
189
+ value: catalog,
190
+ };
191
+ }
142
192
  return catalog;
143
193
  }
144
194
  } catch { /* try next candidate */ }
145
195
  }
146
- if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: null };
196
+ if (useCache && cacheKey) {
197
+ bundledCatalogCache = {
198
+ key: cacheKey,
199
+ expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS,
200
+ value: null,
201
+ };
202
+ }
147
203
  return null;
148
204
  }
149
205