@bitkyc08/opencodex 2.7.43 → 2.8.2-preview.20260731

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 (82) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  3. package/gui/dist/assets/index-GC0Vlu1Z.js +67 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +42 -7
  7. package/src/adapters/cursor/discovery.ts +4 -1
  8. package/src/adapters/cursor/effort-map.ts +3 -0
  9. package/src/adapters/kiro.ts +15 -1
  10. package/src/adapters/openai-chat.ts +55 -4
  11. package/src/claude/alias.ts +94 -14
  12. package/src/claude/outbound.ts +6 -3
  13. package/src/cli/catalog-prewarm.ts +24 -0
  14. package/src/cli/claude-desktop.ts +2 -2
  15. package/src/cli/claude.ts +32 -7
  16. package/src/cli/doctor.ts +48 -1
  17. package/src/cli/index.ts +5 -0
  18. package/src/cli/init.ts +129 -102
  19. package/src/cli/interactive-confirm.ts +5 -1
  20. package/src/cli/star-prompt.ts +26 -4
  21. package/src/cli/v2.ts +10 -1
  22. package/src/codex/account-store.ts +2 -0
  23. package/src/codex/catalog/bundled.ts +9 -2
  24. package/src/codex/catalog/metadata.ts +6 -0
  25. package/src/codex/catalog/parsing.ts +26 -1
  26. package/src/codex/catalog/provider-fetch.ts +240 -82
  27. package/src/codex/catalog/sync.ts +27 -5
  28. package/src/codex/catalog.ts +3 -3
  29. package/src/codex/features.ts +524 -5
  30. package/src/codex/quota.ts +77 -2
  31. package/src/codex/runtime.ts +10 -1
  32. package/src/config.ts +8 -0
  33. package/src/generated/jawcode-model-metadata.ts +12 -12
  34. package/src/github/star-state.ts +191 -0
  35. package/src/lib/bun-binary-validator.d.mts +3 -0
  36. package/src/lib/bun-binary-validator.mjs +18 -0
  37. package/src/lib/bun-runtime.ts +6 -20
  38. package/src/lib/destination-policy.ts +21 -3
  39. package/src/lib/provider-outbound.ts +8 -2
  40. package/src/lib/shadow-call.ts +30 -0
  41. package/src/lib/test-home-guard.ts +90 -0
  42. package/src/lib/win-exec.ts +12 -2
  43. package/src/lib/winsw.ts +6 -0
  44. package/src/oauth/index.ts +29 -5
  45. package/src/oauth/key-providers.ts +21 -2
  46. package/src/oauth/kiro-credentials.ts +129 -9
  47. package/src/oauth/kiro.ts +15 -3
  48. package/src/oauth/login-cli.ts +1 -1
  49. package/src/oauth/store.ts +2 -0
  50. package/src/providers/derive.ts +2 -2
  51. package/src/providers/free-directory.ts +4 -1
  52. package/src/providers/model-discovery.ts +356 -0
  53. package/src/providers/registry.ts +114 -0
  54. package/src/router.ts +5 -3
  55. package/src/server/auth-cors.ts +4 -2
  56. package/src/server/index.ts +3 -3
  57. package/src/server/live.ts +75 -25
  58. package/src/server/management/agent-settings-routes.ts +82 -8
  59. package/src/server/management/config-routes.ts +24 -7
  60. package/src/server/management/context.ts +11 -1
  61. package/src/server/management/model-routes.ts +61 -14
  62. package/src/server/management/provider-routes.ts +44 -9
  63. package/src/server/management/shared.ts +18 -5
  64. package/src/server/management/sidebar-routes.ts +39 -0
  65. package/src/server/management-api.ts +3 -1
  66. package/src/server/proxy-liveness.ts +9 -2
  67. package/src/server/responses/core.ts +31 -20
  68. package/src/server/responses/upstream-error.ts +48 -0
  69. package/src/server/startup-action-control.ts +30 -14
  70. package/src/service.ts +395 -31
  71. package/src/storage/policy-job.ts +26 -5
  72. package/src/storage/restore-job.ts +16 -5
  73. package/src/storage/worker-lifecycle.ts +81 -0
  74. package/src/tray/windows.ts +86 -13
  75. package/src/types.ts +16 -0
  76. package/src/update/badge.ts +72 -0
  77. package/src/update/job.ts +8 -4
  78. package/src/usage/expected-prices.ts +6 -5
  79. package/src/usage/log.ts +8 -0
  80. package/src/web-search/loop.ts +57 -16
  81. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  82. package/gui/dist/assets/index-cmds12BG.js +0 -67
