@bitkyc08/opencodex 2.9.1 → 2.10.0

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 (38) hide show
  1. package/README.md +91 -449
  2. package/gui/dist/assets/index-OY43ubAq.css +1 -0
  3. package/gui/dist/assets/index-YwNnKZcL.js +67 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/pi.svg +21 -0
  6. package/package.json +1 -1
  7. package/src/cli/account.ts +3 -5
  8. package/src/cli/claude-desktop.ts +43 -7
  9. package/src/cli/doctor.ts +12 -0
  10. package/src/cli/help.ts +1 -1
  11. package/src/cli/provider-runtime.ts +7 -0
  12. package/src/cli/star-prompt.ts +25 -4
  13. package/src/cli/status.ts +7 -2
  14. package/src/codex/app-server-processes.ts +299 -54
  15. package/src/codex/catalog/metadata.ts +9 -11
  16. package/src/codex/catalog/provider-fetch.ts +10 -10
  17. package/src/codex/catalog/sync.ts +27 -2
  18. package/src/codex/catalog.ts +1 -1
  19. package/src/config.ts +15 -1
  20. package/src/lib/bun-stream-caps.ts +14 -0
  21. package/src/oauth/index.ts +48 -3
  22. package/src/providers/registry.ts +62 -0
  23. package/src/server/index.ts +39 -3
  24. package/src/server/management/agent-settings-routes.ts +40 -3
  25. package/src/server/management/logs-usage-routes.ts +2 -0
  26. package/src/server/management/provider-routes.ts +4 -1
  27. package/src/server/management/sidebar-routes.ts +3 -1
  28. package/src/server/relay-eager.ts +100 -2
  29. package/src/server/responses/collaboration.ts +21 -0
  30. package/src/server/responses/core.ts +20 -12
  31. package/src/service.ts +393 -14
  32. package/src/storage/cleanup.ts +76 -5
  33. package/src/storage/policy.ts +6 -1
  34. package/src/types.ts +2 -0
  35. package/src/update/index.ts +9 -2
  36. package/src/update/job.ts +87 -11
  37. package/gui/dist/assets/index-CHwf3tTD.css +0 -1
  38. package/gui/dist/assets/index-CuVjugeE.js +0 -67
