@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.
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/gui/dist/assets/index-BcaDQD3i.js +40 -0
- package/gui/dist/assets/index-Cq8maiJf.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +11 -9
- package/src/adapters/cursor/exec-policy.ts +38 -0
- package/src/adapters/cursor/live-transport.ts +4 -3
- package/src/adapters/cursor/protobuf-request.ts +20 -0
- package/src/adapters/cursor/transport.ts +5 -0
- package/src/adapters/cursor.ts +2 -2
- package/src/bridge.ts +4 -2
- package/src/claude/agents-inject.ts +198 -0
- package/src/claude/alias.ts +69 -0
- package/src/claude/context-windows.ts +189 -0
- package/src/claude/desktop-3p.ts +254 -0
- package/src/claude/gateway-cache.ts +70 -0
- package/src/claude/inbound-debug.ts +114 -0
- package/src/claude/inbound.ts +481 -0
- package/src/claude/model-info.ts +145 -0
- package/src/claude/outbound.ts +487 -0
- package/src/cli/claude.ts +157 -0
- package/src/cli/help.ts +12 -0
- package/src/cli/index.ts +86 -7
- package/src/cli/v2.ts +23 -18
- package/src/codex/features.ts +288 -16
- package/src/lib/crash-guard.ts +11 -1
- package/src/lib/debug-settings.ts +14 -2
- package/src/lib/token-estimate.ts +27 -1
- package/src/providers/registry.ts +1 -1
- package/src/server/auth-cors.ts +4 -2
- package/src/server/claude-messages.ts +494 -0
- package/src/server/index.ts +72 -0
- package/src/server/management-api.ts +226 -34
- package/src/server/request-log.ts +19 -4
- package/src/server/responses.ts +13 -1
- package/src/server/system-env.ts +314 -0
- package/src/types.ts +108 -0
- package/src/usage/log.ts +8 -2
- package/src/usage/summary.ts +18 -1
- package/src/usage/totals.ts +7 -18
- package/gui/dist/assets/index-Bp8dDrs5.js +0 -40
- 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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
* -
|
|
10
|
-
*
|
|
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 {
|
|
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 =
|
|
59
|
-
log.log(`
|
|
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
|
|
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
|
-
? `
|
|
75
|
-
: `
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 (
|
|
116
|
-
log.log(
|
|
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
|
package/src/codex/features.ts
CHANGED
|
@@ -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`,
|
|
125
|
-
* the
|
|
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
|
-
|
|
132
|
-
const
|
|
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
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/crash-guard.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|