@bitkyc08/opencodex 2.6.17 → 2.6.18

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 (84) hide show
  1. package/README.md +9 -0
  2. package/bin/ocx.mjs +70 -5
  3. package/gui/dist/assets/index-DDcEW0Cm.css +1 -0
  4. package/gui/dist/assets/index-DbTEyo46.js +9 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +9 -2
  8. package/src/adapters/base.ts +6 -0
  9. package/src/adapters/cursor/arg-codec.ts +38 -0
  10. package/src/adapters/cursor/arg-normalize.ts +88 -0
  11. package/src/adapters/cursor/cursor-errors.ts +85 -0
  12. package/src/adapters/cursor/discovery.ts +144 -0
  13. package/src/adapters/cursor/effort-map.ts +74 -0
  14. package/src/adapters/cursor/exec-policy.ts +44 -0
  15. package/src/adapters/cursor/framing.ts +136 -0
  16. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  17. package/src/adapters/cursor/kv-store.ts +25 -0
  18. package/src/adapters/cursor/live-models.ts +93 -0
  19. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  20. package/src/adapters/cursor/live-transport.ts +758 -0
  21. package/src/adapters/cursor/mcp-config.ts +42 -0
  22. package/src/adapters/cursor/mcp-manager.ts +236 -0
  23. package/src/adapters/cursor/message-mapper.ts +46 -0
  24. package/src/adapters/cursor/native-exec-common.ts +55 -0
  25. package/src/adapters/cursor/native-exec-desktop.ts +177 -0
  26. package/src/adapters/cursor/native-exec-fs.ts +284 -0
  27. package/src/adapters/cursor/native-exec-mcp.ts +151 -0
  28. package/src/adapters/cursor/native-exec-network.ts +32 -0
  29. package/src/adapters/cursor/native-exec-shell.ts +191 -0
  30. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  31. package/src/adapters/cursor/native-exec.ts +177 -0
  32. package/src/adapters/cursor/protobuf-events.ts +309 -0
  33. package/src/adapters/cursor/protobuf-request.ts +347 -0
  34. package/src/adapters/cursor/request-builder.ts +98 -0
  35. package/src/adapters/cursor/tool-definitions.ts +301 -0
  36. package/src/adapters/cursor/transport-retry.ts +116 -0
  37. package/src/adapters/cursor/transport.ts +47 -0
  38. package/src/adapters/cursor/types.ts +36 -0
  39. package/src/adapters/cursor.ts +99 -0
  40. package/src/adapters/google.ts +7 -1
  41. package/src/adapters/kiro.ts +15 -0
  42. package/src/adapters/openai-chat.ts +7 -2
  43. package/src/adapters/run-turn-queue.ts +58 -0
  44. package/src/adapters/tool-catalog-nudge.ts +71 -0
  45. package/src/bridge.ts +7 -1
  46. package/src/cli-help.ts +9 -2
  47. package/src/cli-status.ts +7 -5
  48. package/src/cli.ts +122 -79
  49. package/src/codex-catalog.ts +213 -71
  50. package/src/codex-history-provider.ts +31 -14
  51. package/src/codex-inject.ts +17 -9
  52. package/src/codex-paths.ts +2 -1
  53. package/src/codex-shim.ts +30 -7
  54. package/src/codex-sync.ts +70 -0
  55. package/src/config.ts +58 -2
  56. package/src/doctor.ts +4 -2
  57. package/src/index.ts +1 -0
  58. package/src/model-cache.ts +22 -2
  59. package/src/oauth/callback-server.ts +44 -16
  60. package/src/oauth/cursor.ts +188 -0
  61. package/src/oauth/index.ts +29 -3
  62. package/src/oauth/key-providers.ts +20 -33
  63. package/src/oauth/login-cli.ts +7 -4
  64. package/src/open-url.ts +5 -1
  65. package/src/ports.ts +13 -0
  66. package/src/process-control.ts +76 -0
  67. package/src/provider-label.ts +10 -5
  68. package/src/providers/derive.ts +30 -3
  69. package/src/providers/registry.ts +39 -1
  70. package/src/proxy-liveness.ts +122 -0
  71. package/src/responses/parser.ts +1 -0
  72. package/src/responses/state.ts +83 -0
  73. package/src/router.ts +38 -23
  74. package/src/server/adapter-resolve.ts +3 -0
  75. package/src/server.ts +130 -18
  76. package/src/service.ts +94 -32
  77. package/src/types.ts +24 -1
  78. package/src/update-job.ts +360 -0
  79. package/src/update.ts +73 -11
  80. package/src/usage-log.ts +3 -3
  81. package/src/usage-summary.ts +3 -2
  82. package/src/win-paths.ts +68 -0
  83. package/gui/dist/assets/index-DIBiVVC0.css +0 -1
  84. package/gui/dist/assets/index-DcnD944i.js +0 -9
