@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.
- package/README.md +91 -449
- package/gui/dist/assets/index-OY43ubAq.css +1 -0
- package/gui/dist/assets/index-YwNnKZcL.js +67 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/package.json +1 -1
- package/src/cli/account.ts +3 -5
- package/src/cli/claude-desktop.ts +43 -7
- package/src/cli/doctor.ts +12 -0
- package/src/cli/help.ts +1 -1
- package/src/cli/provider-runtime.ts +7 -0
- package/src/cli/star-prompt.ts +25 -4
- package/src/cli/status.ts +7 -2
- package/src/codex/app-server-processes.ts +299 -54
- package/src/codex/catalog/metadata.ts +9 -11
- package/src/codex/catalog/provider-fetch.ts +10 -10
- package/src/codex/catalog/sync.ts +27 -2
- package/src/codex/catalog.ts +1 -1
- package/src/config.ts +15 -1
- package/src/lib/bun-stream-caps.ts +14 -0
- package/src/oauth/index.ts +48 -3
- package/src/providers/registry.ts +62 -0
- package/src/server/index.ts +39 -3
- package/src/server/management/agent-settings-routes.ts +40 -3
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/provider-routes.ts +4 -1
- package/src/server/management/sidebar-routes.ts +3 -1
- package/src/server/relay-eager.ts +100 -2
- package/src/server/responses/collaboration.ts +21 -0
- package/src/server/responses/core.ts +20 -12
- package/src/service.ts +393 -14
- package/src/storage/cleanup.ts +76 -5
- package/src/storage/policy.ts +6 -1
- package/src/types.ts +2 -0
- package/src/update/index.ts +9 -2
- package/src/update/job.ts +87 -11
- package/gui/dist/assets/index-CHwf3tTD.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
|
293
|
+
out.push({ pid, commandLine, uid });
|
|
294
|
+
continue;
|
|
296
295
|
}
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/codex/catalog.ts
CHANGED
|
@@ -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)
|
|
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
|
+
}
|