@bitkyc08/opencodex 0.2.1 → 1.9.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/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { execFileSync } from "node:child_process";
2
3
  import { restoreNativeCodex } from "./codex-inject";
3
4
  import { loadConfig, readPid, removePid, writePid } from "./config";
4
5
  import { serviceCommand } from "./service";
@@ -37,9 +38,12 @@ async function syncModelsToCodex(port?: number) {
37
38
  const { injectCodexConfig } = await import("./codex-inject");
38
39
  const result = await injectCodexConfig(p, config);
39
40
  try {
40
- const { syncCatalogModels } = await import("./codex-catalog");
41
+ const { invalidateCodexModelsCache, syncCatalogModels } = await import("./codex-catalog");
41
42
  const cat = await syncCatalogModels(config);
42
- if (cat.added > 0) console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
43
+ if (cat.added > 0) {
44
+ invalidateCodexModelsCache();
45
+ console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
46
+ }
43
47
  } catch (e) {
44
48
  console.error("catalog sync skipped:", e instanceof Error ? e.message : String(e));
45
49
  }
@@ -85,32 +89,59 @@ function handleStart() {
85
89
  }
86
90
 
87
91
  function killProxy(pid: number): void {
92
+ if (!isProcessAlive(pid)) return;
88
93
  if (process.platform === "win32") {
94
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
89
95
  try {
90
- (require("node:child_process") as typeof import("node:child_process"))
91
- .execSync(`taskkill /PID ${pid} /T /F`, { stdio: "pipe" });
92
- } catch { /* process already gone */ }
96
+ execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe" });
97
+ } catch (err) {
98
+ if (isProcessAlive(pid)) throw err;
99
+ }
93
100
  } else {
94
101
  process.kill(pid, "SIGTERM");
102
+ if (!waitForExit(pid, 5000)) process.kill(pid, "SIGKILL");
103
+ }
104
+ if (!waitForExit(pid, 5000)) throw new Error(`process ${pid} did not exit`);
105
+ }
106
+
107
+ function isProcessAlive(pid: number): boolean {
108
+ try {
109
+ process.kill(pid, 0);
110
+ return true;
111
+ } catch {
112
+ return false;
95
113
  }
96
114
  }
97
115
 
116
+ function waitForExit(pid: number, timeoutMs: number): boolean {
117
+ const deadline = Date.now() + timeoutMs;
118
+ const marker = new Int32Array(new SharedArrayBuffer(4));
119
+ while (Date.now() < deadline) {
120
+ if (!isProcessAlive(pid)) return true;
121
+ Atomics.wait(marker, 0, 0, 50);
122
+ }
123
+ return !isProcessAlive(pid);
124
+ }
125
+
98
126
  function handleStop() {
99
127
  const pid = readPid();
128
+ let stopFailed = false;
100
129
  if (pid) {
101
130
  try {
102
131
  killProxy(pid);
103
132
  console.log(`✅ Proxy (PID ${pid}) stopped.`);
133
+ removePid();
104
134
  } catch {
105
- console.log("Proxy process not found.");
135
+ stopFailed = true;
136
+ console.error(`❌ Failed to stop proxy (PID ${pid}).`);
106
137
  }
107
- removePid();
108
138
  } else {
109
139
  console.log("No running proxy found.");
110
140
  }
111
141
  // Recover native Codex so plain `codex` keeps working while the proxy is down.
112
142
  const r = restoreNativeCodex();
113
143
  console.log(`↩️ ${r.message}`);
144
+ if (stopFailed) process.exit(1);
114
145
  }
115
146
 