@@ -1,17 +1,19 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
- import { atomicWriteFile, getConfigDir, websocketsEnabled } from "./config";
5
+ import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "./config";
6
6
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./codex-paths";
7
- import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
7
+ import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "./model-cache";
8
8
  import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
9
+ import { effectiveGoogleMode } from "./providers/registry";
9
10
  import type { OcxConfig, OcxProviderConfig } from "./types";
10
11
  import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "./reasoning-effort";
11
12
  import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "./generated/jawcode-model-metadata";
12
13
  import { shouldCaseFoldMetadataModelId } from "./providers/derive";
13
14
  import { applyProviderContextCap, providerContextCap } from "./provider-context-cap";
14
15
  import { CODEX_GPT5_IDENTITY_LINE } from "./adapters/identity";
16
+ import { fetchCursorUsableModels } from "./adapters/cursor/live-models";
15
17
 
16
18
  const BUNDLED_CATALOG_CACHE_MS = 60_000;
17
19
  let bundledCatalogCache: { expiresAt: number; value: RawCatalog | null } | null = null;
@@ -32,8 +34,39 @@ function samePath(a: string, b: string): boolean {
32
34
  return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
33
35
  }
34
36
 
37
+ function activeCodexHome(): string | null {
38
+ const raw = process.env.CODEX_HOME?.trim();
39
+ if (!raw) return null;
40
+ const path = resolve(expandUserPath(raw));
41
+ try {
42
+ return realpathSync.native(path);
43
+ } catch {
44
+ return path;
45
+ }
46
+ }
47
+
48
+ function activeCodexConfigPath(): string {
49
+ const home = activeCodexHome();
50
+ return home ? join(home, "config.toml") : CODEX_CONFIG_PATH;
51
+ }
52
+
53
+ function activeDefaultCatalogPath(): string {
54
+ const home = activeCodexHome();
55
+ return home ? join(home, "opencodex-catalog.json") : DEFAULT_CATALOG_PATH;
56
+ }
57
+
58
+ function activeCodexModelsCachePath(): string {
59
+ const home = activeCodexHome();
60
+ return home ? join(home, "models_cache.json") : CODEX_MODELS_CACHE_PATH;
61
+ }
62
+
63
+ function resolveActiveCodexConfigPath(path: string): string {
64
+ const home = activeCodexHome();
65
+ return home ? resolve(home, path) : resolveCodexConfigPath(path);
66
+ }
67
+
35
68
  function isDefaultCatalogPath(path: string): boolean {
36
- return samePath(path, DEFAULT_CATALOG_PATH);
69
+ return samePath(path, activeDefaultCatalogPath());
37
70
  }
38
71
 
39
72
  /**
@@ -71,6 +104,7 @@ function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
71
104
  const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number }> = {
72
105
  "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
73
106
  "gpt-5.4": { maxContextWindow: 1_000_000 },
107
+ "gpt-5.3-codex-spark": { contextWindow: 128_000, maxContextWindow: 128_000 },
74
108
  };
75
109
 
76
110
  /**
@@ -121,16 +155,22 @@ export function isMediaGenerationModelId(id: string): boolean {
121
155
  return MEDIA_GEN_ID_RE.test(id);
122
156
  }
123
157
 
158
+ function shouldExposeRoutedModel(model: CatalogModel): boolean {
159
+ if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
160
+ return !isMediaGenerationModelId(model.id);
161
+ }
162
+
124
163
  /** Resolve the `model_catalog_json` path from Codex config.toml, else the default. */