package/src/cli/init.ts CHANGED
@@ -8,11 +8,28 @@ import type { OcxConfig, OcxProviderConfig } from "../types";
8
8
 
9
9
  function createPrompt(): { ask(question: string): Promise<string>; close(): void } {
10
10
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
+ let closed = false;
12
+ rl.on("close", () => { closed = true; });
11
13
  return {
12
14
  ask(question: string): Promise<string> {
13
- return new Promise(resolve => rl.question(question, resolve));
15
+ return new Promise((resolve, reject) => {
16
+ if (closed) {
17
+ reject(new Error("stdin closed before the prompt could be answered"));
18
+ return;
19
+ }
20
+ const onClose = () => {
21
+ reject(new Error("stdin reached EOF while waiting for input"));
22
+ };
23
+ rl.once("close", onClose);
24
+ rl.question(question, answer => {
25
+ rl.off("close", onClose);
26
+ resolve(answer);
27
+ });
28
+ });
29
+ },
30
+ close() {
31
+ if (!closed) rl.close();
14
32
  },
15
- close() { rl.close(); },
16
33
  };
17
34
  }
18
35
 
@@ -83,115 +100,125 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()):
83
100
 
84
101
  export async function runInit(): Promise<void> {
85
102
  const prompt = createPrompt();
86
- console.log("\n🔧 opencodex (ocx) setup\n");
87
-
88
- const providers = buildInitProviders();
89
- printMenu(providers);
90
-
91
- const choice = await prompt.ask("\nSelect default provider (number): ");
92
- const idx = parseInt(choice, 10) - 1;
93
-
94
- let providerName: string;
95
- let providerConfig: OcxProviderConfig;
96
- let oauthHint = false;
97
-
98
- if (idx >= 0 && idx < providers.length) {
99
- const p = providers[idx];
100
- providerName = p.id;
101
- console.log(`\n📡 ${p.label}`);
102
- console.log(` Base URL: ${p.baseUrl}`);
103
-
104
- if (p.kind === "forward") {
105
- providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" };
106
- console.log(" No API key needed forwards your existing `codex login`.");
107
- } else if (p.kind === "oauth") {
108
- providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) };
109
- oauthHint = true;
110
- } else {
111
- // key + local: collect a key (local usually blank).
112
- if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
113
- // Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value.
114
- let baseUrl = p.baseUrl;
115
- if (/\{[^}]*\}/.test(baseUrl)) {
116
- const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim();
117
- if (!resolved) {
118
- console.error(" A resolved URL is required — replace the {placeholder} with your actual value.");
119
- process.exit(1);
103
+ try {
104
+ console.log("\n🔧 opencodex (ocx) setup\n");
105
+
106
+ const providers = buildInitProviders();
107
+ printMenu(providers);
108
+
109
+ const choice = await prompt.ask("\nSelect default provider (number): ");
110
+ const idx = parseInt(choice, 10) - 1;
111
+
112
+ let providerName: string;
113
+ let providerConfig: OcxProviderConfig;
114
+ let oauthHint = false;
115
+
116
+ if (idx >= 0 && idx < providers.length) {
117
+ const p = providers[idx];
118
+ providerName = p.id;
119
+ console.log(`\n📡 ${p.label}`);
120
+ console.log(` Base URL: ${p.baseUrl}`);
121
+
122
+ if (p.kind === "forward") {
123
+ providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" };
124
+ console.log(" No API key needed forwards your existing `codex login`.");
125
+ } else if (p.kind === "oauth") {
126
+ providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) };
127
+ oauthHint = true;
128
+ } else {
129
+ // key + local: collect a key (local usually blank).
130
+ if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
131
+ // Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value.
132
+ let baseUrl = p.baseUrl;
133
+ if (/\{[^}]*\}/.test(baseUrl)) {
134
+ const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim();
135
+ if (!resolved) {
136
+ console.error(" A resolved URL is required — replace the {placeholder} with your actual value.");
137
+ process.exit(1);
138
+ }
139
+ baseUrl = resolved;
120
140
  }
121
- baseUrl = resolved;
141
+ const env = envKeyFor(p.id);
142
+ const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
143
+ const apiKey = (await prompt.ask(`\n${hint}`)).trim();
144
+ const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim();
145
+ const defaultModel = modelChoice || p.defaultModel;
146
+ providerConfig = {
147
+ adapter: p.adapter,
148
+ baseUrl,
149
+ ...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
150
+ ...(defaultModel ? { defaultModel } : {}),
151
+ };
152
+ // Apply the catalog's models / vision classification (same enrichment as the GUI).
153
+ enrichProviderFromCatalog(p.id, providerConfig);
154
+ }
155
+ } else {
156
+ providerName = (await prompt.ask("Provider name: ")).trim();
157
+ if (!isValidProviderName(providerName)) {
158
+ console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key.");
159
+ process.exit(1);
122
160
  }
123
- const env = envKeyFor(p.id);
124
- const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
125
- const apiKey = (await prompt.ask(`\n${hint}`)).trim();
126
- const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim();
127
- const defaultModel = modelChoice || p.defaultModel;
161
+ const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
162
+ const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
163
+ const apiKey = await prompt.ask("API key (optional): ");
164
+ const defaultModel = await prompt.ask("Default model: ");
128
165
  providerConfig = {
129
- adapter: p.adapter,
130
- baseUrl,
131
- ...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
132
- ...(defaultModel ? { defaultModel } : {}),
166
+ adapter: adapter.trim(),
167
+ baseUrl: baseUrl.trim(),
168
+ ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
169
+ ...(defaultModel.trim() ? { defaultModel: defaultModel.trim() } : {}),
133
170
  };
134
- // Apply the catalog's models / vision classification (same enrichment as the GUI).
135
- enrichProviderFromCatalog(p.id, providerConfig);
136
- }
137
- } else {
138
- providerName = (await prompt.ask("Provider name: ")).trim();
139
- if (!isValidProviderName(providerName)) {
140
- console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key.");
141
- prompt.close();
142
- process.exit(1);
143
171
  }
144
- const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
145
- const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
146
- const apiKey = await prompt.ask("API key (optional): ");
147
- const defaultModel = await prompt.ask("Default model: ");
148
- providerConfig = {
149
- adapter: adapter.trim(),
150
- baseUrl: baseUrl.trim(),
151
- ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
152
- ...(defaultModel.trim() ? { defaultModel: defaultModel.trim() } : {}),
153
- };
154
- }
155
172
 
