@bitkyc08/opencodex 2.7.8 → 2.7.9-preview.20260712

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 (45) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/gui/dist/assets/index-BcaDQD3i.js +40 -0
  5. package/gui/dist/assets/index-Cq8maiJf.css +1 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +11 -9
  9. package/src/adapters/cursor/exec-policy.ts +38 -0
  10. package/src/adapters/cursor/live-transport.ts +4 -3
  11. package/src/adapters/cursor/protobuf-request.ts +20 -0
  12. package/src/adapters/cursor/transport.ts +5 -0
  13. package/src/adapters/cursor.ts +2 -2
  14. package/src/bridge.ts +4 -2
  15. package/src/claude/agents-inject.ts +198 -0
  16. package/src/claude/alias.ts +69 -0
  17. package/src/claude/context-windows.ts +189 -0
  18. package/src/claude/desktop-3p.ts +254 -0
  19. package/src/claude/gateway-cache.ts +70 -0
  20. package/src/claude/inbound-debug.ts +114 -0
  21. package/src/claude/inbound.ts +481 -0
  22. package/src/claude/model-info.ts +145 -0
  23. package/src/claude/outbound.ts +487 -0
  24. package/src/cli/claude.ts +157 -0
  25. package/src/cli/help.ts +12 -0
  26. package/src/cli/index.ts +86 -7
  27. package/src/cli/v2.ts +23 -18
  28. package/src/codex/features.ts +288 -16
  29. package/src/lib/crash-guard.ts +11 -1
  30. package/src/lib/debug-settings.ts +14 -2
  31. package/src/lib/token-estimate.ts +27 -1
  32. package/src/providers/registry.ts +1 -1
  33. package/src/server/auth-cors.ts +4 -2
  34. package/src/server/claude-messages.ts +494 -0
  35. package/src/server/index.ts +72 -0
  36. package/src/server/management-api.ts +226 -34
  37. package/src/server/request-log.ts +19 -4
  38. package/src/server/responses.ts +13 -1
  39. package/src/server/system-env.ts +314 -0
  40. package/src/types.ts +108 -0
  41. package/src/usage/log.ts +8 -2
  42. package/src/usage/summary.ts +18 -1
  43. package/src/usage/totals.ts +7 -18
  44. package/gui/dist/assets/index-Bp8dDrs5.js +0 -40
  45. package/gui/dist/assets/index-C0xVu72_.css +0 -1
package/src/cli/index.ts CHANGED
@@ -27,6 +27,9 @@ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-li
27
27
  import { stopProxy } from "../lib/process-control";
28
28
  import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
29
29
  import { drainAndShutdown, startServer } from "../server";
30
+ import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
31
+ import { buildDesktop3pRegistry } from "../claude/desktop-3p";
32
+ import { installShellHook, uninstallShellHook } from "../server/system-env";
30
33
  import { startTokenGuardian } from "../oauth/token-guardian";
31
34
  import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
32
35
  import { maybeShowStarPrompt } from "./star-prompt";