116
147
  function handleStatus() {
@@ -1,13 +1,14 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { atomicWriteFile } from "./config";
4
+ import { atomicWriteFile, websocketsEnabled } from "./config";
5
+ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./codex-paths";
5
6
  import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
6
7
  import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
7
8
  import type { OcxConfig, OcxProviderConfig } from "./types";
9
+ import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "./generated/jawcode-model-metadata";
10
+ import { shouldCaseFoldMetadataModelId } from "./providers/derive";
8
11
 
9
- const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
10
- const DEFAULT_CATALOG_PATH = join(homedir(), ".codex", "opencodex-catalog.json");
11
12
  const OCX_DIR = join(homedir(), ".opencodex");
12
13
  const CATALOG_BACKUP_PATH = join(OCX_DIR, "catalog-backup.json");
13
14
 
@@ -34,14 +35,15 @@ export function nativeOpenAiSlugs(): string[] {
34
35
 
35
36
  export interface CatalogModel { id: string; provider: string; owned_by?: string; }
36
37
  type RawEntry = Record<string, unknown>;
38
+ const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
37
39
 
38
40
  /** Resolve the `model_catalog_json` path from Codex config.toml, else the default. */
39
41
  export function readCodexCatalogPath(): string {
40
42
  try {
41
43
  if (existsSync(CODEX_CONFIG_PATH)) {
42
44
  const toml = readFileSync(CODEX_CONFIG_PATH, "utf-8");
43
- const m = toml.match(/^\s*model_catalog_json\s*=\s*"([^"]+)"/m);
44
- if (m) return m[1];
45
+ const path = readRootTomlString(toml, "model_catalog_json");
46
+ if (path) return resolveCodexConfigPath(path);
45
47
  }
46
48
  } catch { /* ignore */ }
47
49
  return DEFAULT_CATALOG_PATH;
@@ -55,13 +57,86 @@ function readCatalog(path: string): { models?: RawEntry[]; [k: string]: unknown
55
57
  } catch { return null; }
56
58
  }
57
59
 
60
+ function normalizeServiceTiers(entry: RawEntry): RawEntry {
61
+ // Codex stores the user-facing config spelling as "fast", but the catalog/request
62
+ // service tier id is "priority" in current codex-rs. Keep legacy catalogs working.
63
+ if (entry.service_tier === "fast") entry.service_tier = "priority";
64
+ if (Array.isArray(entry.service_tiers)) {
65
+ entry.service_tiers = entry.service_tiers.map(tier => {
66
+ if (tier && typeof tier === "object" && "id" in tier && tier.id === "fast") {
67
+ return { ...tier, id: "priority" };
68
+ }
69
+ return tier;
70
+ });
71
+ }
72
+ return entry;
73
+ }
74
+
75
+ function ensureAutoCompactTokenLimit(entry: RawEntry): RawEntry {
76
+ if (
77
+ typeof entry.context_window === "number"
78
+ && entry.context_window > 0
79
+ && typeof entry.auto_compact_token_limit !== "number"
80
+ ) {
81
+ entry.auto_compact_token_limit = Math.floor(entry.context_window * 0.9);
82
+ }
83
+ return entry;
84
+ }
85
+
86
+ export function normalizeRoutedCatalogEntry(entry: RawEntry): RawEntry {
87
+ delete entry.model_messages;
88
+ delete entry.tool_mode;
89
+ delete entry.multi_agent_version;
90
+ delete entry.use_responses_lite;
91
+ delete entry.supports_websockets;
92
+ delete entry.additional_speed_tiers;
93
+ delete entry.service_tier;
94
+ delete entry.service_tiers;
95
+ delete entry.default_service_tier;
96
+ // Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar
97
+ // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only models.
98
+ entry.web_search_tool_type = "text_and_image";
99
+ entry.supports_search_tool = true;
100
+ return ensureAutoCompactTokenLimit(entry);
101
+ }
102
+
103
+ function applyJawcodeCatalogMetadata(entry: RawEntry, slug: string): void {
104
+ const slash = slug.indexOf("/");
105
+ if (slash < 0) return;
106
+ const provider = slug.slice(0, slash);
107
+ const modelId = slug.slice(slash + 1);
108
+ const jawcodeProvider = resolveJawcodeProvider(provider);
109
+ if (!jawcodeProvider) return;
110
+ const meta = getJawcodeModelMetadata(jawcodeProvider, modelId)
111
+ ?? (shouldCaseFoldMetadataModelId(provider) ? getJawcodeModelMetadataCaseInsensitive(jawcodeProvider, modelId) : undefined);
112
+ if (!meta) return;
113
+ if (typeof meta.contextWindow === "number" && meta.contextWindow > 0) {
114
+ entry.context_window = meta.contextWindow;
115
+ entry.max_context_window = meta.contextWindow;
116
+ entry.auto_compact_token_limit = Math.floor(meta.contextWindow * 0.9);
117
+ }
118
+ if (Array.isArray(meta.input) && meta.input.length > 0) {
119
+ entry.input_modalities = meta.input;
120
+ }
121
+ }
122
+
123
+ function loadCatalogForSync(path: string): { models?: RawEntry[]; [k: string]: unknown } | null {
124
+ const catalog = readCatalog(path);
125
+ if (catalog) return catalog;
126
+ return readCatalog(CODEX_MODELS_CACHE_PATH);
127
+ }
128
+
129
+ function readCurrentCatalogOrCache(): { models?: RawEntry[]; [k: string]: unknown } | null {
130
+ return readCatalog(readCodexCatalogPath()) ?? readCatalog(CODEX_MODELS_CACHE_PATH);
131
+ }
132
+
58
133
  /**
59
134
  * A full native entry from the on-disk catalog, used as a clone template so injected
60
135
  * entries carry EVERY field Codex's strict parser requires (e.g. `base_instructions`).
61
136
  * Returns a deep copy, or null if no catalog/native entry exists.
62
137
  */
63
138
  export function loadCatalogTemplate(): RawEntry | null {
64
- const cat = readCatalog(readCodexCatalogPath());
139
+ const cat = readCurrentCatalogOrCache();
65
140
  const native = cat?.models?.find(
66
141
  m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
67
142
  );
@@ -108,17 +183,22 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
108
183
  );
109
184
  e.supported_reasoning_levels = ROUTED_REASONING_LEVELS.map(l => byEffort.get(l.effort) ?? { ...l });
110
185
  e.default_reasoning_level = "medium";
186
+ normalizeRoutedCatalogEntry(e);
187
+ applyJawcodeCatalogMetadata(e, slug);
111
188
  }