156
- const portStr = await prompt.ask("\nProxy port [10100]: ");
157
- const port = parseInt(portStr, 10) || 10100;
173
+ const portStr = await prompt.ask("\nProxy port [10100]: ");
174
+ const port = parseInt(portStr, 10) || 10100;
158
175
 
159
- const config: OcxConfig = {
160
- ...getDefaultConfig(),
161
- port,
162
- providers: { [providerName]: providerConfig },
163
- defaultProvider: providerName,
164
- };
176
+ const config: OcxConfig = {
177
+ ...getDefaultConfig(),
178
+ port,
179
+ providers: { [providerName]: providerConfig },
180
+ defaultProvider: providerName,
181
+ };
165
182
 
166
- saveConfig(config);
167
- // Init writes a fresh config, so a stale pre-migration backup from a previous
168
- // installation would make the next `ocx start` crash on a stale-backup
169
- // collision (issue #257). But only a STALE backup (unparseable, or already a
170
- // post-migration v2 snapshot) may be deleted; a backup that still parses as a
171
- // valid pre-migration (v1) config is a user-intentional rollback point and is
172
- // preserved by renaming it out of the collision path (sol review 260722).
173
- cleanupOpenAiTierBackupAfterInit();
174
- console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
175
- if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);
176
-
177
- const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: ");
178
- if (injectAnswer.trim().toLowerCase() !== "n") {
179
- console.log("Fetching available models from provider...");
180
- const result = await injectCodexConfig(port, config);
181
- console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`);
182
- }
183
+ saveConfig(config);
184
+ // Init writes a fresh config, so a stale pre-migration backup from a previous
185
+ // installation would make the next `ocx start` crash on a stale-backup
186
+ // collision (issue #257). But only a STALE backup (unparseable, or already a
187
+ // post-migration v2 snapshot) may be deleted; a backup that still parses as a
188
+ // valid pre-migration (v1) config is a user-intentional rollback point and is
189
+ // preserved by renaming it out of the collision path (sol review 260722).
190
+ cleanupOpenAiTierBackupAfterInit();
191
+ console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
192
+ if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);
193
+
194
+ const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: ");
195
+ if (injectAnswer.trim().toLowerCase() !== "n") {
196
+ console.log("Fetching available models from provider...");
197
+ const result = await injectCodexConfig(port, config);
198
+ console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`);
199
+ }
183
200
 