125
164
  export function readCodexCatalogPath(): string {
126
165
  try {
127
- if (existsSync(CODEX_CONFIG_PATH)) {
128
- const toml = readFileSync(CODEX_CONFIG_PATH, "utf-8");
166
+ const configPath = activeCodexConfigPath();
167
+ if (existsSync(configPath)) {
168
+ const toml = readFileSync(configPath, "utf-8");
129
169
  const path = readRootTomlString(toml, "model_catalog_json");
130
- if (path) return resolveCodexConfigPath(path);
170
+ if (path) return resolveActiveCodexConfigPath(path);
131
171
  }
132
172
  } catch { /* ignore */ }
133
- return DEFAULT_CATALOG_PATH;
173
+ return activeDefaultCatalogPath();
134
174
  }
135
175
 
136
176
  function parseCatalogJson(raw: string): RawCatalog | null {
@@ -237,7 +277,9 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry): RawEntry {
237
277
  // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only models.
238
278
  entry.web_search_tool_type = "text_and_image";
239
279
  entry.supports_search_tool = true;
240
- entry.supports_parallel_tool_calls = false;
280
+ // Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
281
+ // Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
282
+ entry.supports_parallel_tool_calls = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
241
283
  return ensureStrictCatalogFields(entry);
242
284
  }
243
285
 
@@ -270,6 +312,7 @@ type ExecFile = (
270
312
  stdio: ["ignore", "pipe", "ignore"];
271
313
  timeout: number;
272
314
  windowsHide: boolean;
315
+ shell?: boolean;
273
316
  },
274
317
  ) => string;
275
318
 
@@ -318,13 +361,33 @@ function codexShimCommandCandidates(): string[] {
318
361
  }
319
362
  }
320
363
 