@@ -8,8 +8,9 @@
8
8
  * `hermes-codex-bridge-mcp`.
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
- import { existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { isProcessAlive, waitForExit } from "../lib/process-control";
13
+ import { readCodexCatalogPath } from "./catalog/parsing";
13
14
 
14
15
  export const STALE_CODEX_APP_SERVER_HINT =
15
16
  "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex).";
@@ -72,6 +73,7 @@ export interface ProcessSnapshot {
72
73
  commandLine: string;
73
74
  uid?: number;
74
75
  owner?: string;
76
+ startedAtMs?: number;
75
77
  }
76
78
 
77
79
  export interface CodexAppServerProcessIo {
@@ -82,6 +84,8 @@ export interface CodexAppServerProcessIo {
82
84
  kill?: (pid: number, signal: NodeJS.Signals) => void;
83
85
  waitExit?: (pid: number, timeoutMs: number) => boolean;
84
86
  now?: () => number;
87
+ readStartMs?: (pid: number) => number | null;
88
+ catalogMtimeMs?: () => number | null;
85
89
  }
86
90
 
87
91
  /** Split a process command line into argv-like tokens (handles simple quotes). */
@@ -237,7 +241,9 @@ function parseUnixProcStatusUid(status: string): number | undefined {
237
241
  }
238
242
 
239
243
  function listUnixProcSnapshots(uid: number | undefined): ProcessSnapshot[] {
240
- if (!existsSync("/proc")) return [];
244
+ // procfs missing on a Linux-shaped platform is an enumeration failure, not
245
+ // "no processes" — the staleness collector must not read it as not_running.
246
+ if (!existsSync("/proc")) throw new Error("procfs_unavailable");
241
247
  const out: ProcessSnapshot[] = [];
242
248
  for (const ent of readdirSync("/proc")) {
243
249
  if (!/^\d+$/.test(ent)) continue;
@@ -262,40 +268,38 @@ function listUnixProcSnapshots(uid: number | undefined): ProcessSnapshot[] {
262
268
 
263
269
  function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] {
264
270
  const out: ProcessSnapshot[] = [];
265
- try {
266
- const output = uid !== undefined
267
- ? execFileSync("ps", ["-u", String(uid), "-o", "pid=,command="], {
268
- encoding: "utf-8",
269
- stdio: ["ignore", "pipe", "ignore"],
270
- timeout: 8_000,
271
- })
272
- : execFileSync("ps", ["-axo", "pid=,uid=,command="], {
273
- encoding: "utf-8",
274
- stdio: ["ignore", "pipe", "ignore"],
275
- timeout: 8_000,
276
- });
277
- for (const raw of output.split(/\r?\n/)) {
278
- const line = raw.trim();
279
- if (!line) continue;
280
- if (uid !== undefined) {
281
- const match = /^(\d+)\s+(.*)$/.exec(line);
282
- if (!match) continue;
283
- const pid = Number(match[1]);
284
- const commandLine = match[2]?.trim() ?? "";
285
- if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue;
286
- out.push({ pid, commandLine, uid });
287
- continue;
288
- }
289
- const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(line);
271
+ // Top-level exec failure propagates: callers decide their own safe default
272
+ // (restart flow treat as none; staleness check → unknown, never "fresh").
273
+ const output = uid !== undefined
274
+ ? execFileSync("ps", ["-u", String(uid), "-o", "pid=,command="], {
275
+ encoding: "utf-8",
276
+ stdio: ["ignore", "pipe", "ignore"],
277
+ timeout: 5_000,
278
+ })
279
+ : execFileSync("ps", ["-axo", "pid=,uid=,command="], {
280
+ encoding: "utf-8",
281
+ stdio: ["ignore", "pipe", "ignore"],
282
+ timeout: 5_000,
283
+ });
284
+ for (const raw of output.split(/\r?\n/)) {
285
+ const line = raw.trim();
286
+ if (!line) continue;
287
+ if (uid !== undefined) {
288
+ const match = /^(\d+)\s+(.*)$/.exec(line);
290
289
  if (!match) continue;
291
290
  const pid = Number(match[1]);
292
- const processUid = Number(match[2]);
293
- const commandLine = match[3]?.trim() ?? "";
291
+ const commandLine = match[2]?.trim() ?? "";
294
292
  if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue;
295
- out.push({ pid, commandLine, uid: Number.isSafeInteger(processUid) ? processUid : undefined });
293
+ out.push({ pid, commandLine, uid });
294
+ continue;
296
295
  }
297
- } catch {
298
- return out;
296
+ const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(line);
297
+ if (!match) continue;
298
+ const pid = Number(match[1]);
299
+ const processUid = Number(match[2]);
300
+ const commandLine = match[3]?.trim() ?? "";
301
+ if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue;
302
+ out.push({ pid, commandLine, uid: Number.isSafeInteger(processUid) ? processUid : undefined });
299
303
  }
300
304
  return out;
301
305
  }
@@ -333,33 +337,34 @@ export function listWindowsSnapshots(): ProcessSnapshot[] {
333
337
  "} | ForEach-Object {",
334
338
  " try {",
335
339
  " $o=Invoke-CimMethod -InputObject $_ -MethodName GetOwner -ErrorAction Stop",
336
- " if($null -eq $o -or $o.ReturnValue -ne 0 -or [string]::IsNullOrWhiteSpace($o.User)){return}",
340
+ " if($null -eq $o -or $o.ReturnValue -ne 0 -or [string]::IsNullOrWhiteSpace($o.User)){\"__OCX_ENUM_INCOMPLETE__\"; return}",
337
341
  " $owner=if($o.Domain){\"$($o.Domain)\\$($o.User)\"}else{$o.User}",
338
342
  " if($owner -ine $me){return}",
339
343
  " $cmd=($_.CommandLine -replace \"`t\",\" \")",
340
344
  " \"{0}`t{1}`t{2}\" -f $_.ProcessId, $cmd, $owner",
341
- " } catch { }",
345
+ " } catch { \"__OCX_ENUM_INCOMPLETE__\" }",
342
346
  "}",
343
347
  ].join("\n");
344
- try {
345
- const output = execFileSync("powershell.exe", [
346
- "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
347
- "-Command",
348
- psCommand,
349
- ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 12_000, windowsHide: true });
350
- for (const line of output.split(/\r?\n/)) {
351
- const tab = line.indexOf("\t");
352
- if (tab <= 0) continue;
353
- const tab2 = line.indexOf("\t", tab + 1);
354
- if (tab2 <= tab) continue;
355
- const pid = Number(line.slice(0, tab));
356
- const commandLine = line.slice(tab + 1, tab2).trim();
357
- const owner = line.slice(tab2 + 1).trim();
358
- if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine || !owner) continue;
359
- out.push({ pid, commandLine, owner });
360
- }
361
- } catch {
362
- return out;
348
+ // Top-level exec failure propagates (see listDarwinSnapshots note).
349
+ const output = execFileSync("powershell.exe", [
350
+ "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
351
+ "-Command",
352
+ psCommand,
353
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 8_000, windowsHide: true });
354
+ for (const line of output.split(/\r?\n/)) {
355
+ // A candidate whose owner could not be verified makes the whole
356
+ // enumeration incomplete the staleness collector must not read the
357
+ // partial result as "nothing running".
358
+ if (line.trim() === "__OCX_ENUM_INCOMPLETE__") throw new Error("windows_enum_incomplete");
359
+ const tab = line.indexOf("\t");
360
+ if (tab <= 0) continue;
361
+ const tab2 = line.indexOf("\t", tab + 1);
362
+ if (tab2 <= tab) continue;
363
+ const pid = Number(line.slice(0, tab));
364
+ const commandLine = line.slice(tab + 1, tab2).trim();
365
+ const owner = line.slice(tab2 + 1).trim();
366
+ if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine || !owner) continue;
367
+ out.push({ pid, commandLine, owner });
363
368
  }
364
369
  return out;
365
370
  }
@@ -379,7 +384,18 @@ export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): C
379
384
  return undefined;
380
385
  }
381
386
  });