184
- const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: ");
185
- if (shimAnswer.trim().toLowerCase() !== "n") {
186
- try {
187
- const { installCodexShim } = await import("../codex/shim");
188
- const result = installCodexShim();
189
- console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`);
190
- } catch (err) {
191
- console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`);
201
+ const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: ");
202
+ if (shimAnswer.trim().toLowerCase() !== "n") {
203
+ try {
204
+ const { installCodexShim } = await import("../codex/shim");
205
+ const result = installCodexShim();
206
+ console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`);
207
+ } catch (err) {
208
+ console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`);
209
+ }
192
210
  }
193
- }
194
211
 
195
- console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`);
196
- prompt.close();
212
+ console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`);
213
+ } catch (error) {
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ if (/stdin (closed|reached EOF)/i.test(message)) {
216
+ console.error(`\n❌ ${message}. Re-run \`ocx init\` in an interactive terminal.`);
217
+ process.exitCode = 1;
218
+ return;
219
+ }
220
+ throw error;
221
+ } finally {
222
+ prompt.close();
223
+ }
197
224
  }
@@ -25,7 +25,11 @@ export interface InteractiveConfirmOptions {
25
25
  output?: NodeJS.WriteStream;
26
26
  }
27
27
 
28
- const REVERSE = "\x1b[7m";
28
+ // The highlight sets an explicit black-on-white pair rather than bare reverse
29
+ // video (\x1b[7m). Reverse alone inherits whatever foreground colour is in
30
+ // effect, so on some themes the selected label rendered as black text on a black
31
+ // block and the choice became invisible.
32
+ const REVERSE = "\x1b[30;47m";
29
33
  const DIM = "\x1b[2m";
30
34
  const RESET = "\x1b[0m";
31
35
  const CLEAR_LINE = "\r\x1b[K";
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { getConfigDir } from "../config";
5
5
  import { recordOwnedConfigPath } from "../lib/config-ownership";
6
+ import { commandInvocation } from "../lib/win-exec";
6
7
  import { isAgentDriven } from "./agent-driven";
7
8
  import { interactiveConfirm } from "./interactive-confirm";
8
9
 
@@ -29,16 +30,37 @@ export function hasStarPromptRun(): boolean {
29
30
  * that case the prompt stays silent instead of asking for something it would
30
31
  * then fail to do.
31
32
  */
33
+ /**
34
+ * On Windows `gh` is a `.cmd` shim; a shell-less spawn of the bare name skips
35
+ * PATHEXT and refuses `.cmd` targets, so it stalls until the timeout instead of
36
+ * failing fast. Route every call through the launcher the rest of the CLI uses.
37
+ */
38
+ /** Resolve `gh` once; callers keep their own spawnSync overload. */
39
+ function ghInvocation(args: string[]) {
40
+ const invocation = commandInvocation("gh", args);
41
+ return {
42
+ file: invocation.file,
43
+ args: invocation.args,
44
+ verbatim: invocation.options.windowsVerbatimArguments === true,
45
+ };
46
+ }
47
+
32
48
  function ghAvailable(): boolean {
33
- const version = spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 3000, windowsHide: true });
49
+ const v = ghInvocation(["--version"]);
50
+ const version = spawnSync(v.file, v.args,
51
+ { stdio: "ignore", timeout: 3000, windowsHide: true, windowsVerbatimArguments: v.verbatim });
34
52
  if (version.error || version.status !== 0) return false;