364
+ /**
365
+ * `.cmd`/`.bat` launchers (npm's `codex.cmd`) cannot be spawned shell-less — Node ≥18.20
366
+ * and Bun refuse with EINVAL (CVE-2024-27980 hardening), which the probe loop silently
367
+ * swallowed, so npm-only Codex installs never loaded the bundled catalog on Windows.
368
+ * Route those through the shell (repo convention — see src/update.ts, bin/ocx.mjs) and
369
+ * pre-quote the path: shell:true joins file+args verbatim, so an unquoted path with
370
+ * spaces (`C:\Users\John Doe\...`) would split. Windows paths cannot contain `"`.
371
+ */
372
+ export function codexExecInvocation(
373
+ command: string,
374
+ platform: NodeJS.Platform = process.platform,
375
+ ): { file: string; shell: boolean } {
376
+ if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
377
+ return { file: `"${command.replace(/"/g, "")}"`, shell: true };
378
+ }
379
+ return { file: command, shell: false };
380
+ }
381
+
321
382
  function runCodexDebugModels(command: string, execFile: ExecFile): string {
322
383
  const args = ["debug", "models", "--bundled"];
323
- return execFile(command, args, {
384
+ const invocation = codexExecInvocation(command);
385
+ return execFile(invocation.file, args, {
324
386
  encoding: "utf8" as const,
325
387
  stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
326
388
  timeout: 10_000,
327
389
  windowsHide: true,
390
+ shell: invocation.shell,
328
391
  });
329
392
  }
330
393
 
@@ -367,7 +430,7 @@ function loadCatalogForSync(path: string): RawCatalog | null {
367
430
  if (catalog && findNativeTemplate(catalog)) return catalog;
368
431
  return readCatalog(catalogBackupPathFor(path))
369
432
  ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
370
- ?? readCatalog(CODEX_MODELS_CACHE_PATH)
433
+ ?? readCatalog(activeCodexModelsCachePath())
371
434
  ?? materializeBundledCodexCatalog(path)
372
435
  ?? catalog;
373
436
  }
@@ -376,7 +439,7 @@ function readCurrentCatalogOrCache(): RawCatalog | null {
376
439
  const path = readCodexCatalogPath();
377
440
  return (isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null)
378
441
  ?? readCatalog(path)
379
- ?? readCatalog(CODEX_MODELS_CACHE_PATH);
442
+ ?? readCatalog(activeCodexModelsCachePath());
380
443
  }
381
444
 
382
445
  /**
@@ -388,7 +451,7 @@ export function loadCatalogTemplate(): RawEntry | null {
388
451
  const catalogPath = readCodexCatalogPath();
389
452
  const native = findNativeTemplate(readCatalog(catalogPath))
390
453
  ?? findNativeTemplate(readCatalogBackup(catalogPath))
391
- ?? findNativeTemplate(readCatalog(CODEX_MODELS_CACHE_PATH))
454
+ ?? findNativeTemplate(readCatalog(activeCodexModelsCachePath()))
392
455
  ?? findNativeTemplate(loadBundledCodexCatalog());
393
456
  return native ? JSON.parse(JSON.stringify(native)) : null;
394
457
  }
@@ -585,6 +648,25 @@ type ProviderModelsApiItem = {
585
648
  };
586
649
  };
587
650
 
651
+ /** Generative Language API `models.list` item (`{ models: [...] }`, not OpenAI's `{ data }`). */
652
+ type GoogleModelsApiModel = {
653
+ name?: string;
654
+ inputTokenLimit?: number;
655
+ supportedGenerationMethods?: string[];
656
+ };
657
+
658
+ function googleModelsToApiItems(models: GoogleModelsApiModel[]): ProviderModelsApiItem[] {
659
+ return models
660
+ // Keep chat-capable models only; a model missing the field stays (defensive).
661
+ .filter(m => m.supportedGenerationMethods?.includes("generateContent") ?? true)
662
+ .map(m => ({
663
+ id: (m.name ?? "").replace(/^models\//, ""),
664
+ owned_by: "google",
665
+ ...(typeof m.inputTokenLimit === "number" && m.inputTokenLimit > 0 ? { context_length: m.inputTokenLimit } : {}),
666
+ }))
667
+ .filter(m => m.id.length > 0);
668
+ }
669
+
588
670
  function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
589
671
  const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow;
590
672
  return typeof configured === "number" && configured > 0 ? configured : undefined;
@@ -668,26 +750,54 @@ function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModel
668
750
  async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
669
751
  if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
670
752
  const apiKey = await resolveModelsAuthToken(name, prov);
671
- if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip
672
753
  const configured: CatalogModel[] = (prov.models ?? []).map(id => ({
673
754
  id,
674
755
  provider: name,
675
756
  ...catalogHintsFromProviderConfig(name, prov, id, contextCap),
676
757
  }));
758
+ if (prov.adapter === "cursor") {
759
+ if (prov.liveModels === false || !apiKey) return configured;
760
+ // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
761
+ // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
762
+ // suffix) but filter the static seed to the bases the account actually has — so models not on the
763
+ // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
764
+ const cachedCursor = getFreshCached(name, ttlMs);
765
+ if (cachedCursor) return applyConfigHintsToCachedModels(name, prov, cachedCursor);
766
+ const liveIds = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl });
767
+ if (liveIds) {
768
+ const available = configured.filter(m => liveIds.some(id => id === m.id || id.startsWith(`${m.id}-`)));
769
+ const result = available.length > 0 ? available : configured;
770
+ setCached(name, result);
771
+ return result;
772
+ }
773
+ const staleCursor = getStaleCached(name);
774
+ return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured;
775
+ }
776
+ if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip
677
777
  if (prov.liveModels === false) {
678
778
  return configured;
679
779
  }
680
780
  const fresh = getFreshCached(name, ttlMs);
681
781
  if (fresh) return applyConfigHintsToCachedModels(name, prov, fresh, contextCap); // dedups Codex's frequent /v1/models polling within the TTL
682
- const { url, headers } = buildModelsRequest(prov, apiKey);
782
+ if (isModelsFetchCoolingDown(name)) {
783
+ // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
784
+ // fetch timeout on every catalog poll — the dashboard polls this path per page load.
785
+ const stale = getStaleCached(name);
786
+ return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
787
+ }
788
+ const { url, headers } = buildModelsRequest(prov, apiKey, name);
683
789
  try {
684
790
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
685
791
  if (!res.ok) {
792
+ markModelsFetchFailure(name);
686
793
  const stale = getStaleCached(name);
687
794
  return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
688
795
  }
689
- const json = await res.json() as { data?: ProviderModelsApiItem[] };
690
- const live = (json.data ?? []).map(m => applyProviderConfigHints(name, prov, {
796
+ const json = await res.json() as { data?: ProviderModelsApiItem[]; models?: GoogleModelsApiModel[] };
797
+ const items: ProviderModelsApiItem[] = effectiveGoogleMode(name, prov) === "ai-studio" && Array.isArray(json.models)
798
+ ? googleModelsToApiItems(json.models)
799
+ : (json.data ?? []);
800
+ const live = items.map(m => applyProviderConfigHints(name, prov, {
691
801
  id: m.id,
692
802
  provider: name,
693
803
  owned_by: m.owned_by,
@@ -699,6 +809,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
699
809
  setCached(name, merged);
700
810
  return merged;
701
811
  } catch {
812
+ markModelsFetchFailure(name);
702
813
  const stale = getStaleCached(name);
703
814
  return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
704
815
  }
@@ -717,9 +828,10 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
717
828
  activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))),
718
829
  );
719
830
  const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), activeProviders.map(([name]) => name), config.providers, config)
720
- // Drop image/video generation models (e.g. Grok image/video) they are not usable by Codex and
721
- // must not surface in the dashboard, /v1/models, or the routed catalog. Single choke point.
722
- .filter(m => !isMediaGenerationModelId(m.id));
831
+ // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
832
+ // intentionally mirrors Cursor's public model table, including Gemini image preview, so the
833
+ // exposure decision goes through shouldExposeRoutedModel (single choke point).
834
+ .filter(shouldExposeRoutedModel);
723
835
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
724
836
  return all;
725
837
  }