@@ -152,6 +155,7 @@ async function handleStart(options: { block?: boolean } = {}) {
152
155
  cleaned = true;
153
156
  try { guardian.stop(); } catch { /* best-effort */ }
154
157
  try { historyGuardian?.stop(); } catch { /* best-effort */ }
158
+ try { revertSystemEnv(); } catch { /* best-effort */ }
155
159
  removePid(process.pid);
156
160
  removeRuntimePort(process.pid);
157
161
  if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
@@ -192,8 +196,24 @@ async function handleStart(options: { block?: boolean } = {}) {
192
196
  process.on("SIGHUP", shutdown);
193
197
  process.on("exit", syncCleanup);
194
198
 
199
+ // System-wide env injection AFTER signal handlers are registered (crash safety:
200
+ // syncCleanup reverts even if injection itself or subsequent startup steps fail).
201
+ await injectSystemEnv(port, config).catch(() => {});
202
+ // Auto-install .zshrc hook (idempotent — skips if already present).
203
+ installShellHook();
204
+
195
205
  await maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start
196
206
  await syncModelsToCodex(port).catch(() => {});
207
+ // Build Desktop 3P alias registry so inbound claude-opus-4-8-{code} aliases (and legacy claude-opus-4-{code}) decode correctly.
208
+ try {
209
+ const { fetchAllModels } = await import("../server/management-api");
210
+ const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../codex/catalog");
211
+ const models = filterCatalogVisibleModels(await fetchAllModels(config), config);
212
+ buildDesktop3pRegistry(
213
+ [...visibleNativeSlugs(config)],
214
+ models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })),
215
+ );
216
+ } catch { /* best-effort — registry rebuilds on first /v1/models call */ }
197
217
  if (options.block ?? true) {
198
218
  setInterval(() => {}, 60_000);
199
219
  await new Promise<void>(() => {});
@@ -208,13 +228,15 @@ async function handleEnsure() {
208
228
  return;
209
229
  }
210
230
  const live = await findLiveProxy();
211
- if (live) {
212
- await syncModelsToCodex(live.port).catch(e => {
213
- console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
214
- });
215
- console.log(`✅ Proxy running on port ${live.port}`);
216
- return;
217
- }
231
+ if (live) {
232
+ await syncModelsToCodex(live.port).catch(e => {
233
+ console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
234
+ });
235
+ // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
236
+ await injectSystemEnv(live.port, config).catch(() => {});
237
+ console.log(`✅ Proxy running on port ${live.port}`);
238
+ return;
239
+ }
218
240
 
219
241
  const child = spawn(process.execPath, [process.argv[1], "start"], {
220
242
  detached: true,
@@ -284,6 +306,9 @@ async function handleStop() {
284
306
  }
285
307
  const r = restoreNativeCodex();
286
308
  console.log(`↩️ ${r.message}`);
309
+ // Safety net: revert system env vars even if the daemon's syncCleanup didn't run
310
+ // (e.g. SIGKILL). revertSystemEnv is ownership-checked and idempotent.
311
+ try { revertSystemEnv(); } catch { /* best-effort */ }
287
312
  if (stopFailed) process.exit(1);
288
313
  }
289
314
 
@@ -319,6 +344,16 @@ async function handleUninstall() {
319
344
  if (!r.success) throw new Error(r.message);
320
345
  });
321
346
 
347
+ await runStep("system env vars reverted", () => {
348
+ const r = revertSystemEnv();
349
+ if (!r.reverted && r.reason !== "no tracking file" && r.reason !== "not macOS") throw new Error(r.reason ?? "revert failed");
350
+ });
351
+
352
+ await runStep("shell hook removed", () => {
353
+ const r = uninstallShellHook();
354
+ if (!r.removed && r.reason !== "not installed" && r.reason !== "not macOS") throw new Error(r.reason ?? "remove failed");
355
+ });
356
+
322
357
  try {
323
358
  const { uninstallCodexShim } = await import("../codex/shim");
324
359
  const r = uninstallCodexShim();
@@ -580,6 +615,50 @@ switch (command) {
580
615
  const { handleModels } = await import("./models");
581
616
  handleModels(args.slice(1));
582
617
  break;
618
+ }
619
+ case "claude": {
620
+ const { cmdClaude } = await import("./claude");
621
+ // "ocx claude desktop" → write Desktop 3P config
622
+ if (args[1] === "desktop") {
623
+ const config = loadConfig();
624
+ const { fetchAllModels } = await import("../server/management-api");
625
+ const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../codex/catalog");
626
+ const { parseDesktop3pModeArgs, writeDesktop3pConfig } = await import("../claude/desktop-3p");
627
+ // Mutually-exclusive mode flags (devlog 138): default static (deterministic; the
628
+ // static list overrides discovery anyway — no merge).
629
+ const parsedMode = parseDesktop3pModeArgs(args.slice(2));
630
+ if ("error" in parsedMode) {
631
+ console.error(`❌ ${parsedMode.error}`);
632
+ process.exit(1);
633
+ }
634
+ const mode = parsedMode.mode;
635
+ const live = await findLiveProxy();
636
+ const port = live?.port ?? config.port ?? 10100;
637
+ const allModels = await fetchAllModels(config);
638
+ const models = filterCatalogVisibleModels(allModels, config);
639
+ const nativeSlugs = [...visibleNativeSlugs(config)];
640
+ // contextWindow rides along so supports1m derives from authoritative data (감사 R1#1).
641
+ const routedModels = models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow }));
642
+ const result = writeDesktop3pConfig(port, nativeSlugs, routedModels, undefined, mode);
643
+ if (result.written) {
644
+ const oneM = routedModels.filter(m => typeof m.contextWindow === "number" && m.contextWindow >= 1_000_000).length;
645
+ console.log(`✅ Claude Desktop 3P 설정 완료: ${result.path}`);
646
+ console.log(` Gateway: http://127.0.0.1:${port}`);
647
+ if (mode === "discovery") {
648
+ console.log(` 모델 목록: 자동 발견만 (프록시 /v1/models에서 ${nativeSlugs.length + models.length}개)`);
649
+ } else {
650
+ const suffix = mode === "hybrid" ? " + 자동 발견 병행" : "";
651
+ console.log(` 모델 ${nativeSlugs.length + models.length}개 고정 등록${suffix} (1M 컨텍스트 별도 행 ${oneM}개)`);
652
+ if (oneM > 0) console.log(` 1M을 쓰려면 Desktop 모델 피커에서 [1M] 붙은 행을 직접 선택하세요.`);
653
+ }
654
+ console.log(` Claude Desktop을 재시작하면 적용됩니다.`);
655
+ } else {
656
+ console.error(`❌ 설정 실패: ${result.reason}`);
657
+ process.exit(1);
658
+ }
659
+ break;
660
+ }
661
+ process.exit(await cmdClaude(args.slice(1)));
583
662
  }
584
663
  case "help":
585
664
  case "--help":