112
- return e;
189
+ return ensureAutoCompactTokenLimit(normalizeServiceTiers(e));
113
190
  }
114
191
  // Fallback when no template is available (best-effort; strict parser may need more).
115
- return {
192
+ const entry: RawEntry = {
116
193
  slug, display_name: slug, description: desc,
117
194
  default_reasoning_level: "medium",
118
195
  supported_reasoning_levels: ROUTED_REASONING_LEVELS.map(l => ({ ...l })),
119
196
  shell_type: "shell_command", visibility: "list", supported_in_api: true,
120
197
  priority, base_instructions: "You are a helpful coding assistant.",
198
+ ...(slug.includes("/") ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
121
199
  };
200
+ applyJawcodeCatalogMetadata(entry, slug);
201
+ return ensureAutoCompactTokenLimit(normalizeServiceTiers(entry));
122
202
  }
123
203
 
124
204
  /**
@@ -126,7 +206,7 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
126
206
  * catalog sync and the proxy `/v1/models?client_version` branch.
127
207
  * Native gpt slugs stay bare; routed models are namespaced `<provider>/<model>`.
128
208
  */
129
- export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[], goModels: CatalogModel[], featured?: string[]): RawEntry[] {
209
+ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[], goModels: CatalogModel[], featured?: string[], wsEnabled = false): RawEntry[] {
130
210
  // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
131
211
  // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
132
212
  // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
@@ -144,12 +224,19 @@ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[
144
224
  if (rank.has(slug)) e.priority = rank.get(slug)!;
145
225
  out.push(e);
146
226
  }
227
+ // Central capability override (phase 120.4): the advertised flag must match the implemented WS
228
+ // endpoint. Overrides both the routed strip (normalizeRoutedCatalogEntry) and any native template
229
+ // leak (deriveEntry clones the template as-is for native slugs).
230
+ for (const entry of out) {
231
+ if (wsEnabled) entry.supports_websockets = true;
232
+ else delete entry.supports_websockets;
233
+ }
147
234
  return out;
148
235
  }
149
236
 
150
237
  /** Bare picker-visible native slugs in the live Codex catalog (drives the subagent picker UI). */