@@ -775,6 +887,77 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
775
887
  });
776
888
  }
777
889
 
890
+ export function mergeCatalogEntriesForSync(
891
+ catalogModels: RawEntry[],
892
+ routedEntries: RawEntry[],
893
+ baseline: Map<string, number>,
894
+ featured: string[],
895
+ wsEnabled: boolean,
896
+ goIds: Set<string> = new Set(),
897
+ template: RawEntry | null = null,
898
+ gatheredProviderNames: Set<string> = new Set(routedEntries.flatMap(entry => {
899
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
900
+ const slash = slug.indexOf("/");
901
+ return slash > 0 ? [slug.slice(0, slash)] : [];
902
+ })),
903
+ ): RawEntry[] {
904
+ const rank = new Map(featured.map((slug, i) => [slug, i] as const));
905
+ const native = catalogModels
906
+ .filter(m => typeof m.slug === "string"
907
+ && !(m.slug as string).includes("/")
908
+ && !goIds.has(m.slug as string)
909
+ && !isUnsupportedOpenAiNativeSlug(m.slug as string))
910
+ .map(m => {
911
+ const slug = m.slug as string;
912
+ // Featured models rank first (rank order); non-featured natives are pushed below the featured
913
+ // block when any model is featured, else keep their pristine baseline priority.
914
+ const baselinePriority = baseline.get(slug) ?? (m.priority as number);
915
+ const priority = rank.has(slug)
916
+ ? rank.get(slug)!
917
+ : featured.length > 0
918
+ ? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
919
+ : baselinePriority;
920
+ return normalizeServiceTiers({ ...m, priority });
921
+ });
922
+
923
+ // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a
924
+ // routed provider exposing the same id can never delete the native OpenAI/Codex base row.
925
+ const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
926
+ for (const slug of nativeOpenAiSlugs()) {
927
+ if (nativeSlugs.has(slug)) continue;
928
+ nativeSlugs.add(slug);
929
+ const priority = rank.has(slug)
930
+ ? rank.get(slug)!
931
+ : featured.length > 0
932
+ ? featured.length + 100
933
+ : 9;
934
+ native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
935
+ }
936
+
937
+ let finalRoutedEntries = routedEntries;
938
+ if (routedEntries.length === 0 && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"))) {
939
+ finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
940
+ console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
941
+ } else {
942
+ const freshSlugs = new Set(routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []));
943
+ const preservedForeignRouted = catalogModels.filter(m => {
944
+ if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
945
+ const provider = m.slug.slice(0, m.slug.indexOf("/"));
946
+ return !gatheredProviderNames.has(provider) && !freshSlugs.has(m.slug);
947
+ });
948
+ finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
949
+ }
950
+
951
+ return [...native, ...finalRoutedEntries].map(m => {
952
+ const normalized = normalizeServiceTiers(m);
953
+ applyNativeOpenAiContextOverride(normalized);
954
+ const e = ensureStrictCatalogFields(normalized);
955
+ if (wsEnabled) e.supports_websockets = true;
956
+ else delete e.supports_websockets;
957
+ return e;
958
+ });
959
+ }
960
+
778
961
  /**
779
962
  * Merge namespaced routed-model entries into the on-disk Codex catalog.
780
963
  * Idempotent + non-destructive:
@@ -803,64 +986,23 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
803
986
  const disabled = new Set(config.disabledModels ?? []);
804
987
  const enabledGo = goModels.filter(m => !disabled.has(`${m.provider}/${m.id}`));
805
988
  const featured = config.subagentModels ?? [];
806
- const rank = new Map(featured.map((slug, i) => [slug, i] as const));
807
989
  const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
808
990
  const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config));
809
- // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields, but drop bare
810
- // duplicates of routed models (replaced by namespaced entries) + any prior "/" entries. Re-derive
811
- // each native's priority from the pristine baseline so featuring a native is reversible.
991
+ // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
992
+ // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
993
+ // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
812
994
  const baseline = readNativeBaseline(catalogPath);
813
995
  const goIds = new Set(enabledGo.map(m => m.id));
814
- const native = (catalog.models ?? [])
815
- .filter(m => typeof m.slug === "string"
816
- && !(m.slug as string).includes("/")
817
- && !goIds.has(m.slug as string)
818
- // Gap B: drop legacy/internal OpenAI-family natives (gpt-5.2, gpt-5.3-codex,
819
- // codex-auto-review, …) from the on-disk catalog too, matching the live /v1/models
820
- // allowlist. Genuine user-added natives (non gpt-/codex- slugs) are preserved.
821
- && !isUnsupportedOpenAiNativeSlug(m.slug as string))
822
- .map(m => {
823
- const slug = m.slug as string;
824
- const baselinePriority = baseline.get(slug) ?? (m.priority as number);
825
- const priority = rank.has(slug)
826
- ? rank.get(slug)!
827
- : featured.length > 0
828
- ? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
829
- : baselinePriority;
830
- return normalizeServiceTiers({ ...m, priority });
831
- });
832
- const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
833
- for (const slug of nativeOpenAiSlugs()) {
834
- if (nativeSlugs.has(slug)) continue;
835
- nativeSlugs.add(slug);
836
- const priority = rank.has(slug)
837
- ? rank.get(slug)!
838
- : featured.length > 0
839
- ? featured.length + 100
840
- : 9;
841
- native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
842
- }
996
+ const gatheredProviderNames = new Set(
997
+ Object.entries(config.providers ?? {})
998
+ .filter(([, prov]) => prov.disabled !== true)
999
+ .map(([name]) => name),
1000
+ );
843
1001
  // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
844
1002
  // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
845
1003
  // native template can never leak supports_websockets while the flag is off.
846
1004
  const wsEnabled = websocketsEnabled(config);
847
- // Gap A: never let a transient EMPTY routed fetch wipe routed entries that were on disk. If
848
- // gatherRoutedModels returned nothing (provider down / flaky / cache miss) but the pre-sync
849
- // catalog DID carry routed entries, preserve those prior routed entries instead of overwriting
850
- // them with an empty set — otherwise the Codex picker silently loses kiro/opencode-go models.
851
- let routedEntries = goEntries;
852
- if (goEntries.length === 0 && catalogHasRoutedEntries(catalog)) {
853
- routedEntries = (catalog.models ?? []).filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
854
- console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${routedEntries.length} existing routed entr${routedEntries.length === 1 ? "y" : "ies"} on disk.`);
855
- }
856
- catalog.models = [...native, ...routedEntries].map(m => {
857
- const normalized = normalizeServiceTiers(m);
858
- applyNativeOpenAiContextOverride(normalized);
859
- const e = ensureStrictCatalogFields(normalized);
860
- if (wsEnabled) e.supports_websockets = true;
861
- else delete e.supports_websockets;
862
- return e;
863
- });
1005
+ catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, gatheredProviderNames);
864
1006
 
865
1007
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
866
1008
  return { added: goEntries.length, path: catalogPath };
@@ -915,6 +1057,6 @@ export function invalidateCodexModelsCache(): void {
915
1057
  client_version: "0.0.0",
916
1058
  models,
917
1059
  };
918
- atomicWriteFile(CODEX_MODELS_CACHE_PATH, JSON.stringify(wrapper, null, 2) + "\n");
1060
+ atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n");
919
1061
  } catch { /* best-effort */ }
