@koreanhole/opencodex 2.97.0 → 2.99.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/index.ts CHANGED
@@ -20,6 +20,7 @@ import { startServer } from "../server";
20
20
  import { loginClineAcp } from "../adapters/cli-agent/cline/acp";
21
21
  import { runCodingAgentMcpServer } from "../adapters/cli-agent/mcp-server";
22
22
  import { syncCodexCatalog } from "../lib/codex-catalog";
23
+ import { injectCodexConfig, recordCodexCatalogState, restoreCodexConfig } from "../lib/codex-inject";
23
24
 
24
25
  const here = dirname(fileURLToPath(import.meta.url));
25
26
 
@@ -61,7 +62,10 @@ function help(): string {
61
62
  " provider list [--json] Show supported providers",
62
63
  " start [--port <port>] [--host <host>] Start the proxy",
63
64
  " status [--json] Check the running proxy",
64
- " stop [--json] Stop the running proxy",
65
+ " stop [--json] Stop the proxy and restore native Codex",
66
+ " restore [--json] Restore native Codex without stopping",
67
+ " restore back [--json] Re-point Codex at the running proxy",
68
+ " eject Alias of restore",
65
69
  " codex sync [--json] Refresh the Codex model catalog",
66
70
  " version Print the package version",
67
71
  " help Show this help",
@@ -179,21 +183,27 @@ async function startCommand(args: string[]): Promise<number> {
179
183
  config.port = parsed;
180
184
  }
181
185
  if (host !== undefined) config.host = host;
186
+ const running = startServer(config, { env });
187
+ try {
188
+ await injectCodexConfig({ env, endpoint: { host: running.host, port: running.port }, config });
189
+ } catch (error) {
190
+ await running.stop(true).catch(() => undefined);
191
+ throw error;
192
+ }
182
193
  try {
183
194
  const catalog = await syncCodexCatalog({ env });
184
195
  if (catalog.ok && catalog.written) {
196
+ await recordCodexCatalogState(env).catch(error => {
197
+ console.warn(`[opencodex] Codex catalog state was not journaled: ${error instanceof Error ? error.message : String(error)}`);
198
+ });
185
199
  console.log(`Codex catalog refreshed: ${catalog.path}`);
186
200
  console.log("Restart the Codex app/app-server to reload the model picker.");
187
- if (!catalog.configured) {
188
- console.log(`Activate it with: model_catalog_json = "${catalog.path}"`);
189
- }
190
201
  } else if (!catalog.ok) {
191
202
  console.warn(`[opencodex] Codex catalog was not refreshed: ${catalog.message}`);
192
203
  }
193
204
  } catch (error) {
194
205
  console.warn(`[opencodex] Codex catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
195
206
  }
196
- const running = startServer(config, { env });
197
207
  await writeRuntimeState({ pid: process.pid, port: running.port, host: running.host, startedAt: new Date().toISOString() }, env);
198
208
  console.log(`opencodex listening on http://${running.host}:${running.port}/v1 (default provider: ${config.defaultProvider})`);
199
209
  console.log(`Health: http://${running.host}:${running.port}/healthz`);
@@ -202,10 +212,22 @@ async function startCommand(args: string[]): Promise<number> {
202
212
  console.log(`\nStopping proxy (${signal})...`);
203
213
  await running.stop(true);
204
214
  await removeRuntimeStateIfPid(process.pid, env);
205
- process.exit(0);
215
+ let exitCode = 0;
216
+ try {
217
+ const restored = await restoreCodexConfig({ env });
218
+ if (!restored.success) {
219
+ exitCode = 1;
220
+ console.error(`ocx: Codex restore did not complete: ${restored.catalog.message}`);
221
+ }
222
+ } catch (error) {
223
+ exitCode = 1;
224
+ console.error(`ocx: Codex restore failed: ${error instanceof Error ? error.message : String(error)}`);
225
+ }
226
+ process.exit(exitCode);
206
227
  };
207
228
  process.once("SIGINT", () => void shutdown("SIGINT"));
208
229
  process.once("SIGTERM", () => void shutdown("SIGTERM"));
230
+ process.once("SIGHUP", () => void shutdown("SIGHUP"));
209
231
  await new Promise<never>(() => undefined);
210
232
  return 0;
211
233
  }
@@ -232,9 +254,42 @@ async function codexCommand(args: string[]): Promise<number> {
232
254
  }
233
255
  console.log("Codex reload required: restart the Codex app or app-server model picker.");
234
256
  }
257
+ if (result.ok) await recordCodexCatalogState(process.env).catch(() => undefined);
235
258
  return 0;
236
259
  }