package/src/cli/v2.ts CHANGED
@@ -6,12 +6,12 @@
6
6
  * - config.toml writes go through the official `codex features enable|disable`
7
7
  * CLI only (format-preserving TOML edit stays upstream-owned).
8
8
  * - after a successful flip the catalog is RESYNCED so model metadata stays fresh.
9
- * - `on` warns when [agents] max_threads is still present (codex-rs refuses to
10
- * boot with it while v2 is enabled) ocx never edits that key itself.
9
+ * - flips preserve the active thread limit while moving it between the v1/v2
10
+ * config keys, with byte-for-byte rollback when the feature command fails.
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 { getMaxConcurrentThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMaxConcurrentThreads } from "../codex/features";
14
+ import { getLogicalMaxThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
15
15
 
16
16
  import { loadConfig, saveConfig } from "../config";
17
17
 
@@ -55,8 +55,8 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
55
55
  log.log(v2StatusLine(isEnabled()));
56
56
  const cfg = loadConfig();
57
57
  log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default"));
58
- const threads = getMaxConcurrentThreads();
59
- log.log(`max_concurrent_threads_per_session: ${threads ?? "(unset — codex default)"}`);
58
+ const threads = getLogicalMaxThreads();
59
+ log.log(`max_threads: ${threads ?? "(unset — codex default)"}`);
60
60
  if (isEnabled() && hasMaxThreads()) {
61
61
  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).");
62
62
  }
@@ -68,11 +68,12 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
68
68
  log.error("v2 threads: pass an integer >= 1 (features.multi_agent_v2.max_concurrent_threads_per_session)");
69
69
  return 1;
70
70
  }
71
- const result = setMaxConcurrentThreads(value);
71
+ const enabled = isEnabled();
72
+ const result = transitionMultiAgentV2(enabled, next => runCodexFeatures(next ? "enable" : "disable", deps), { threadLimit: value });
72
73
  if (!result.ok) { log.error(`v2 threads: ${result.error}`); return 1; }
73
74
  log.log(result.changed
74
- ? `max_concurrent_threads_per_session = ${value} — applies to new sessions.`
75
- : `max_concurrent_threads_per_session already ${value} — nothing to do.`);
75
+ ? `max_threads = ${value} (${enabled ? "v2" : "v1"}) — applies to new sessions.`
76
+ : `max_threads already ${value} — nothing to do.`);
76
77
  return 0;
77
78
  }
78
79
  if (verb === "mode") {
@@ -82,6 +83,14 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
82
83
  return 1;
83
84
  }
84
85
  const cfg = loadConfig();
86
+ if (modeArg !== "default") {
87
+ const target = modeArg === "v2";
88
+ const transition = transitionMultiAgentV2(target, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps));
89
+ if (!transition.ok) {
90
+ log.error(`multi-agent mode transition failed: ${transition.error}`);
91
+ return 1;
92
+ }
93
+ }
85
94
  if (modeArg === "default") delete cfg.multiAgentMode;
86
95
  else cfg.multiAgentMode = modeArg as "v1" | "v2";
87
96
  saveConfig(cfg);
@@ -102,18 +111,14 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
102
111
  }
103
112
 
104
113
  const want = verb === "on";
105
- if (isEnabled() === want) {
106
- log.log(`multi_agent_v2 already ${want ? "ON" : "OFF"} — nothing to do.`);
107
- return 0;
108
- }
109
- try {
110
- runCodexFeatures(want ? "enable" : "disable", deps);
111
- } catch (err) {
112
- log.error(`codex features ${want ? "enable" : "disable"} multi_agent_v2 failed: ${err instanceof Error ? err.message : String(err)}`);
114
+ const transition = transitionMultiAgentV2(want, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps));
115
+ if (!transition.ok) {
116
+ log.error(`codex features ${want ? "enable" : "disable"} multi_agent_v2 failed: ${transition.error}`);
113
117
  return 1;
114
118
  }
115
- if (want && hasMaxThreads()) {
116
- log.log("WARNING: [agents] max_threads is still setcodex will REFUSE to start until you remove it (features.multi_agent_v2.max_concurrent_threads_per_session replaces it).");
119
+ if (!transition.changed) {
120
+ log.log(`multi_agent_v2 already ${want ? "ON" : "OFF"} nothing to do.`);
121
+ return 0;
117
122
  }
118
123
 
119
124
  // Resync catalog so multi-agent surface metadata stays fresh in both the
@@ -14,7 +14,7 @@
14
14
  * catalog.ts:40-54) so tests can point fixtures via env or the explicit
15
15
  * `configPath` parameter without fighting the module-load-time const in paths.ts.
16
16
  */
17
- import { existsSync, readFileSync } from "node:fs";
17
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
18
18
  import { join, resolve } from "node:path";
19
19
  import { realpathSync } from "node:fs";
20
20
  import { atomicWriteFile, expandUserPath } from "../config";
@@ -34,6 +34,14 @@ function applyEol(content: string, eol: "\r\n" | "\n"): string {
34
34
  return eol === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
35
35
  }