920
1062
  }
@@ -144,6 +144,8 @@ export interface CodexHistorySyncResult {
144
144
  rows: number;
145
145
  files: number;
146
146
  ejectedRows?: number;
147
+ /** Set when a lock/busy error survived retries and the sync was SKIPPED, not empty. */
148
+ failed?: true;
147
149
  }
148
150
 
149
151
  interface ThreadRow {
@@ -351,7 +353,7 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu
351
353
  return { rows: rows.length, files };
352
354
  }
353
355
 
354
- function isRecoverableHistoryError(error: unknown): boolean {
356
+ export function isRecoverableHistoryError(error: unknown): boolean {
355
357
  const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
356
358
  const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
357
359
  return code === "SQLITE_BUSY"
@@ -366,15 +368,33 @@ function isRecoverableHistoryError(error: unknown): boolean {
366
368
  || message.includes("permission denied");
367
369
  }
368
370
 
369
- export function syncCodexHistoryProvider(provider: CodexHistoryProvider, stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): CodexHistorySyncResult {
370
- try {
371
- return syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath);
372
- } catch (error) {
373
- if (isRecoverableHistoryError(error)) return { rows: 0, files: 0 };
374
- throw error;
371
+ const HISTORY_RETRY_DELAY_MS = 500;
372
+
373
+ /**
374
+ * Run a history mutation with one retry across recoverable lock/busy errors (the app's own
375
+ * connection holds `state_5.sqlite` in WAL with a 5 s busy_timeout; a transient writer
376
+ * usually clears within that window). Returns null when both attempts hit a recoverable
377
+ * error — callers surface that as `failed: true` instead of a silent no-op. Hard errors
378
+ * (corruption, programming bugs) still throw.
379
+ */
380
+ export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void } = {}): T | null {
381
+ const sleepFn = io.sleepFn ?? Bun.sleepSync;
382
+ for (let attempt = 0; ; attempt++) {
383
+ try {
384
+ return fn();
385
+ } catch (error) {
386
+ if (!isRecoverableHistoryError(error)) throw error;
387
+ if (attempt >= 1) return null;
388
+ try { sleepFn(HISTORY_RETRY_DELAY_MS); } catch { /* sleep is best-effort */ }
389
+ }
375
390
  }
376
391
  }
377
392
 
393
+ export function syncCodexHistoryProvider(provider: CodexHistoryProvider, stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): CodexHistorySyncResult {
394
+ return withHistoryRetry(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath))
395
+ ?? { rows: 0, files: 0, failed: true };
396
+ }
397
+
378
398
  function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbPath: string, backupPath: string): CodexHistorySyncResult {
379
399
  if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
380
400
  if (provider === "openai") return restoreCodexHistoryProvider(stateDbPath, backupPath);
@@ -495,17 +515,14 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
495
515
  }