382
- const snapshots = io.listSnapshots?.() ?? defaultListSnapshots(platform, getuid);
387
+ let snapshots: ProcessSnapshot[];
388
+ if (io.listSnapshots) {
389
+ snapshots = io.listSnapshots();
390
+ } else {
391
+ // Restart/kill contract (#476): enumeration failure means no targets —
392
+ // never signal a process we could not verify.
393
+ try {
394
+ snapshots = defaultListSnapshots(platform, getuid);
395
+ } catch {
396
+ snapshots = [];
397
+ }
398
+ }
383
399
  const seen = new Set<number>();
384
400
  const matched: CodexAppServerProcess[] = [];
385
401
  for (const snapshot of snapshots) {
@@ -401,6 +417,235 @@ export function formatStaleCodexAppServerWarning(processes: readonly CodexAppSer
401
417
  );
402
418
  }
403
419
 
420
+ /** /proc/<pid>/stat starttime (clock ticks since boot) → epoch ms, or null. */
421
+ function readLinuxProcStartMs(pid: number): number | null {
422
+ try {
423
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
424
+ // Field 22 (starttime) follows the comm field, which may contain spaces
425
+ // inside parentheses — split after the final ")".
426
+ const close = stat.lastIndexOf(")");
427
+ if (close < 0) return null;
428
+ const fields = stat.slice(close + 2).split(/\s+/);
429
+ const startTicks = Number(fields[19]); // field 22 = index 19 after comm
430
+ const boot = /^btime\s+(\d+)/m.exec(readFileSync("/proc/stat", "utf8"));
431
+ if (!Number.isFinite(startTicks) || !boot) return null;
432
+ const hertz = 100; // USER_HZ on every supported Linux target
433
+ return (Number(boot[1]) + startTicks / hertz) * 1000;
434
+ } catch {
435
+ return null;
436
+ }
437
+ }
438
+
439
+ /** `ps` lstart → epoch ms, or null (macOS). */
440
+ function readDarwinProcStartMs(pid: number): number | null {
441
+ try {
442
+ const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
443
+ encoding: "utf-8",
444
+ stdio: ["ignore", "pipe", "ignore"],
445
+ timeout: 4_000,
446
+ }).trim();
447
+ if (!out) return null;
448
+ const parsed = Date.parse(out);
449
+ return Number.isFinite(parsed) ? parsed : null;
450
+ } catch {
451
+ return null;
452
+ }
453
+ }
454
+
455
+ /** Win32_Process.CreationDate → epoch ms, or null (Windows). */
456
+ function readWindowsProcStartMs(pid: number): number | null {
457
+ try {
458
+ const out = execFileSync("powershell.exe", [
459
+ "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
460
+ "-Command",
461
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate.ToUniversalTime().ToString("o")`,
462
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 8_000, windowsHide: true }).trim();
463
+ if (!out) return null;
464
+ const parsed = Date.parse(out);
465
+ return Number.isFinite(parsed) ? parsed : null;
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ /** Best-effort process start time; null when the platform source is unreadable. */
472
+ export function readProcessStartMs(pid: number, platform: NodeJS.Platform = process.platform): number | null {
473
+ if (platform === "win32") return readWindowsProcStartMs(pid);
474
+ if (platform === "darwin") return readDarwinProcStartMs(pid);
475
+ return readLinuxProcStartMs(pid);
476
+ }
477
+
478
+ /**
479
+ * Start times for many pids in ONE platform call where possible, so the
480
+ * staleness check does not serialize per-process ps/PowerShell invocations
481
+ * on the request path (#857). Missing entries come back as null.
482
+ */
483
+ export function readProcessStartMsBatch(
484
+ pids: readonly number[],
485
+ platform: NodeJS.Platform = process.platform,
486
+ ): Map<number, number | null> {
487
+ const out = new Map<number, number | null>();
488
+ if (pids.length === 0) return out;
489
+ if (platform === "darwin") {
490
+ try {
491
+ const stdout = execFileSync("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], {
492
+ encoding: "utf-8",
493
+ stdio: ["ignore", "pipe", "ignore"],
494
+ timeout: 3_000,
495
+ });
496
+ const byPid = new Map<number, number>();
497
+ for (const raw of stdout.split(/\r?\n/)) {
498
+ const match = /^\s*(\d+)\s+(.+)$/.exec(raw);
499
+ if (!match) continue;
500
+ const pid = Number(match[1]);
501
+ const parsed = Date.parse(match[2]!.trim());
502
+ if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) byPid.set(pid, parsed);
503
+ }
504
+ for (const pid of pids) out.set(pid, byPid.get(pid) ?? null);
505
+ return out;
506
+ } catch {
507
+ for (const pid of pids) out.set(pid, null);
508
+ return out;
509
+ }
510
+ }
511
+ if (platform === "win32") {
512
+ try {
513
+ const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR ");
514
+ const stdout = execFileSync("powershell.exe", [
515
+ "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
516
+ "-Command",
517
+ `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`,
518
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true });
519
+ const byPid = new Map<number, number>();
520
+ for (const line of stdout.split(/\r?\n/)) {
521
+ const tab = line.indexOf("\t");
522
+ if (tab <= 0) continue;
523
+ const pid = Number(line.slice(0, tab));
524
+ const parsed = Date.parse(line.slice(tab + 1).trim());
525
+ if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) byPid.set(pid, parsed);
526
+ }
527
+ for (const pid of pids) out.set(pid, byPid.get(pid) ?? null);
528
+ return out;
529
+ } catch {
530
+ for (const pid of pids) out.set(pid, null);
531
+ return out;
532
+ }
533
+ }
534
+ for (const pid of pids) out.set(pid, readLinuxProcStartMs(pid));
535
+ return out;
536
+ }
537
+
538
+ export type CodexAppServerCatalogState = "fresh" | "stale" | "not_running" | "unknown";
539
+
540
+ export interface CodexAppServerCatalogStatus {
541
+ state: CodexAppServerCatalogState;
542
+ processes: Array<{ pid: number; startedAtMs: number | null }>;
543
+ catalogMtimeMs: number | null;
544
+ }
545
+
546
+ /** Resolve the catalog file Codex app-servers loaded at startup, for staleness checks. */
547
+ function defaultCatalogMtimeMs(): number | null {
548
+ try {
549
+ return statSync(readCodexCatalogPath()).mtimeMs;
550
+ } catch {
551
+ return null;
552
+ }
553
+ }
554
+
555
+ // Short TTL: process listing + stat run once per window even under per-turn
556
+ // guidance calls (#857).
557
+ let catalogStateCache: { atMs: number; status: CodexAppServerCatalogStatus } | null = null;
558
+ const CATALOG_STATE_TTL_MS = 5_000;
559
+
560
+ /**
561
+ * Compare the on-disk catalog mtime against the start time of running Codex
562
+ * app-servers (#857): a server that started before the catalog changed keeps
563
+ * an in-memory copy that disagrees with what ocx advertises.
564
+ *
565
+ * Cost note: a cold call synchronously runs the platform listing plus ONE
566
+ * batched start-time query (hard bounds: ~5s+3s macOS, ~8s+5s Windows,
567
+ * microseconds on Linux); the 5s TTL then serves repeats. Typical cold cost
568
+ * is tens of milliseconds; fully-async background refresh is deliberately
569
+ * out of scope for this slice.
570
+ *
571
+ * - not_running: no app-server process → nothing can disagree.
572
+ * - unknown: catalog unreadable, or any server's start time is unreadable —
573
+ * callers must treat this conservatively (suppress positive model claims).
574
+ * - stale: at least one server predates the catalog mtime.
575
+ */
576
+ export function collectCodexAppServerCatalogState(
577
+ io: CodexAppServerProcessIo = {},
578
+ ): CodexAppServerCatalogStatus {
579
+ const now = (io.now ?? Date.now)();
580
+ const fullyDefault = !io.listSnapshots && !io.readStartMs && !io.catalogMtimeMs
581
+ && !io.platform && !io.getuid && !io.now;
582
+ if (fullyDefault
583
+ && catalogStateCache && now - catalogStateCache.atMs < CATALOG_STATE_TTL_MS) {
584
+ return catalogStateCache.status;
585
+ }
586
+ const compute = (): CodexAppServerCatalogStatus => {
587
+ const platform = io.platform ?? process.platform;
588
+ const getuid = io.getuid ?? (() => {
589
+ try {
590
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
591
+ } catch {
592
+ return undefined;
593
+ }
594
+ });
595
+ let snapshots: ProcessSnapshot[];
596
+ let enumerationFailed = false;
597
+ if (io.listSnapshots) {
598
+ snapshots = io.listSnapshots();
599
+ } else {
600
+ try {
601
+ snapshots = defaultListSnapshots(platform, getuid);
602
+ } catch {
603
+ // Enumeration failure must never read as "nothing running" — that
604
+ // would let positive model guidance through on guesswork (#857).
605
+ snapshots = [];
606
+ enumerationFailed = true;
607
+ }
608
+ }
609
+ const processes: CodexAppServerProcess[] = [];
610
+ const seen = new Set<number>();
611
+ for (const snapshot of snapshots) {
612
+ if (seen.has(snapshot.pid)) continue;
613
+ if (!isCodexAppServerCommandLine(snapshot.commandLine)) continue;
614
+ seen.add(snapshot.pid);
615
+ processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine });
616
+ }
617
+ if (processes.length === 0) {
618
+ return enumerationFailed
619
+ ? { state: "unknown", processes: [], catalogMtimeMs: null }
620
+ : { state: "not_running", processes: [], catalogMtimeMs: null };
621
+ }
622
+ const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)();
623
+ const withStarts = io.readStartMs
624
+ ? processes.map(proc => ({ pid: proc.pid, startedAtMs: io.readStartMs!(proc.pid) }))
625
+ : (() => {
626
+ const batch = readProcessStartMsBatch(processes.map(proc => proc.pid), platform);
627
+ return processes.map(proc => ({ pid: proc.pid, startedAtMs: batch.get(proc.pid) ?? null }));
628
+ })();
629
+ if (catalogMtimeMs === null || withStarts.some(proc => proc.startedAtMs === null)) {
630
+ return { state: "unknown", processes: withStarts, catalogMtimeMs };
631
+ }
632
+ // `<=` is deliberate: coarse clocks (ps lstart is second-granularity) can
633
+ // report equal values when the catalog actually changed after startup.
634
+ const stale = withStarts.some(proc => proc.startedAtMs! <= catalogMtimeMs);
635
+ return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs };
636
+ };
637
+ const status = compute();
638
+ if (fullyDefault) {
639
+ catalogStateCache = { atMs: now, status };
640
+ }
641
+ return status;
642
+ }
643
+
644
+ /** Test hook: drop the memoized catalog state. */
645
+ export function resetCodexAppServerCatalogStateCache(): void {
646
+ catalogStateCache = null;
647
+ }
648
+
404
649
  export interface RestartCodexAppServersResult {
405
650
  requested: number[];
406
651
  stopped: number[];
@@ -34,7 +34,6 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
34
34
  import { filterSupportedNativeSlugs } from "./parsing";
35
35
  import type { RawEntry } from "./parsing";
36
36
  import { readCurrentCatalogOrCache, unique } from "./bundled";
37
- import { ensureGpt56ReasoningLevels, isGpt56NativeSlug } from "./effort";
38
37
 
39
38
  export const NATIVE_OPENAI_MODELS = [
40
39
  "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
@@ -88,21 +87,20 @@ export function nativeReasoningEfforts(slug: string): string[] {
88
87
  ? upstream!.supported_reasoning_levels as Array<{ effort?: string }>
89
88
  : [];
90
89
  if (levels.length > 0) {
91
- const efforts = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
92
- // gpt-5.6 natives get max+ultra restored (ensureGpt56ReasoningLevels catalog path does
93
- // the same); older natives (gpt-5.5/5.4/5.4-mini/5.3-codex-spark) stop at xhigh per
94
- // upstream snapshot.
95
- if (isGpt56NativeSlug(slug)) {
96
- const set = new Set(efforts);
97
- for (const e of ["max", "ultra"]) set.add(e);
98
- return [...set];
99
- }
100
- return efforts;
90
+ // Preserve the exact pinned per-model ladder. In particular, GPT-5.6 Sol and Terra
91
+ // include ultra while Luna intentionally ends at max.
92
+ return levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
101
93
  }
102
94
  // gpt-5.3-codex-spark is not in upstream snapshot — use the standard old-ladder default.
103
95
  return ["low", "medium", "high", "xhigh"];
104
96
  }
105
97
 
98
+ /** Upstream-pinned default for a native slug, when present and non-empty. */
99
+ export function nativeDefaultReasoningEffort(slug: string): string | undefined {
100
+ const level = UPSTREAM_NATIVE_ENTRIES.get(slug)?.default_reasoning_level;
101
+ return typeof level === "string" && level.length > 0 ? level : undefined;
102
+ }
103
+
106
104
  export function nativeParallelToolCalls(slug: string): boolean {
107
105
  return UPSTREAM_NATIVE_ENTRIES.get(slug)?.supports_parallel_tool_calls === true
108
106
  || false;
@@ -98,7 +98,9 @@ function stableJson(value: unknown): string {
98
98
  function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record<string, unknown> {
99
99
  return {
100
100
  n: name,
101
- live: prov.liveModels !== false,
101
+ // Preserve the persisted tri-state. Registry enrichment may turn an omitted value into
102
+ // `false` while an explicit `true` stays live, so those callers must not share a flight.
103
+ live: prov.liveModels ?? null,
102
104
  base: prov.baseUrl ?? "",
103
105
  adapter: prov.adapter ?? "",
104
106
  models: [...(prov.models ?? [])].sort(),
@@ -407,7 +409,6 @@ function boundedOwnedBy(value: unknown): string | undefined {
407
409
 
408
410
  export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
409
411
  if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
410
- const apiKey = await resolveModelsAuthToken(name, prov);
411
412
  const seedVertexDefault = prov.adapter === "google"
412
413
  && prov.googleMode === "vertex"
413
414
  && (prov.models?.length ?? 0) === 0
@@ -418,6 +419,13 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
418
419
  provider: name,
419
420
  ...catalogHintsFromProviderConfig(name, prov, id, contextCap),
420
421
  }));
422
+ // Static catalogs never need an OAuth refresh or an upstream model request. Clear any
423
+ // discovery failure left by an older live configuration even when the account is logged out.
424
+ if (prov.liveModels === false) {
425
+ clearProviderDiscoveryStatus(name);
426
+ return configured;
427
+ }
428
+ const apiKey = await resolveModelsAuthToken(name, prov);
421
429
  // A configured default is a real callable selector and must remain discoverable when a
422
430
  // compatible provider's live /models request fails (issue #308). Keep this separate from the
423
431
  // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero
@@ -436,10 +444,6 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
436
444
  : models
437
445
  );
438
446
  if (prov.adapter === "cursor") {
439
- if (prov.liveModels === false) {
440
- clearProviderDiscoveryStatus(name);
441
- return configured;
442
- }
443
447
  if (!apiKey) return configured;
444
448
  // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
445
449
  // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
@@ -474,10 +478,6 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
474
478
  // matching Cursor's !apiKey → configured degradation and fetch-failure fallback.
475
479
  return configured;
476
480
  }
477
- if (prov.liveModels === false) {
478
- clearProviderDiscoveryStatus(name);
479
- return configured;
480
- }
481
481
  const fresh = getFreshCached(name, ttlMs);
482
482
  if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL
483
483
  if (isModelsFetchCoolingDown(name)) {
@@ -334,6 +334,21 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
334
334
  });
335
335
  }
336
336
 
337
+ /**
338
+ * True when an existing catalog row was authored by OpenCodex routing (#855).
339
+ * Every generated routed row — current full-slug form, the June–July 2026
340
+ * provider-name form, and legacy combo aliases — carries the stable
341
+ * description prefix `Routed via opencodex → `; foreign rows from Cursor or
342
+ * user tooling do not. `owned_by` cannot serve as the signal (upstream
343
+ * ownership), and `comp_hash` defaults to "opencodex" for every normalized
344
+ * row.
345
+ */
346
+ function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean {
347
+ const desc = typeof entry.description === "string" ? entry.description : "";
348
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
349
+ return slug.includes("/") && desc.startsWith("Routed via opencodex → ");
350
+ }
351
+
337
352
  export function mergeCatalogEntriesForSync(
338
353
  catalogModels: RawEntry[],
339
354
  routedEntries: RawEntry[],
@@ -418,12 +433,22 @@ export function mergeCatalogEntriesForSync(
418
433
  const preservingExistingRouted = routedEntries.length === 0
419
434
  && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
420
435
  if (preservingExistingRouted) {
421
- finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
436
+ // #855: transient-fetch protection keeps existing rows, but rows OpenCodex
437
+ // itself authored for a provider that is no longer configured are ghosts,
438
+ // not protected foreign entries.
439
+ finalRoutedEntries = catalogModels.filter(m => {
440
+ if (typeof m.slug !== "string" || !(m.slug as string).includes("/")) return false;
441
+ const provider = (m.slug as string).slice(0, (m.slug as string).indexOf("/"));
442
+ return !(isOcxAuthoredRoutedEntry(m) && !gatheredProviderNames.has(provider));
443
+ });
422
444
  } else {
423
445
  const preservedForeignRouted = catalogModels.filter(m => {
424
446
  if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
425
447
  const provider = m.slug.slice(0, m.slug.indexOf("/"));
426
- return !gatheredProviderNames.has(provider) && !freshSlugs.has(m.slug);
448
+ if (gatheredProviderNames.has(provider) || freshSlugs.has(m.slug)) return false;
449
+ // #855: an OpenCodex-authored row whose provider was deleted is a ghost;
450
+ // only genuinely foreign rows (Cursor, user tooling) are preserved.
451
+ return !isOcxAuthoredRoutedEntry(m);
427
452
  });
428
453
  finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
429
454
  }
@@ -2,7 +2,7 @@
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
3
  export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
- export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
5
+ export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
package/src/config.ts CHANGED
@@ -760,6 +760,9 @@ const configSchema = z.object({
760
760
  providers: z.record(z.string(), providerConfigSchema),
761
761
  defaultProvider: z.string().min(1).default("openai"),
762
762
  openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
763
+ // Invalid hand edits must not discard an otherwise usable config. Treat them as
764
+ // pre-migration so startup can safely re-run the one-time normalization.
765
+ googleAntigravityStaticCatalogVersion: z.literal(1).optional().catch(undefined),
763
766
  providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
764
767
  contextCapValue: z.number().int().positive().optional(),
765
768
  multiAgentGuidanceEnabled: z.boolean().optional(),
@@ -1457,9 +1460,20 @@ function appOwnedMemoryBudgetError(value: unknown): string | null {
1457
1460
  return null;
1458
1461
  }
1459
1462
 
1463
+ function googleAntigravityStaticCatalogVersionError(value: unknown): string | null {
1464
+ const raw = rawConfigRecord(value);
1465
+ if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null;
1466
+ const version = raw.googleAntigravityStaticCatalogVersion;
1467
+ if (version === undefined || version === 1) return null;
1468
+ return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted";
1469
+ }
1470
+
1460
1471
  /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
1461
1472
  export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
1462
- const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value);
1473
+ const boundaryError = blankHostnameError(value)
1474
+ ?? claudeSubagentEffortError(value)
1475
+ ?? appOwnedMemoryBudgetError(value)
1476
+ ?? googleAntigravityStaticCatalogVersionError(value);
1463
1477
  if (boundaryError) return { ok: false, error: boundaryError };
1464
1478
  const result = configSchema.safeParse(value);
1465
1479
  if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) };
@@ -110,3 +110,17 @@ export function selectEagerPath(
110
110
  if (platform === "win32") return decision;
111
111
  return decision.reason === "config-eager" ? decision : null;
112
112
  }
113
+
114
+ /**
115
+ * #864 transport gate: win32 traffic that needs a client payload rewrite must
116
+ * use the eager single reader with the rewrite applied inline, because the
117
+ * alternative tee()+JS-pull chain is the Bun#32111-unsafe path that loses the
118
+ * terminal SSE block on Windows. Independent of the version-based eager
119
+ * policy: the pull chain is unsafe on the AFFECTED runtimes by definition.
120
+ */
121
+ export function isWin32EagerRewrite(
122
+ platform: NodeJS.Platform,
123
+ needsClientRewrite: boolean,
124
+ ): boolean {
125
+ return platform === "win32" && needsClientRewrite;
126
+ }