237
260
 
261
+ async function restoreCommand(args: string[]): Promise<number> {
262
+ const env = process.env;
263
+ const back = args[0] === "back";
264
+ if (back) args.shift();
265
+ const json = takeFlag(args, "--json");
266
+ const removeProviderTable = takeFlag(args, "--remove-codex-provider-table");
267
+ if (args.length > 0) throw new ConfigError(`Unknown restore option: ${args[0]}`);
268
+ let result;
269
+ if (back) {
270
+ const state = await readRuntimeState(env);
271
+ if (!state) throw new ConfigError("Proxy is not running; start it before `ocx restore back`.");
272
+ const config = await loadConfig(env);
273
+ if (!isAlive(state.pid) || !await health(state.host, state.port, config, env)) {
274
+ throw new ConfigError("The recorded proxy is not healthy; refusing to restore Codex routing.");
275
+ }
276
+ result = await injectCodexConfig({
277
+ env,
278
+ endpoint: { host: state.host, port: state.port },
279
+ config,
280
+ });
281
+ result = { ...result, action: "restore-back" as const };
282
+ } else {
283
+ result = await restoreCodexConfig({ env, removeProviderTable });
284
+ }
285
+ if (json) console.log(JSON.stringify(result));
286
+ else {
287
+ console.log(result.config.message);
288
+ console.log(result.catalog.message);
289
+ }
290
+ return result.success ? 0 : 1;
291
+ }
292
+
238
293
  async function statusCommand(args: string[]): Promise<number> {
239
294
  const json = takeFlag(args, "--json");
240
295
  if (args.length > 0) throw new ConfigError(`Unknown status option: ${args[0]}`);
@@ -257,16 +312,24 @@ async function stopCommand(args: string[]): Promise<number> {
257
312
  if (args.length > 0) throw new ConfigError(`Unknown stop option: ${args[0]}`);
258
313
  const state = await readRuntimeState();
259
314
  if (!state) {
260
- if (json) console.log(JSON.stringify({ stopped: false, reason: "not-running" }));
261
- else console.log("Proxy is not running.");
315
+ const restored = await restoreCodexConfig({ env: process.env });
316
+ if (json) console.log(JSON.stringify({ stopped: false, reason: "not-running", restore: restored }));
317
+ else {
318
+ console.log("Proxy is not running.");
319
+ console.log(restored.config.message);
320
+ }
262
321
  return 1;
263
322
  }
264
323
  const config = await loadConfig();
265
324
  if (!isAlive(state.pid)) {
266
325
  await removeRuntimeStateIfPid(state.pid);
267
- if (json) console.log(JSON.stringify({ stopped: true, stale: true }));
268
- else console.log("Removed stale proxy state.");
269
- return 0;
326
+ const restored = await restoreCodexConfig({ env: process.env });
327
+ if (json) console.log(JSON.stringify({ stopped: true, stale: true, restore: restored }));
328
+ else {
329
+ console.log("Removed stale proxy state.");
330
+ console.log(restored.config.message);
331
+ }
332
+ return restored.success ? 0 : 1;
270
333
  }
271
334
  if (!await health(state.host, state.port, config, process.env)) {
272
335
  throw new ConfigError(`Refusing to stop pid ${state.pid}: it is not the opencodex proxy recorded by this home.`);
@@ -276,9 +339,13 @@ async function stopCommand(args: string[]): Promise<number> {
276
339
  while (isAlive(state.pid) && Date.now() < deadline) await Bun.sleep(50);
277
340
  if (isAlive(state.pid)) process.kill(state.pid, "SIGKILL");
278
341
  await removeRuntimeStateIfPid(state.pid);
279
- if (json) console.log(JSON.stringify({ stopped: true, pid: state.pid }));
280
- else console.log(`Stopped proxy (pid ${state.pid}).`);
281
- return 0;
342
+ const restored = await restoreCodexConfig({ env: process.env });
343
+ if (json) console.log(JSON.stringify({ stopped: true, pid: state.pid, restore: restored }));
344
+ else {
345
+ console.log(`Stopped proxy (pid ${state.pid}).`);
346
+ console.log(restored.config.message);
347
+ }
348
+ return restored.success ? 0 : 1;
282
349
  }
283
350
 
284
351
  async function main(argv: string[]): Promise<number> {
@@ -301,7 +368,8 @@ async function main(argv: string[]): Promise<number> {
301
368
  if (command === "start") return await startCommand(argv);
302
369
  if (command === "status") return await statusCommand(argv);
303
370
  if (command === "stop") return await stopCommand(argv);
304
- throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, version, or help.`);
371
+ if (command === "restore" || command === "eject") return await restoreCommand(argv);
372
+ throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, restore, eject, version, or help.`);
305
373
  }
306
374
 
307
375
  try {
@@ -7,6 +7,7 @@ import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-mode
7
7
  import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
8
8
  import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
9
9
 
10
+ // Cline ACP descriptors expose no context source; this fallback is Cline-only.
10
11
  const CATALOG_FALLBACK_CONTEXT = 128_000;
11
12
  const BASE_INSTRUCTIONS = "You are a helpful coding assistant.";
12
13
  const MANAGED_PREFIXES = ["cline-cli/", "gemini-cli/"] as const;
@@ -28,7 +29,7 @@ export interface CodexCatalogModel {
28
29
  context_window: number;
29
30
  max_context_window: number;
30
31
  auto_compact_token_limit: number;
31
- input_modalities: ["text"];
32
+ input_modalities: Array<"text" | "image" | "audio">;
32
33
  node_repl_disabled: false;
33
34
  node_repl_auto_review_required: false;
34
35
  include_plugin_usage_instructions: false;
@@ -36,14 +37,14 @@ export interface CodexCatalogModel {
36
37
  include_skills_usage_instructions: false;
37
38
  supports_reasoning_summaries: boolean;
38
39
  default_reasoning_summary: string;
39
- support_verbosity: true;
40
+ support_verbosity: boolean;
40
41
  default_verbosity: "low";
41
42
  apply_patch_tool_type: "freeform";
42
43
  truncation_policy: { mode: "tokens"; limit: number };
43
44
  supports_parallel_tool_calls: true;
44
45
  supports_image_detail_original: false;
45
46
  experimental_supported_tools: [];
46
- effective_context_window_percent: 95;
47
+ effective_context_window_percent: number;
47
48
  comp_hash: null;
48
49
  multi_agent_version: "v1";
49
50
  auto_review_model_override: null;
@@ -115,6 +116,19 @@ export function buildCodexCatalogModel(providerId: "cline-cli" | "gemini-cli", m
115
116
  const defaultLevel = model.defaultReasoningLevel ?? levels[0]?.effort ?? "low";
116
117
  const isDefault = model.id === "default";
117
118
  const providerLabel = providerId === "gemini-cli" ? "Gemini CLI" : "Cline CLI";
119
+ const contextWindow = model.contextWindowTokens ?? (providerId === "cline-cli" ? CATALOG_FALLBACK_CONTEXT : undefined);
120
+ if (contextWindow === undefined) {
121
+ throw new Error(`Gemini model "${model.id}" has no runtime context window.`);
122
+ }
123
+ const deterministicThreshold = providerId === "gemini-cli"
124
+ ? model.compressionThreshold
125
+ : model.compressionThreshold ?? 0.9;
126
+ const compactLimit = deterministicThreshold !== undefined
127
+ ? Math.max(1, Math.floor(contextWindow * deterministicThreshold))
128
+ : contextWindow;
129
+ const effectivePercent = deterministicThreshold !== undefined
130
+ ? Math.max(1, Math.min(100, Math.round(deterministicThreshold * 100)))
131
+ : 100;
118
132
  return {
119
133
  slug: `${providerId}/${encodeRoutedId(model.id)}`,
120
134
  display_name: model.name ?? (isDefault ? `${providerLabel} default` : model.id),
@@ -129,10 +143,10 @@ export function buildCodexCatalogModel(providerId: "cline-cli" | "gemini-cli", m
129
143
  web_search_tool_type: "text_and_image",
130
144
  supports_search_tool: false,
131
145
  tool_mode: "code_mode_only",
132
- context_window: CATALOG_FALLBACK_CONTEXT,
133
- max_context_window: CATALOG_FALLBACK_CONTEXT,
134
- auto_compact_token_limit: Math.floor(CATALOG_FALLBACK_CONTEXT * 0.9),
135
- input_modalities: ["text"],
146
+ context_window: contextWindow,
147
+ max_context_window: model.maxContextWindowTokens ?? contextWindow,
148
+ auto_compact_token_limit: compactLimit,
149
+ input_modalities: [...(model.inputModalities ?? ["text"])],
136
150
  node_repl_disabled: false,
137
151
  node_repl_auto_review_required: false,
138
152
  include_plugin_usage_instructions: false,
@@ -140,14 +154,14 @@ export function buildCodexCatalogModel(providerId: "cline-cli" | "gemini-cli", m
140
154
  include_skills_usage_instructions: false,
141
155
  supports_reasoning_summaries: levels.length > 0,
142
156
  default_reasoning_summary: levels.length > 0 ? "auto" : "none",
143
- support_verbosity: true,
157
+ support_verbosity: false,
144
158
  default_verbosity: "low",
145
159
  apply_patch_tool_type: "freeform",
146
160
  truncation_policy: { mode: "tokens", limit: 10_000 },
147
161
  supports_parallel_tool_calls: true,
148
162
  supports_image_detail_original: false,
149
163
  experimental_supported_tools: [],
150
- effective_context_window_percent: 95,
164
+ effective_context_window_percent: effectivePercent,
151
165
  comp_hash: null,
152
166
  multi_agent_version: "v1",
153
167
  auto_review_model_override: null,
@@ -194,11 +208,30 @@ export async function discoverCodexCatalogModels(options: CodexCatalogDiscoveryO
194
208
  if (gemini.ok && gemini.metadataWarning) warnings.push(`Gemini metadata: ${gemini.metadataWarning}`);
195
209
  const clineModels = normalizedModels(cline.ok ? cline.models.map(id => ({ id })) : []);
196
210
  const geminiModels = normalizedModels(gemini.ok ? gemini.modelInfo : []);
211
+ const geminiDefault = geminiModels.find(model => model.id === "default");
212
+ const geminiContexts = geminiModels.flatMap(model => model.contextWindowTokens !== undefined ? [model.contextWindowTokens] : []);
213
+ if (geminiDefault && geminiContexts.length > 0) {
214
+ const conservative = Math.min(...geminiContexts);
215
+ geminiDefault.contextWindowTokens = conservative;
216
+ geminiDefault.maxContextWindowTokens = conservative;
217
+ geminiDefault.compressionThreshold = geminiModels.find(model => model.compressionThreshold !== undefined)?.compressionThreshold;
218
+ const modalities = geminiModels.flatMap(model => model.inputModalities ?? []);
219
+ geminiDefault.inputModalities = [...new Set(modalities.length > 0 ? modalities : ["text" as const])]
220
+ .filter((value): value is "text" | "image" | "audio" => value === "text" || value === "image" || value === "audio");
221
+ }
222
+ const models: CodexCatalogModel[] = [];
223
+ for (const model of clineModels) {
224
+ models.push(buildCodexCatalogModel("cline-cli", model, models.length));
225
+ }
226
+ for (const model of geminiModels) {
227
+ if (model.contextWindowTokens === undefined) {
228
+ warnings.push(`Gemini model "${model.id}" has no runtime context window; omitted from the Codex catalog.`);
229
+ continue;
230
+ }
231
+ models.push(buildCodexCatalogModel("gemini-cli", model, models.length));
232
+ }
197
233
  return {
198
- models: [
199
- ...clineModels.map((model, index) => buildCodexCatalogModel("cline-cli", model, index)),
200
- ...geminiModels.map((model, index) => buildCodexCatalogModel("gemini-cli", model, clineModels.length + index)),
201
- ],
234
+ models,
202
235
  warnings,
203
236
  };
204
237
  }
@@ -224,6 +257,67 @@ function readRootTomlString(text: string, key: string): string | undefined {
224
257
  return match?.[1]?.trim() || undefined;
225
258
  }
226
259
 
260
+ export function codexHome(env: NodeJS.ProcessEnv = process.env): string {
261
+ return env.CODEX_HOME?.trim() || join(env.HOME?.trim() || homedir(), ".codex");
262
+ }
263
+
264
+ /** Resolve the catalog only when opencodex is allowed to manage that file. */
265
+ export async function resolveCodexCatalogPath(
266
+ env: NodeJS.ProcessEnv = process.env,
267
+ ): Promise<string | undefined> {
268
+ const home = codexHome(env);
269
+ let configured: string | undefined;
270
+ try {
271
+ configured = readRootTomlString(await readFile(join(home, "config.toml"), "utf8"), "model_catalog_json");
272
+ } catch {
273
+ configured = undefined;
274
+ }
275
+ if (configured && basename(configured) !== "opencodex-catalog.json") return undefined;
276
+ return configured ? resolve(home, configured) : join(home, "opencodex-catalog.json");
277
+ }
278
+
279
+ export function isManagedCodexCatalogSlug(slug: unknown): boolean {
280
+ return managedSlug(slug);
281
+ }
282
+
283
+ /** Remove only opencodex-routed rows; preserve native/user rows. */
284
+ export async function removeManagedCodexCatalogRows(path: string): Promise<{
285
+ changed: boolean;
286
+ removed: number;
287
+ message: string;
288
+ }> {
289
+ let raw: string;
290
+ try {
291
+ raw = await readFile(path, "utf8");
292
+ } catch (error) {
293
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
294
+ return { changed: false, removed: 0, message: "No opencodex catalog file was present." };
295
+ }
296
+ throw error;
297
+ }
298
+ let parsed: Record<string, unknown>;
299
+ try {
300
+ const value = asRecord(JSON.parse(raw));
301
+ if (!value) throw new Error("catalog is not an object");
302
+ parsed = value;
303
+ } catch {
304
+ throw new Error("The opencodex catalog is malformed; it was preserved.");
305
+ }
306
+ const models = Array.isArray(parsed.models) ? parsed.models : undefined;
307
+ if (!models || models.some(model => !asRecord(model))) {
308
+ throw new Error("The opencodex catalog has an invalid models field; it was preserved.");
309
+ }
310
+ const kept = models.filter(model => !managedSlug(asRecord(model)?.slug));
311
+ const removed = models.length - kept.length;
312
+ if (removed === 0) return { changed: false, removed: 0, message: "No managed catalog rows were present." };
313
+ if (kept.length === 0 && basename(path) === "opencodex-catalog.json") {
314
+ await rm(path, { force: true });
315
+ return { changed: true, removed, message: `Removed ${removed} managed catalog row(s) and the empty opencodex catalog.` };
316
+ }
317
+ await writeCodexCatalog(path, { ...parsed, models: kept } as CodexCatalogDocument);
318
+ return { changed: true, removed, message: `Removed ${removed} managed catalog row(s) from ${path}.` };
319
+ }
320
+
227
321
  export async function writeCodexCatalog(
228
322
  path: string,
229
323
  document: CodexCatalogDocument,