496
516
  }
497
517
 
498
- export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows: number; files: number } {
499
- try {
500
- if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
518
+ export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows: number; files: number; failed?: true } {
519
+ if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
520
+ return withHistoryRetry(() => {
501
521
  const db = openStateDb(stateDbPath);
502
522
  try {
503
523
  return ejectRemainingOpencodexHistory(db);
504
524
  } finally {
505
525
  db.close();
506
526
  }
507
- } catch (error) {
508
- if (isRecoverableHistoryError(error)) return { rows: 0, files: 0 };
509
- throw error;
510
- }
527
+ }) ?? { rows: 0, files: 0, failed: true };
511
528
  }
@@ -32,7 +32,11 @@ function isLoopbackHostname(hostname: string | undefined): boolean {
32
32
 
33
33
  function providerBaseHost(hostname: string | undefined): string {
34
34
  const trimmed = (hostname ?? "127.0.0.1").trim();
35
- if (isLoopbackHostname(trimmed) || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "localhost";
35
+ const lower = trimmed.toLowerCase();
36
+ // Match what the server actually binds. Writing "localhost" while binding IPv4-only
37
+ // 127.0.0.1 breaks on Windows, where localhost commonly resolves to ::1 first.
38
+ if (lower === "::1" || lower === "[::1]") return "[::1]";
39
+ if (isLoopbackHostname(trimmed) || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "127.0.0.1";
36
40
  if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
37
41
  return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
38
42
  }
@@ -283,9 +287,11 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
283
287
  const catalogMessage = catalogPath
284
288
  ? ` Codex model catalog: ${catalogPath}\n`
285
289
  : ` Codex model catalog not injected because no opencodex catalog file exists yet.\n`;
286
- const historyMessage = config?.syncResumeHistory !== false
287
- ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`
288
- : ` Codex resume history: left unchanged (syncResumeHistory=false).\n`;
290
+ const historyMessage = config?.syncResumeHistory === false
291
+ ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
292
+ : history.failed
293
+ ? ` ⚠️ Codex resume history sync SKIPPED: the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'.\n`
294
+ : ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`;
289
295
  return {
290
296
  success: true,
291
297
  message: `Injected opencodex as default provider into Codex config.\n` +
@@ -376,11 +382,13 @@ export function restoreNativeCodex(): { success: boolean; message: string } {
376
382
  const msg = cat.removed > 0
377
383
  ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
378
384
  : cfg.message;
379
- const historyMsg = history.rows > 0
380
- ? ` Resume history restored from opencodex backup (${history.rows} thread(s)).`
381
- : history.ejectedRows
382
- ? ` ${history.ejectedRows} opencodex history thread(s) were ejected to openai so native Codex can resume them.`
383
- : "";
385
+ const historyMsg = history.failed
386
+ ? ` ⚠️ Codex resume history could NOT be restored the Codex app appears to be holding the history DB. Close the Codex app/IDE and run 'ocx stop' again; until then routed threads stay hidden in the native app.`
387
+ : history.rows > 0
388
+ ? ` Resume history restored from opencodex backup (${history.rows} thread(s)).`
389
+ : history.ejectedRows
390
+ ? ` ${history.ejectedRows} opencodex history thread(s) were ejected to openai so native Codex can resume them.`
391
+ : "";
384
392
  return { success: cfg.success, message: `${msg}${historyMsg}` };
385
393
  }
386
394
 
@@ -1,11 +1,12 @@
1
1
  import { realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
+ import { expandUserPath } from "./config";
4
5
 
5
6
  function resolveCodexHome(): string {
6
7
  const raw = process.env.CODEX_HOME?.trim();
7
8
  if (raw) {
8
- const path = resolve(raw);
9
+ const path = resolve(expandUserPath(raw));
9
10
  let stat;
10
11
  try {
11
12
  stat = statSync(path);