151
238
  export function listCatalogNativeSlugs(): string[] {
152
- const cat = readCatalog(readCodexCatalogPath());
239
+ const cat = readCurrentCatalogOrCache();
153
240
  return (cat?.models ?? [])
154
241
  .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list")
155
242
  .map(m => m.slug as string);
@@ -211,11 +298,28 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
211
298
  const lists = await Promise.all(
212
299
  Object.entries(config.providers).map(([name, prov]) => fetchProviderModels(name, prov, ttlMs)),
213
300
  );
214
- const all = lists.flat();
301
+ const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), Object.keys(config.providers));
215
302
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
216
303
  return all;
217
304
  }
218
305
 
306
+ export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], providerNames: string[]): CatalogModel[] {
307
+ const out = [...models];
308
+ const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
309
+ for (const provider of providerNames) {
310
+ if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
311
+ const jawcodeProvider = resolveJawcodeProvider(provider);
312
+ if (!jawcodeProvider) continue;
313
+ for (const meta of listJawcodeModelMetadata(jawcodeProvider)) {
314
+ const key = `${provider}/${meta.id}`;
315
+ if (seen.has(key)) continue;
316
+ seen.add(key);
317
+ out.push({ provider, id: meta.id, owned_by: provider });
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+
219
323
  /**
220
324
  * Reorder routed models so the configured subagent picks come FIRST (in the chosen order).
221
325
  * Codex's spawn_agent advertises only the first 5 routed catalog entries, so putting the chosen
@@ -244,7 +348,7 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
244
348
  */
245
349
  export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> {
246
350
  const catalogPath = readCodexCatalogPath();
247
- const catalog = readCatalog(catalogPath);
351
+ const catalog = loadCatalogForSync(catalogPath);
248
352
  if (!catalog) return { added: 0, path: catalogPath };
249
353
 
250
354
  const template = (catalog.models ?? []).find(
@@ -261,7 +365,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
261
365
  const featured = config.subagentModels ?? [];
262
366
  const rank = new Map(featured.map((slug, i) => [slug, i] as const));
263
367
  const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
264
- const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured);
368
+ const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config));
265
369
  // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields, but drop bare
266
370
  // duplicates of routed models (replaced by namespaced entries) + any prior "/" entries. Re-derive
267
371
  // each native's priority from the pristine baseline so featuring a native is reversible.
@@ -272,9 +376,18 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
272
376
  .map(m => {
273
377
  const slug = m.slug as string;
274
378
  const priority = rank.has(slug) ? rank.get(slug)! : (baseline.get(slug) ?? (m.priority as number));
275
- return { ...m, priority };
379
+ return normalizeServiceTiers({ ...m, priority });
276
380
  });
277
- catalog.models = [...native, ...goEntries];
381
+ // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
382
+ // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
383
+ // native template can never leak supports_websockets while the flag is off.
384
+ const wsEnabled = websocketsEnabled(config);
385
+ catalog.models = [...native, ...goEntries].map(m => {
386
+ const e = ensureAutoCompactTokenLimit(normalizeServiceTiers(m));
387
+ if (wsEnabled) e.supports_websockets = true;
388
+ else delete e.supports_websockets;
389
+ return e;
390
+ });
278
391
 
279
392
  try {
280
393
  if (!existsSync(OCX_DIR)) mkdirSync(OCX_DIR, { recursive: true });
@@ -306,13 +419,12 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
306
419
  }
307
420
 
308
421
  /**
309
- * Delete Codex's models cache (~/.codex/models_cache.json) so the next turn re-fetches /v1/models.
422
+ * Delete Codex's models cache ($CODEX_HOME/models_cache.json) so the next turn re-fetches /v1/models.
310
423
  * Codex caches the model list for 5 min (DEFAULT_MODEL_CACHE_TTL); invalidating makes catalog edits
311
424
  * (enable/disable, subagent reorder) apply on the next turn instead of waiting for the TTL.
312
425
  */
313
426
  export function invalidateCodexModelsCache(): void {
314
427
  try {
315
- const p = join(homedir(), ".codex", "models_cache.json");
316
- if (existsSync(p)) unlinkSync(p);
428
+ if (existsSync(CODEX_MODELS_CACHE_PATH)) unlinkSync(CODEX_MODELS_CACHE_PATH);
317
429
  } catch { /* best-effort */ }
318
430
  }
@@ -1,14 +1,9 @@
1
1
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- import { atomicWriteFile } from "./config";
2
+ import { atomicWriteFile, websocketsEnabled } from "./config";
5
3
  import { restoreCodexCatalog } from "./codex-catalog";
4
+ import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, tomlString } from "./codex-paths";
6
5
  import type { OcxConfig } from "./types";
7
6
 
8
- const CODEX_HOME = join(homedir(), ".codex");
9
- const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
10
- const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
11
-
12
7
  const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
13
8
 
14
9
  /**
@@ -19,7 +14,7 @@ const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
19
14
  * whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex
20
15
  * never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider.
21
16
  */
22
- function buildProviderTableBlock(port: number): string {
17
+ export function buildProviderTableBlock(port: number, supportsWebsockets = false): string {
23
18
  const lines = [
24
19
  "",
25
20
  OCX_SECTION_MARKER,
@@ -27,7 +22,9 @@ function buildProviderTableBlock(port: number): string {
27
22
  'name = "OpenCodex Proxy"',
28
23
  `base_url = "http://localhost:${port}/v1"`,
29
24
  'wire_api = "responses"',
25
+ "requires_openai_auth = true",
30
26
  ];
27
+ if (supportsWebsockets) lines.push("supports_websockets = true");
31
28
  return lines.join("\n") + "\n";
32
29
  }
33
30
 
@@ -53,6 +50,17 @@ function stripExistingModelProvider(content: string): string {
53
50
  return out.join("\n");
54
51
  }
55
52
 
53
+ function stripRootContextWindowOverrides(content: string): string {
54
+ const lines = content.split("\n");
55
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
56
+ return lines
57
+ .filter((line, i) => {
58
+ const isRoot = firstTable === -1 || i < firstTable;
59
+ return !isRoot || !/^\s*model_(?:context_window|auto_compact_token_limit)\s*=/.test(line);
60
+ })
61
+ .join("\n");
62
+ }
63
+
56
64
  /**
57
65
  * Insert `model_provider = "opencodex"` at the document ROOT — immediately before the first table
58
66
  * header (TOML root keys must precede all tables). If there are no tables, append it to the root body.
@@ -70,16 +78,95 @@ function setRootModelProvider(content: string): string {
70
78
  return lines.join("\n");
71
79
  }
72
80
 
73
- function buildProfileFile(port: number): string {
81
+ function readRootModelCatalogPath(content: string): string | null {
82
+ return readRootTomlString(content, "model_catalog_json");
83
+ }
84
+
85
+ function setRootModelCatalogPath(content: string, catalogPath: string): string {
86
+ if (readRootModelCatalogPath(content)) return content;
87
+ const lines = content.split("\n");
88
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
89
+ const key = `model_catalog_json = ${tomlString(catalogPath)}`;
90
+ if (firstTable === -1) {
91
+ return content.replace(/\n+$/, "") + "\n" + key + "\n";
92
+ }
93
+ let insertAt = firstTable;
94
+ while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
95
+ lines.splice(insertAt, 0, key);
96
+ return lines.join("\n");
97
+ }
98
+
99
+ function removeProfileSection(content: string): string {
100
+ const lines = content.split("\n");
101
+ const filtered: string[] = [];
102
+ let inProfile = false;
103
+ for (const line of lines) {
104
+ if (line.trim() === "[profiles.opencodex]") {
105
+ inProfile = true;
106
+ continue;
107
+ }
108
+ if (inProfile) {
109
+ if (line.startsWith("[") && line.trim() !== "[profiles.opencodex]") {
110
+ inProfile = false;
111
+ filtered.push(line);
112
+ }
113
+ continue;
114
+ }
115
+ filtered.push(line);
116
+ }
117
+ return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
118
+ }
119
+
120
+ function normalizeServiceTier(content: string): string {
121
+ return content.replace(/^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, '$1"fast"');
122
+ }
123
+
124
+ function ensureFastModeFeature(content: string): string {
125
+ const lines = content.split("\n");
126
+ const featuresStart = lines.findIndex(line => line.trim() === "[features]");
127
+ if (featuresStart === -1) {
128
+ return content.trimEnd() + "\n\n[features]\nfast_mode = true\n";
129
+ }
130
+
131
+ const nextTable = lines.findIndex((line, index) => index > featuresStart && /^\s*\[/.test(line));
132
+ const featuresEnd = nextTable === -1 ? lines.length : nextTable;
133
+ for (let i = featuresStart + 1; i < featuresEnd; i++) {
134
+ if (/^\s*fast_mode\s*=/.test(lines[i])) {
135
+ lines[i] = lines[i].replace(/^(\s*)fast_mode\s*=.*$/, "$1fast_mode = true");
136
+ return lines.join("\n");
137
+ }
138
+ }
139
+
140
+ let insertAt = featuresEnd;
141
+ while (insertAt > featuresStart + 1 && lines[insertAt - 1].trim() === "") insertAt--;
142
+ lines.splice(insertAt, 0, "fast_mode = true");
143
+ return lines.join("\n");
144
+ }
145
+
146
+ function stripDefaultCatalogPath(content: string): string {
147
+ return content
148
+ .split("\n")
149
+ .filter(line => {
150
+ const m = line.match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
151
+ return !m || parseTomlString(m[1]) !== DEFAULT_CATALOG_PATH;
152
+ })
153
+ .join("\n");
154
+ }
155
+
156
+ function buildProfileFile(port: number, catalogPath: string): string {
74
157
  return [
75
158
  "# OpenCodex proxy profile — use with: codex --profile opencodex",
76
159
  `# Routes all model requests through the opencodex proxy at localhost:${port}`,
77
160
  'model_provider = "opencodex"',
161
+ `model_catalog_json = ${tomlString(catalogPath)}`,
162
+ "",
163
+ "[features]",
164
+ "fast_mode = true",
78
165
  "",
79
166
  ].join("\n");
80
167
  }
81
168
 
82
- export async function injectCodexConfig(port: number, _config?: OcxConfig): Promise<{ success: boolean; message: string }> {
169
+ export async function injectCodexConfig(port: number, config?: OcxConfig): Promise<{ success: boolean; message: string }> {
83
170
  if (!existsSync(CODEX_CONFIG_PATH)) {
84
171
  return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` };
85
172
  }
@@ -92,15 +179,22 @@ export async function injectCodexConfig(port: number, _config?: OcxConfig): Prom
92
179
  if (content.includes("[model_providers.opencodex]")) {
93
180
  content = removeOcxSection(content);
94
181
  }
182
+ content = removeProfileSection(content);
95
183
  content = stripExistingModelProvider(content);
184
+ content = stripRootContextWindowOverrides(content);
185
+ content = normalizeServiceTier(content);
186
+ content = ensureFastModeFeature(content);
187
+
188
+ const catalogPath = readRootModelCatalogPath(content) ?? DEFAULT_CATALOG_PATH;
189
+ content = setRootModelCatalogPath(content, catalogPath);
96
190
 
97
191
  // 1) Root key BEFORE the first table header (must be a global, not nested under a table).
98
192
  content = setRootModelProvider(content);
99
193
  // 2) Provider table appended at EOF (position-independent).
100
- content = content.trimEnd() + "\n" + buildProviderTableBlock(port);
194
+ content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}));
101
195
 
102
196
  writeFileSync(CODEX_CONFIG_PATH, content, "utf-8");
103
- writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port), "utf-8");
197
+ writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath), "utf-8");
104
198
 
105
199
  return {
106
200
  success: true,
@@ -141,9 +235,12 @@ export function stripOpencodexConfig(content: string): string {
141
235
  if (out.includes("[model_providers.opencodex]")) {
142
236
  out = removeOcxSection(out);
143
237
  }
238
+ out = removeProfileSection(out);
144
239
  // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
145
240
  // must match the detection regex above, or a detected line could survive un-removed.
146
241
  out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
242
+ out = stripRootContextWindowOverrides(out);
243
+ out = stripDefaultCatalogPath(out);
147
244
  return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
148
245
  }
149
246
 
@@ -159,6 +256,8 @@ export function removeCodexConfig(): { success: boolean; message: string } {
159
256
  const had = hasOpencodexRouting(content);
160
257
  if (had) {
161
258
  atomicWriteFile(CODEX_CONFIG_PATH, stripOpencodexConfig(content));
259
+ } else if (stripRootContextWindowOverrides(content) !== content) {
260
+ atomicWriteFile(CODEX_CONFIG_PATH, stripOpencodexConfig(content));
162
261
  }
163
262
  if (existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
164
263
  return {
@@ -0,0 +1,59 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+
5
+ function resolveCodexHome(): string {
6
+ const raw = process.env.CODEX_HOME?.trim();
7
+ if (raw) {
8
+ const path = resolve(raw);
9
+ let stat;
10
+ try {
11
+ stat = statSync(path);
12
+ } catch (err) {
13
+ const message = err instanceof Error ? err.message : String(err);
14
+ throw new Error(`CODEX_HOME points to ${raw}, but that path could not be read: ${message}`);
15
+ }
16
+ if (!stat.isDirectory()) {
17
+ throw new Error(`CODEX_HOME points to ${raw}, but that path is not a directory`);
18
+ }
19
+ return realpathSync.native(path);
20
+ }
21
+
22
+ return join(homedir(), ".codex");
23
+ }
24
+
25
+ export const CODEX_HOME = resolveCodexHome();
26
+ export const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
27
+ export const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
28
+ export const DEFAULT_CATALOG_PATH = join(CODEX_HOME, "opencodex-catalog.json");
29
+ export const CODEX_MODELS_CACHE_PATH = join(CODEX_HOME, "models_cache.json");
30
+
31
+ export function tomlString(value: string): string {
32
+ return JSON.stringify(value);
33
+ }
34
+
35
+ export function parseTomlString(raw: string): string {
36
+ if (raw.startsWith("\"")) {
37
+ try {
38
+ return JSON.parse(raw) as string;
39
+ } catch {
40
+ return raw.slice(1, -1);
41
+ }
42
+ }
43
+ return raw.slice(1, -1);
44
+ }
45
+
46
+ export function readRootTomlString(content: string, key: string): string | null {
47
+ const lines = content.split("\n");
48
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
49
+ const rootLines = firstTable === -1 ? lines : lines.slice(0, firstTable);
50
+ for (const line of rootLines) {
51
+ const m = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(\"(?:\\\\.|[^\"])*\"|'[^']*')`));
52
+ if (m) return parseTomlString(m[1]);
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export function resolveCodexConfigPath(path: string): string {
58
+ return isAbsolute(path) ? path : join(CODEX_HOME, path);
59
+ }
package/src/config.ts CHANGED
@@ -57,6 +57,10 @@ export function saveConfig(config: OcxConfig): void {
57
57
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
58
58
  }
59
59
 
60
+ export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
61
+ return config.websockets === true;
62
+ }
63
+
60
64
  export function getDefaultConfig(): OcxConfig {
61
65
  // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
62
66
  // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
@@ -72,6 +76,7 @@ export function getDefaultConfig(): OcxConfig {
72
76
  },
73
77
  defaultProvider: "openai",
74
78
  subagentModels: [...DEFAULT_SUBAGENT_MODELS],
79
+ websockets: false,
75
80
  };
76
81
  }
77
82
 
package/src/debug.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Opt-in frame-drop visibility. The streaming path is intentionally quiet (no unconditional
2
+ // console output), so this no-ops unless OCX_DEBUG_FRAMES=1. Lets a malformed/chunk-split
3
+ // upstream frame be detected instead of silently truncating content.
4
+ const DEBUG_FRAMES = process.env.OCX_DEBUG_FRAMES === "1";
5
+
6
+ export function debugDroppedFrame(adapter: string, payload: string): void {
7
+ if (!DEBUG_FRAMES) return;
8
+ const preview = payload.length > 200 ? `${payload.slice(0, 200)}…` : payload;
9
+ console.error(`[ocx:frame-drop] ${adapter}: ${preview}`);
10
+ }