36
36
 
37
+ function mergeTrailingComments(existing?: string, migrated?: string): string {
38
+ if (!existing) return migrated ?? "";
39
+ if (!migrated || existing.trim() === migrated.trim()) return existing;
40
+ const migratedText = migrated.replace(/^\s*#\s*/, "");
41
+ if (existing.replace(/^\s*#\s*/, "").split(";").map(part => part.trim()).includes(migratedText.trim())) return existing;
42
+ return `${existing}; ${migratedText}`;
43
+ }
44
+
37
45
  function activeCodexConfigPath(): string {
38
46
  const raw = process.env.CODEX_HOME?.trim();
39
47
  if (!raw) return CODEX_CONFIG_PATH;
@@ -120,16 +128,30 @@ export function hasAgentsMaxThreads(configPath?: string): boolean {
120
128
  return /^\s*max_threads\s*=/m.test(agents);
121
129
  }
122
130
 
131
+ /** Current legacy v1 `[agents] max_threads`, or null when absent/invalid. */
132
+ export function getAgentsMaxThreads(configPath?: string): number | null {
133
+ const content = readConfigText(configPath);
134
+ if (content === null) return null;
135
+ const agents = tomlTableBody(content, "agents");
136
+ if (agents === null) return null;
137
+ const m = agents.match(/^\s*max_threads\s*=\s*(\d+)\s*(?:#.*)?$/m);
138
+ if (!m) return null;
139
+ const value = Number(m[1]);
140
+ return Number.isInteger(value) && value >= 1 ? value : null;
141
+ }
142
+
123
143
  /**
124
- * Current `features.multi_agent_v2.max_concurrent_threads_per_session`, or null when
125
- * the table/key is absent (codex-rs then applies its own default).
144
+ * Current `features.multi_agent_v2.max_concurrent_threads_per_session`, from
145
+ * either the dedicated or inline-table form; null means the Codex default.
126
146
  */
127
147
  export function getMaxConcurrentThreads(configPath?: string): number | null {
128
148
  const content = readConfigText(configPath);
129
149
  if (content === null) return null;
130
150
  const table = tomlTableBody(content, "features.multi_agent_v2");
131
- if (table === null) return null;
132
- const m = table.match(/^\s*max_concurrent_threads_per_session\s*=\s*(\d+)\s*(?:#.*)?$/m);
151
+ const features = tomlTableBody(content, "features");
152
+ const inline = features?.match(/^\s*multi_agent_v2\s*=\s*\{([^}]*)\}/m);
153
+ const m = table?.match(/^\s*max_concurrent_threads_per_session\s*=\s*(\d+)\s*(?:#.*)?$/m)
154
+ ?? inline?.[1].match(/(?:^|,)\s*max_concurrent_threads_per_session\s*=\s*(\d+)\s*(?:,|$)/);
133
155
  if (!m) return null;
134
156
  const value = Number(m[1]);
135
157
  return Number.isFinite(value) && value >= 1 ? value : null;
@@ -137,14 +159,11 @@ export function getMaxConcurrentThreads(configPath?: string): number | null {
137
159
 
138
160
  /**
139
161
  * Persist `features.multi_agent_v2.max_concurrent_threads_per_session = value`.
140
- * Scoped line edit inside the existing `[features.multi_agent_v2]` table only:
141
- * replaces the key line when present, else inserts it right under the header.
142
- * Refuses (returns an error string) when the table is missing creating it next
143
- * to a boolean-form `multi_agent_v2 = true` would be a TOML key conflict, and the
144
- * table is exactly what `codex features enable multi_agent_v2` materializes, so
145
- * "enable first" is the honest remedy. Idempotent: equal value -> no write.
162
+ * Scoped edit in either the dedicated table or `[features]` boolean/inline form.
163
+ * Boolean form is upgraded to an inline config so the numeric value remains
164
+ * attached to the feature without a TOML key conflict. Idempotent on equal value.
146
165
  */
147
- export function setMaxConcurrentThreads(value: number, configPath?: string): { ok: true; changed: boolean } | { ok: false; error: string } {
166
+ export function setMaxConcurrentThreads(value: number, configPath?: string, migratedComment?: string): { ok: true; changed: boolean } | { ok: false; error: string } {
148
167
  if (!Number.isInteger(value) || value < 1) {
149
168
  return { ok: false, error: "max_concurrent_threads_per_session must be an integer >= 1" };
150
169
  }
@@ -157,7 +176,33 @@ export function setMaxConcurrentThreads(value: number, configPath?: string): { o
157
176
  const headerRe = /^\s*\[features\.multi_agent_v2\]\s*(?:#.*)?$/;
158
177
  const headerIdx = lines.findIndex(l => headerRe.test(l));
159
178
  if (headerIdx === -1) {
160
- return { ok: false, error: "[features.multi_agent_v2] table not found — enable v2 first (ocx v2 on)" };
179
+ const featuresHeader = lines.findIndex(l => /^\s*\[features\]\s*(?:#.*)?$/.test(l));
180
+ if (featuresHeader === -1) return { ok: false, error: "multi_agent_v2 feature config not found — enable v2 first (ocx v2 on)" };
181
+ let featuresEnd = lines.length;
182
+ for (let i = featuresHeader + 1; i < lines.length; i++) {
183
+ if (/^\s*\[/.test(lines[i])) { featuresEnd = i; break; }
184
+ }
185
+ const boolRe = /^(\s*)multi_agent_v2\s*=\s*(true|false)(\s*#.*)?$/;
186
+ const inlineRe = /^(\s*)multi_agent_v2\s*=\s*\{([^}]*)\}(\s*#.*)?$/;
187
+ for (let i = featuresHeader + 1; i < featuresEnd; i++) {
188
+ const bool = lines[i].match(boolRe);
189
+ if (bool) {
190
+ lines[i] = `${bool[1]}multi_agent_v2 = { enabled = ${bool[2]}, max_concurrent_threads_per_session = ${value} }${mergeTrailingComments(bool[3], migratedComment)}`;
191
+ atomicWriteFile(path, applyEol(lines.join("\n"), eol));
192
+ return { ok: true, changed: true };
193
+ }
194
+ const inline = lines[i].match(inlineRe);
195
+ if (!inline) continue;
196
+ const existing = inline[2].match(/(?:^|,)\s*max_concurrent_threads_per_session\s*=\s*(\d+)\s*(?=,|$)/);
197
+ if (existing && Number(existing[1]) === value && (!migratedComment || migratedComment === inline[3])) return { ok: true, changed: false };
198
+ const body = existing
199
+ ? inline[2].replace(/(^|,)\s*max_concurrent_threads_per_session\s*=\s*\d+\s*(?=,|$)/, `$1 max_concurrent_threads_per_session = ${value}`)
200
+ : `${inline[2].trim()}${inline[2].trim() ? ", " : ""}max_concurrent_threads_per_session = ${value}`;
201
+ lines[i] = `${inline[1]}multi_agent_v2 = { ${body.trim()} }${mergeTrailingComments(inline[3], migratedComment)}`;
202
+ atomicWriteFile(path, applyEol(lines.join("\n"), eol));
203
+ return { ok: true, changed: true };
204
+ }
205
+ return { ok: false, error: "multi_agent_v2 feature config not found — enable v2 first (ocx v2 on)" };
161
206
  }
162
207
  let end = lines.length;
163
208
  for (let i = headerIdx + 1; i < lines.length; i++) {
@@ -167,12 +212,239 @@ export function setMaxConcurrentThreads(value: number, configPath?: string): { o
167
212
  for (let i = headerIdx + 1; i < end; i++) {
168
213
  const m = lines[i].match(keyRe);
169
214
  if (!m) continue;
170
- if (Number(m[2]) === value) return { ok: true, changed: false };
171
- lines[i] = `${m[1]}max_concurrent_threads_per_session = ${value}${m[3] ?? ""}`;
215
+ if (Number(m[2]) === value && (!migratedComment || migratedComment === m[3])) return { ok: true, changed: false };
216
+ lines[i] = `${m[1]}max_concurrent_threads_per_session = ${value}${mergeTrailingComments(m[3], migratedComment)}`;
172
217
  atomicWriteFile(path, applyEol(lines.join("\n"), eol));
173
218
  return { ok: true, changed: true };
174
219
  }
175
- lines.splice(headerIdx + 1, 0, `max_concurrent_threads_per_session = ${value}`);
220
+ lines.splice(headerIdx + 1, 0, `max_concurrent_threads_per_session = ${value}${migratedComment ?? ""}`);
176
221
  atomicWriteFile(path, applyEol(lines.join("\n"), eol));
177
222
  return { ok: true, changed: true };
178
223
  }
224
+
225
+ type ConfigEditResult = { ok: true; changed: boolean } | { ok: false; error: string };
226
+
227
+ function editAgentsMaxThreads(value: number | null, configPath?: string, migratedComment?: string): ConfigEditResult {
228
+ const path = configPath ?? activeCodexConfigPath();
229
+ const content = readConfigText(path);
230
+ if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
231
+ const eol = dominantEol(content);
232
+ const lines = content.split(/\r?\n/);
233
+ const headerIdx = lines.findIndex(l => /^\s*\[agents\]\s*(?:#.*)?$/.test(l));
234
+ if (headerIdx === -1) {
235
+ if (value === null) return { ok: true, changed: false };
236
+ const separator = lines.length > 0 && lines[lines.length - 1] !== "" ? [""] : [];
237
+ lines.push(...separator, "[agents]", `max_threads = ${value}${migratedComment ?? ""}`);
238
+ atomicWriteFile(path, applyEol(lines.join("\n"), eol));
239
+ return { ok: true, changed: true };
240
+ }
241
+ let end = lines.length;
242
+ for (let i = headerIdx + 1; i < lines.length; i++) {
243
+ if (/^\s*\[/.test(lines[i])) { end = i; break; }
244
+ }
245
+ const keyRe = /^(\s*)max_threads\s*=\s*(\d+)(\s*#.*)?$/;
246
+ for (let i = headerIdx + 1; i < end; i++) {
247
+ const m = lines[i].match(keyRe);
248
+ if (!m) continue;
249
+ if (value === null) lines.splice(i, 1);
250
+ else if (Number(m[2]) === value && (!migratedComment || migratedComment === m[3])) return { ok: true, changed: false };
251
+ else lines[i] = `${m[1]}max_threads = ${value}${mergeTrailingComments(m[3], migratedComment)}`;
252
+ atomicWriteFile(path, applyEol(lines.join("\n"), eol));
253
+ return { ok: true, changed: true };
254
+ }
255
+ if (value === null) return { ok: true, changed: false };
256
+ lines.splice(headerIdx + 1, 0, `max_threads = ${value}${migratedComment ?? ""}`);
257
+ atomicWriteFile(path, applyEol(lines.join("\n"), eol));
258
+ return { ok: true, changed: true };
259
+ }
260
+
261
+ function removeMaxConcurrentThreads(configPath?: string): ConfigEditResult {
262
+ const path = configPath ?? activeCodexConfigPath();
263
+ const content = readConfigText(path);
264
+ if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
265
+ const eol = dominantEol(content);
266
+ const lines = content.split(/\r?\n/);
267
+ const headerIdx = lines.findIndex(l => /^\s*\[features\.multi_agent_v2\]\s*(?:#.*)?$/.test(l));
268
+ if (headerIdx !== -1) {
269
+ let end = lines.length;
270
+ for (let i = headerIdx + 1; i < lines.length; i++) {
271
+ if (/^\s*\[/.test(lines[i])) { end = i; break; }
272
+ }
273
+ const keyIdx = lines.findIndex((line, i) => i > headerIdx && i < end && /^\s*max_concurrent_threads_per_session\s*=/.test(line));
274
+ if (keyIdx !== -1) {
275
+ lines.splice(keyIdx, 1);
276
+ atomicWriteFile(path, applyEol(lines.join("\n"), eol));
277
+ return { ok: true, changed: true };
278
+ }
279
+ }
280
+ const featuresHeader = lines.findIndex(l => /^\s*\[features\]\s*(?:#.*)?$/.test(l));
281
+ if (featuresHeader === -1) return { ok: true, changed: false };
282
+ let featuresEnd = lines.length;
283
+ for (let i = featuresHeader + 1; i < lines.length; i++) {
284
+ if (/^\s*\[/.test(lines[i])) { featuresEnd = i; break; }
285
+ }
286
+ const inlineRe = /^(\s*)multi_agent_v2\s*=\s*\{([^}]*)\}(\s*#.*)?$/;
287
+ for (let i = featuresHeader + 1; i < featuresEnd; i++) {
288
+ const inline = lines[i].match(inlineRe);
289
+ if (!inline || !/(?:^|,)\s*max_concurrent_threads_per_session\s*=/.test(inline[2])) continue;
290
+ const body = inline[2]
291
+ .replace(/^\s*max_concurrent_threads_per_session\s*=\s*\d+\s*,?\s*/, "")
292
+ .replace(/,\s*max_concurrent_threads_per_session\s*=\s*\d+\s*(?=,|$)/, "")
293
+ .trim();
294
+ lines[i] = `${inline[1]}multi_agent_v2 = { ${body} }${inline[3] ?? ""}`;
295
+ atomicWriteFile(path, applyEol(lines.join("\n"), eol));
296
+ return { ok: true, changed: true };
297
+ }
298
+ return { ok: true, changed: false };
299
+ }
300
+
301
+ function ensureDisabledV2Config(value: number | null, configPath?: string, migratedComment?: string): ConfigEditResult {
302
+ const path = configPath ?? activeCodexConfigPath();
303
+ const content = readConfigText(path);
304
+ if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
305
+ if (tomlTableBody(content, "features.multi_agent_v2") !== null || tomlTableBody(content, "features")?.match(/^\s*multi_agent_v2\s*=/m)) {
306
+ if (value === null) return { ok: true, changed: false };
307
+ return setMaxConcurrentThreads(value, path, migratedComment);
308
+ }
309
+ const eol = dominantEol(content);
310
+ const suffix = content.endsWith("\n") || content.length === 0 ? "" : eol;
311
+ const table = `[features.multi_agent_v2]${eol}enabled = false${value === null ? "" : `${eol}max_concurrent_threads_per_session = ${value}${migratedComment ?? ""}`}${eol}`;
312
+ atomicWriteFile(path, `${content}${suffix}${content.length > 0 && !content.endsWith(`${eol}${eol}`) ? eol : ""}${table}`);
313
+ return { ok: true, changed: true };
314
+ }
315
+
316
+ /** Active logical concurrency value, falling back to the inactive storage. */
317
+ export function getLogicalMaxThreads(configPath?: string): number | null {
318
+ return isMultiAgentV2Enabled(configPath)
319
+ ? getMaxConcurrentThreads(configPath) ?? getAgentsMaxThreads(configPath)
320
+ : getAgentsMaxThreads(configPath) ?? getMaxConcurrentThreads(configPath);
321
+ }
322
+
323
+ function activeThreadComment(content: string, v2Enabled: boolean): string | undefined {
324
+ const legacy = tomlTableBody(content, "agents")?.match(/^\s*max_threads\s*=\s*\d+(\s*#.*)$/m)?.[1];
325
+ const dedicated = tomlTableBody(content, "features.multi_agent_v2")
326
+ ?.match(/^\s*max_concurrent_threads_per_session\s*=\s*\d+(\s*#.*)$/m)?.[1];
327
+ const features = tomlTableBody(content, "features");
328
+ const inlineLine = features?.match(/^\s*multi_agent_v2\s*=\s*\{([^}]*)\}(\s*#.*)$/m);
329
+ const inline = inlineLine && /(?:^|,)\s*max_concurrent_threads_per_session\s*=\s*\d+\s*(?:,|$)/.test(inlineLine[1])
330
+ ? inlineLine[2]
331
+ : undefined;
332
+ return v2Enabled ? dedicated ?? inline ?? legacy : legacy ?? dedicated ?? inline;
333
+ }
334
+
335
+ let migrationEditSeq = 0;
336
+ function applyConfigEditsAtomically(path: string, edit: (tempPath: string) => ConfigEditResult): ConfigEditResult {
337
+ const content = readConfigText(path);
338
+ if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
339
+ const tempPath = `${path}.ocx-migration.${process.pid}.${++migrationEditSeq}`;
340
+ try {
341
+ atomicWriteFile(tempPath, content);
342
+ const result = edit(tempPath);
343
+ if (!result.ok) return result;
344
+ const edited = readConfigText(tempPath);
345
+ if (edited === null) return { ok: false, error: "temporary config migration output is unreadable" };
346
+ if (edited === content) return { ok: true, changed: false };
347
+ atomicWriteFile(path, edited);
348
+ return { ok: true, changed: true };
349
+ } finally {
350
+ try { unlinkSync(tempPath); } catch { /* already absent */ }
351
+ }
352
+ }
353
+
354
+ export type MultiAgentV2TransitionResult =
355
+ | { ok: true; changed: boolean; threadLimit: number | null }
356
+ | { ok: false; error: string };
357
+
358
+ function transitionConfigError(content: string): string | null {
359
+ if (/^\s*(?:features\.multi_agent_v2(?:\.[A-Za-z0-9_]+)?|agents\.max_threads)\s*=/m.test(content)) {
360
+ return "dotted multi-agent config keys are not supported for automatic migration";
361
+ }
362
+ const dedicatedTables = content.match(/^\s*\[features\.multi_agent_v2\]\s*(?:#.*)?$/gm) ?? [];
363
+ const featuresTables = content.match(/^\s*\[features\]\s*(?:#.*)?$/gm) ?? [];
364
+ const agentsTables = content.match(/^\s*\[agents\]\s*(?:#.*)?$/gm) ?? [];
365
+ if (dedicatedTables.length > 1 || featuresTables.length > 1 || agentsTables.length > 1) {
366
+ return "duplicate multi-agent TOML tables cannot be migrated safely";
367
+ }
368
+ const features = tomlTableBody(content, "features");
369
+ const featureDefs = features?.match(/^\s*multi_agent_v2\s*=/gm) ?? [];
370
+ if (featureDefs.length > 1 || (dedicatedTables.length === 1 && featureDefs.length === 1)) {
371
+ return "duplicate multi_agent_v2 definitions cannot be migrated safely";
372
+ }
373
+ if (features && /^\s*multi_agent_v2\.(?:enabled|max_concurrent_threads_per_session)\s*=/m.test(features)) {
374
+ return "dotted multi_agent_v2 fields are not supported for automatic migration";
375
+ }
376
+ const agents = tomlTableBody(content, "agents");
377
+ if ((agents?.match(/^\s*max_threads\s*=/gm) ?? []).length > 1) {
378
+ return "duplicate agents.max_threads definitions cannot be migrated safely";
379
+ }
380
+ const dedicated = tomlTableBody(content, "features.multi_agent_v2");
381
+ if ((dedicated?.match(/^\s*max_concurrent_threads_per_session\s*=/gm) ?? []).length > 1) {
382
+ return "duplicate v2 thread-limit definitions cannot be migrated safely";
383
+ }
384
+ return null;
385
+ }
386
+
387
+ /**
388
+ * Toggle native multi_agent_v2 while moving the active thread limit to the key
389
+ * valid for the destination version. Any failed command/postcondition restores
390
+ * the exact original config bytes.
391
+ */
392
+ export function transitionMultiAgentV2(
393
+ enabled: boolean,
394
+ toggleFeature: (enabled: boolean) => void,
395
+ options: { configPath?: string; threadLimit?: number } = {},
396
+ ): MultiAgentV2TransitionResult {
397
+ if (options.threadLimit !== undefined && (!Number.isInteger(options.threadLimit) || options.threadLimit < 1)) {
398
+ return { ok: false, error: "thread limit must be an integer >= 1" };
399
+ }
400
+ const path = options.configPath ?? activeCodexConfigPath();
401
+ const original = readConfigText(path);
402
+ if (original === null) return { ok: false, error: `config.toml not readable at ${path}` };
403
+ const preflightError = transitionConfigError(original);
404
+ if (preflightError) return { ok: false, error: preflightError };
405
+ const beforeEnabled = isMultiAgentV2Enabled(path);
406
+ const threadLimit = options.threadLimit ?? getLogicalMaxThreads(path);
407
+ const migratedComment = activeThreadComment(original, beforeEnabled);
408
+ try {
409
+ if (enabled) {
410
+ if (!beforeEnabled) {
411
+ const staged = applyConfigEditsAtomically(path, tempPath => {
412
+ const v2 = ensureDisabledV2Config(threadLimit, tempPath, migratedComment);
413
+ if (!v2.ok) return v2;
414
+ return editAgentsMaxThreads(null, tempPath);
415
+ });
416
+ if (!staged.ok) throw new Error(staged.error);
417
+ toggleFeature(true);
418
+ }
419
+ if (!isMultiAgentV2Enabled(path)) throw new Error("codex feature command did not enable multi_agent_v2");
420
+ const target = applyConfigEditsAtomically(path, tempPath => {
421
+ const v2 = threadLimit === null
422
+ ? removeMaxConcurrentThreads(tempPath)
423
+ : setMaxConcurrentThreads(threadLimit, tempPath, migratedComment);
424
+ if (!v2.ok) return v2;
425
+ return editAgentsMaxThreads(null, tempPath);
426
+ });
427
+ if (!target.ok) throw new Error(target.error);
428
+ if (hasAgentsMaxThreads(path) || getMaxConcurrentThreads(path) !== threadLimit) throw new Error("v2 thread-limit migration postcondition failed");
429
+ } else {
430
+ if (beforeEnabled) toggleFeature(false);
431
+ if (isMultiAgentV2Enabled(path)) throw new Error("codex feature command did not disable multi_agent_v2");
432
+ const target = applyConfigEditsAtomically(path, tempPath => {
433
+ const v2 = removeMaxConcurrentThreads(tempPath);
434
+ if (!v2.ok) return v2;
435
+ return editAgentsMaxThreads(threadLimit, tempPath, migratedComment);
436
+ });
437
+ if (!target.ok) throw new Error(target.error);
438
+ if (getMaxConcurrentThreads(path) !== null || getAgentsMaxThreads(path) !== threadLimit) throw new Error("v1 thread-limit migration postcondition failed");
439
+ }
440
+ return { ok: true, changed: readConfigText(path) !== original, threadLimit };
441
+ } catch (err) {
442
+ const message = err instanceof Error ? err.message : String(err);
443
+ try {
444
+ atomicWriteFile(path, original);
445
+ return { ok: false, error: message };
446
+ } catch (rollbackErr) {
447
+ return { ok: false, error: `${message}; rollback failed: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}` };
448
+ }
449
+ }
450
+ }
@@ -156,10 +156,20 @@ const BENIGN_LOG_INTERVAL_MS = 5 * 60_000;
156
156
  * cancellation cannot fully close the runtime-internal window. Treat this exact shape as benign:
157
157
  * keep the process alive, drop the alarmist banner, and fold repeats into a rate-limited summary so
158
158
  * crash.log stays readable for genuinely novel faults.
159
+ *
160
+ * Second known shape (260712): `TypeError: Invalid state: ReadableStream is locked`
161
+ * (code ERR_INVALID_STATE, native-only stack ending in onSinkClose*). When a client
162
+ * disconnects mid-SSE on the tee()'d passthrough path (responses.ts Bun#32111
163
+ * workaround), Bun's sink-close teardown tries to cancel the tee-locked source body
164
+ * and rejects off-path. Request lifecycle is already settled at that point; same
165
+ * benign handling applies.
159
166
  */
160
167
  export function isBenignAbortTeardown(err: unknown): boolean {
161
168
  if (!(err instanceof TypeError)) return false;
162
- if (err.message !== "null is not an object") return false; // bare form only (no `(evaluating …)`)
169
+ const bareNullTeardown = err.message === "null is not an object"; // bare form only (no `(evaluating …)`)
170
+ const lockedStreamTeardown = err.message === "Invalid state: ReadableStream is locked"
171
+ && (err as { code?: unknown }).code === "ERR_INVALID_STATE";
172
+ if (!bareNullTeardown && !lockedStreamTeardown) return false;
163
173
  const stack = err.stack ?? "";
164
174
  // Native-only: no JS source frame. A real app TypeError would carry a `(file:line:col)` frame.
165
175
  return !/\((?!native:)[^)]*:\d+:\d+\)/.test(stack);