35
- const auth = spawnSync("gh", ["auth", "status"], { stdio: "ignore", timeout: 5000, windowsHide: true });
53
+ const a = ghInvocation(["auth", "status"]);
54
+ const auth = spawnSync(a.file, a.args,
55
+ { stdio: "ignore", timeout: 5000, windowsHide: true, windowsVerbatimArguments: a.verbatim });
36
56
  return !auth.error && auth.status === 0;
37
57
  }
38
58
 
39
59
  function starRepo(): { ok: boolean; error?: string } {
40
- const r = spawnSync("gh", ["api", "-X", "PUT", `/user/starred/${REPO}`],
41
- { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, windowsHide: true });
60
+ const star = ghInvocation(["api", "-X", "PUT", `/user/starred/${REPO}`]);
61
+ const r = spawnSync(star.file, star.args,
62
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, windowsHide: true,
63
+ windowsVerbatimArguments: star.verbatim });
42
64
  if (r.error) return { ok: false, error: r.error.message };
43
65
  if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || "").trim() || `gh exited ${r.status}` };
44
66
  return { ok: true };
package/src/cli/v2.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * - nothing in the catalog build path calls this module; no auto-flip exists.
12
12
  */
13
13
  import { execFileSync } from "node:child_process";
14
- import { getLogicalMaxThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
14
+ import { getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
15
15
 
16
16
  import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
17
17
  import { loadConfig, saveConfig } from "../config";
@@ -85,6 +85,15 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
85
85
  log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default"));
86
86
  const threads = getLogicalMaxThreads();
87
87
  log.log(`max_threads: ${threads ?? "(unset — codex default)"}`);
88
+ const v2Active = isEnabled();
89
+ const agentsEnabled = getAgentsEnabled();
90
+ log.log(`agents.enabled: ${agentsEnabled === null ? "(unset — upstream default true)" : agentsEnabled}`);
91
+ const maxDepth = getAgentsMaxDepth();
92
+ // max_depth is V1-only upstream; say so whenever V2 is active so the number
93
+ // cannot be misread as an effective V2 limit.
94
+ log.log(`agents.max_depth: ${maxDepth ?? "(unset — upstream default 1)"}${v2Active ? " (V1-only — ignored while multi_agent_v2 is enabled)" : ""}`);
95
+ const instructions = getSubagentDeveloperInstructions();
96
+ log.log(`subagent_developer_instructions: ${instructions === null ? "(unset — children inherit)" : instructions === "" ? '"" (clears inherited instructions)' : JSON.stringify(instructions)}`);
88
97
  if (isEnabled() && hasMaxThreads()) {
89
98
  log.log("WARNING: [agents] max_threads is set — codex refuses to start while multi_agent_v2 is enabled. Remove it from config.toml (concurrency lives in features.multi_agent_v2.max_concurrent_threads_per_session).");
90
99
  }
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
5
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
5
6
  import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
6
7
 
7
8
  type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
@@ -97,6 +98,7 @@ function loadCodexAccountRecordStore(): CodexAccountStore {
97
98
 
98
99
  function persist(store: CodexAccountStore): void {
99
100
  const dir = getConfigDir();
101
+ assertNotRealHomeUnderTest(dir);
100
102
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
101
103
  atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
102
104
  }
@@ -151,14 +151,21 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatal
151
151
  let cacheKey: string | null = null;
152
152
  const candidates = deps.commandCandidates?.() ?? (() => {
153
153
  const resolved = resolveAndPersistCodexRuntime({
154
- execFileSync: execFile,
154
+ // Forward an INJECTED execFileSync only. Passing the real one unconditionally made
155
+ // resolveCacheKey() bail out (it refuses to memoize injected-dep resolves), so every
156
+ // catalog read re-ran the ~1s `codex --version` probe even on a warm cache hit.
157
+ ...(deps.execFileSync ? { execFileSync: deps.execFileSync } : {}),
155
158
  configDir: deps.configDir,
156
159
  env: deps.env,
157
160
  platform: deps.platform,
158
161
  existsSync: deps.existsSync,
159
162
  readFileSync: deps.readFileSync,
160
163
  now: deps.now,
161
- discoverAlternatives: deps.discoverAlternatives,
164
+ // Catalog loading only consumes `resolved.runtime.command`, never `newerAvailable`.
165
+ // Full PATH discovery probes every candidate launcher (100+ on a dev machine, ~1.2s),
166
+ // which alone can exceed the 3s budget `ocx claude` allows /api/claude-code. Priority
167
+ // selection is identical either way; callers wanting discovery diagnostics opt in.
168
+ discoverAlternatives: deps.discoverAlternatives ?? false,
162
169
  });
163
170
  if (useCache) {
164
171
  cacheKey = [
@@ -123,6 +123,12 @@ export function visibleNativeSlugs(config: Pick<OcxConfig, "disabledModels">): s
123
123
  return nativeOpenAiSlugs().filter(slug => !disabled.has(slug));
124
124
  }
125
125
 
126
+ /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */
127
+ export function desktopVisibleNativeSlugs(config: Pick<OcxConfig, "claudeCode" | "disabledModels">): string[] {
128
+ if (config.claudeCode?.desktopNativeModels === false) return [];
129
+ return visibleNativeSlugs(config);
130
+ }
131
+
126
132
  export function nativeModelRows(config: Pick<OcxConfig, "disabledModels">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
127
133
  const disabled = disabledNativeSlugs(config);
128
134
  return NATIVE_OPENAI_MODELS.map(slug => {
@@ -110,6 +110,8 @@ export interface CatalogModel {
110
110
  /** Whether Codex may send Responses text.verbosity for this routed model. */
111
111
  supportsVerbosity?: boolean;
112
112
  supportsReasoningSummaries?: boolean;
113
+ /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
114
+ capabilities?: string[];
113
115
  }
114
116
 
115
117
  export type RawEntry = Record<string, unknown>;
@@ -272,6 +274,16 @@ export function ensureStrictCatalogFields(
272
274
  if (!Array.isArray(entry.input_modalities) && !options.preserveExactInputModalities) {
273
275
  entry.input_modalities = ["text"];
274
276
  }
277
+ // Codex parses `input_modalities` as a closed enum. One out-of-enum value (zenmux advertises
278
+ // "video") makes its config loader reject the entire catalog, which takes down plugins, apps and
279
+ // MCP servers — not just that model. Normalize at the single point every entry passes through,
280
+ // because provider metadata, jawcode metadata and effort sync each write this field.
281
+ if (Array.isArray(entry.input_modalities)) {
282
+ const accepted = entry.input_modalities.filter(value =>
283
+ value === "text" || value === "image" || value === "audio");
284
+ // Never leave it empty: an entry with no modality at all is worse than a text-only one.
285
+ entry.input_modalities = accepted.length > 0 ? accepted : ["text"];
286
+ }
275
287
  const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 ? entry.context_window : 128000;
276
288
  entry.context_window = contextWindow;
277
289
  if (
@@ -288,7 +300,18 @@ export function ensureStrictCatalogFields(
288
300
 
289
301
  export type MultiAgentMode = "v1" | "default" | "v2";
290
302
 
291
- export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode): RawEntry[] {
303
+ /**
304
+ * @param v2FeatureEnabled When the native multi_agent_v2 feature is on, "default"
305
+ * mode stamps unpinned entries as "v2" instead of deleting the key. The native
306
+ * binary validates spawn_agent models against THIS catalog with its own
307
+ * `multi_agent_version == Some(V2)` test (codex-rs multi_agents_common.rs), so an
308
+ * absent pin means a clean refusal at spawn time — exactly the cross-provider
309
+ * spawns opencodex exists to enable (option B, devlog
310
+ * 260730_codex_rs_upstream_v2_live_handoff/060). Upstream pins are always
311
+ * preserved: a genuine "v1" pin is a real capability statement and stays excluded.
312
+ * With the feature off the output is byte-identical to the historical behavior.
313
+ */
314
+ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v2FeatureEnabled = false): RawEntry[] {
292
315
  if (mode === "default") {
293
316
  // Restore upstream defaults: clear any stale forced multi_agent_version and
294
317
  // re-apply upstream pins from the snapshot for native entries that have one.
@@ -298,6 +321,8 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode):
298
321
  const upstreamPin = upstream?.multi_agent_version;
299
322
  if (typeof upstreamPin === "string") {
300
323
  entry.multi_agent_version = upstreamPin;
324
+ } else if (v2FeatureEnabled) {
325
+ entry.multi_agent_version = "v2";
301
326
  } else {
302
327
  delete entry.multi_agent_version;
303
328
  }