@bitkyc08/opencodex 2.81.0 → 2.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-BqHF5Trd.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-D3FBCNiP.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DTBC8n7-.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  11. package/src/adapters/openai-responses/passthrough.ts +9 -4
  12. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  13. package/src/claude/agents-inject.ts +14 -3
  14. package/src/claude/context-windows.ts +19 -17
  15. package/src/claude/desktop-3p.ts +88 -12
  16. package/src/claude/desktop-profile.ts +2 -1
  17. package/src/claude/gateway-cache.ts +18 -1
  18. package/src/claude/inbound.ts +77 -3
  19. package/src/claude/intercept/cli-catalog.ts +3 -0
  20. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  21. package/src/claude/intercept/picker-models.ts +14 -6
  22. package/src/claude/intercept/settings.ts +3 -0
  23. package/src/claude/long-context.ts +66 -0
  24. package/src/claude/model-info.ts +19 -16
  25. package/src/claude/outbound.ts +94 -9
  26. package/src/claude/subagent-model.ts +40 -11
  27. package/src/cli/agent.ts +9 -5
  28. package/src/cli/aside-profile-recovery.ts +33 -0
  29. package/src/cli/capabilities-base.ts +5 -5
  30. package/src/cli/capabilities-integrations.ts +2 -2
  31. package/src/cli/cli-path-diagnostics.ts +132 -0
  32. package/src/cli/desktop-runtime-guidance.ts +65 -0
  33. package/src/cli/dispatch.ts +5 -0
  34. package/src/cli/doctor.ts +16 -3
  35. package/src/cli/index.ts +14 -9
  36. package/src/cli/integration-aside-sync.ts +2 -1
  37. package/src/cli/integrations.ts +16 -3
  38. package/src/cli/resolve.ts +25 -2
  39. package/src/cli/root.ts +4 -2
  40. package/src/cli/status.ts +64 -12
  41. package/src/cli/update-restart-child.ts +4 -2
  42. package/src/cli/update-restart-home.ts +28 -10
  43. package/src/cli/update-restart-service-record.ts +116 -0
  44. package/src/cli/update-restart-supervision.ts +96 -0
  45. package/src/cli/update-restart.ts +59 -13
  46. package/src/cli/v2.ts +27 -20
  47. package/src/cli/version-skew-notice.ts +77 -0
  48. package/src/clients/lazycodex.ts +1 -1
  49. package/src/clients/omo-role-models.ts +32 -7
  50. package/src/codex/auth-api/account-selection.ts +107 -0
  51. package/src/codex/auth-api/routes.ts +10 -49
  52. package/src/codex/auth-context.ts +20 -1
  53. package/src/codex/autostart-health.ts +12 -3
  54. package/src/codex/catalog/build-entries.ts +18 -0
  55. package/src/codex/catalog/bundled.ts +155 -26
  56. package/src/codex/catalog/combo-member.ts +3 -2
  57. package/src/codex/catalog/effort.ts +26 -5
  58. package/src/codex/catalog/gather-capture.ts +5 -0
  59. package/src/codex/catalog/model-hints.ts +15 -8
  60. package/src/codex/catalog/model-visibility.ts +3 -1
  61. package/src/codex/catalog/operator-tiers.ts +38 -0
  62. package/src/codex/catalog/provider-models.ts +43 -35
  63. package/src/codex/catalog/retained-sync.ts +7 -5
  64. package/src/codex/catalog/routed-gather.ts +15 -1
  65. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  66. package/src/codex/catalog-auto-refresh.ts +19 -0
  67. package/src/codex/catalog.ts +1 -1
  68. package/src/codex/codex-home-owner.ts +3 -0
  69. package/src/codex/config-write-lock.ts +333 -0
  70. package/src/codex/convergence.ts +2 -1
  71. package/src/codex/features.ts +257 -200
  72. package/src/codex/inject/config-write-section.ts +34 -0
  73. package/src/codex/inject/multi-agent-v2.ts +14 -6
  74. package/src/codex/inject/remove.ts +36 -11
  75. package/src/codex/inject/restore.ts +85 -20
  76. package/src/codex/inject-coordination.ts +15 -6
  77. package/src/codex/inject.ts +62 -20
  78. package/src/codex/journal.ts +62 -17
  79. package/src/codex/main-account-cache.ts +43 -0
  80. package/src/codex/paths.ts +2 -0
  81. package/src/codex/prepared-home.ts +18 -0
  82. package/src/codex/prompt-journal.ts +25 -12
  83. package/src/codex/prompt-layers.ts +49 -26
  84. package/src/codex/prompt-lock-claim.ts +134 -0
  85. package/src/codex/prompt-lock-evidence.ts +26 -0
  86. package/src/codex/prompt-lock-io.ts +16 -0
  87. package/src/codex/prompt-lock-owner.ts +130 -0
  88. package/src/codex/prompt-lock.ts +84 -53
  89. package/src/codex/routing/failure-window.ts +146 -0
  90. package/src/codex/routing/selection.ts +4 -1
  91. package/src/codex/routing.ts +13 -17
  92. package/src/codex/runtime.ts +106 -18
  93. package/src/codex/shim.ts +45 -19
  94. package/src/combos/jev-service-exchange.ts +281 -0
  95. package/src/combos/jev.ts +34 -220
  96. package/src/config/atomic-write.ts +1 -1
  97. package/src/config/codex-ws-reuse-setting.ts +10 -0
  98. package/src/config/live-reconcile.ts +17 -0
  99. package/src/config/load-degrade.ts +8 -0
  100. package/src/config/rebase-provenance.ts +11 -0
  101. package/src/config/schema/config-schema.ts +4 -0
  102. package/src/generated/compatibility-version.json +303 -171
  103. package/src/generated/model-metadata.ts +2 -2
  104. package/src/grok/inject.ts +50 -4
  105. package/src/grok/sync.ts +2 -1
  106. package/src/images/loop.ts +29 -11
  107. package/src/integrations/aside-profiles.ts +10 -3
  108. package/src/integrations/catalog-refresh.ts +18 -2
  109. package/src/integrations/writer.ts +9 -3
  110. package/src/lib/bun-path-runtime.d.mts +32 -0
  111. package/src/lib/bun-path-runtime.mjs +95 -0
  112. package/src/lib/bun-runtime-preflight.ts +140 -0
  113. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  114. package/src/lib/desktop-cli-handoff.mjs +92 -0
  115. package/src/lib/desktop-cli-record.d.mts +29 -0
  116. package/src/lib/desktop-cli-record.mjs +102 -0
  117. package/src/lib/request-execution-budget.ts +8 -0
  118. package/src/lib/synced-state-location.ts +46 -6
  119. package/src/lib/test-home-guard.ts +87 -1
  120. package/src/lib/translator-budget.ts +17 -4
  121. package/src/lib/windows-atomic-replace.ts +38 -16
  122. package/src/lib/windows-elevation.ts +6 -0
  123. package/src/lib/windows-secret-acl.ts +92 -25
  124. package/src/lib/winsw.ts +2 -3
  125. package/src/oauth/anthropic-account-refusal.ts +39 -1
  126. package/src/oauth/anthropic-routing.ts +1 -1
  127. package/src/oauth/anthropic-send-ownership.ts +3 -3
  128. package/src/oauth/chatgpt.ts +113 -41
  129. package/src/oauth/index.ts +6 -2
  130. package/src/oauth/store.ts +1 -1
  131. package/src/providers/azure-model-metadata.ts +115 -0
  132. package/src/providers/derive.ts +21 -0
  133. package/src/providers/quota/vendor-probes-key.ts +127 -17
  134. package/src/providers/quota.ts +1 -1
  135. package/src/responses/reasoning-envelope.ts +29 -1
  136. package/src/responses/reasoning-replay-cache.ts +17 -1
  137. package/src/responses/spill-store.ts +15 -2
  138. package/src/responses/state/metrics.ts +8 -0
  139. package/src/responses/state/spill-failure.ts +12 -0
  140. package/src/responses/state/spill-queue.ts +12 -5
  141. package/src/responses/state.ts +18 -10
  142. package/src/responses/task-input.ts +17 -0
  143. package/src/server/background-lifecycle.ts +27 -0
  144. package/src/server/chat-completions.ts +1 -0
  145. package/src/server/chat-native.ts +28 -12
  146. package/src/server/claude-messages.ts +43 -6
  147. package/src/server/inbound-body-admission.ts +1 -0
  148. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  149. package/src/server/index/serve-options.ts +10 -2
  150. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  151. package/src/server/index/startup-warnings.ts +19 -0
  152. package/src/server/index.ts +1 -0
  153. package/src/server/management/agent-settings-routes.ts +93 -32
  154. package/src/server/management/codex-agent-role-routes.ts +4 -2
  155. package/src/server/management/codex-prompt-routes.ts +1 -0
  156. package/src/server/management/codex-role-auto-assign.ts +13 -2
  157. package/src/server/management/config-routes.ts +59 -8
  158. package/src/server/management/context.ts +2 -2
  159. package/src/server/management/shared.ts +3 -3
  160. package/src/server/messages-native-oauth.ts +9 -8
  161. package/src/server/messages-native.ts +49 -13
  162. package/src/server/messages-response-headers.ts +14 -0
  163. package/src/server/request-log-conversation.ts +48 -5
  164. package/src/server/request-log.ts +31 -2
  165. package/src/server/responses/adapter-continuation.ts +2 -2
  166. package/src/server/responses/adapter-dispatch.ts +34 -13
  167. package/src/server/responses/codex-ws-exchange.ts +17 -2
  168. package/src/server/responses/codex-ws-pool.ts +100 -13
  169. package/src/server/responses/codex-ws-wire.ts +9 -0
  170. package/src/server/responses/combo-requested-effort.ts +3 -1
  171. package/src/server/responses/core-codex-account.ts +22 -1
  172. package/src/server/responses/core-combo-native.ts +20 -3
  173. package/src/server/responses/core-combo.ts +22 -14
  174. package/src/server/responses/core-lifetime.ts +3 -36
  175. package/src/server/responses/core-options.ts +5 -0
  176. package/src/server/responses/core-replay.ts +73 -0
  177. package/src/server/responses/core.ts +3 -3
  178. package/src/server/responses/fetch-helpers.ts +26 -3
  179. package/src/server/responses/passthrough-delivery.ts +24 -2
  180. package/src/server/responses/passthrough-dispatch.ts +19 -2
  181. package/src/server/responses/request-prepare.ts +4 -0
  182. package/src/server/responses/request-send-budget.ts +112 -49
  183. package/src/server/responses/response-effects.ts +1 -0
  184. package/src/server/responses/run-turn-execution.ts +2 -0
  185. package/src/server/responses/sidecar-execution.ts +25 -16
  186. package/src/server/responses/sidecar-send-budget.ts +58 -0
  187. package/src/server/responses/ws-upstream.ts +19 -6
  188. package/src/server/startup-health-cache.ts +2 -1
  189. package/src/server/workflow-refusal.ts +3 -0
  190. package/src/service/cli.ts +20 -4
  191. package/src/service/desktop-command-guard.ts +32 -0
  192. package/src/service/desktop-startup.ts +66 -40
  193. package/src/service/desktop-supervision.d.mts +26 -0
  194. package/src/service/desktop-supervision.mjs +140 -0
  195. package/src/service/managing-cli.ts +1 -0
  196. package/src/service/orchestration.ts +46 -20
  197. package/src/service/repair.ts +25 -9
  198. package/src/service/state.ts +4 -3
  199. package/src/service/systemd.ts +17 -8
  200. package/src/service/windows-ops.ts +61 -22
  201. package/src/storage/policy-job.ts +5 -1
  202. package/src/storage/policy-worker.ts +3 -1
  203. package/src/storage/policy.ts +9 -0
  204. package/src/types/config.ts +21 -0
  205. package/src/types/request.ts +5 -0
  206. package/src/types/wire.ts +1 -0
  207. package/src/update/index.ts +57 -9
  208. package/src/update/restart-ownership.ts +10 -3
  209. package/src/update/runtime-ownership.d.mts +6 -0
  210. package/src/update/runtime-ownership.mjs +23 -7
  211. package/src/web-search/loop.ts +5 -2
  212. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -20,11 +20,12 @@
20
20
  * Used by the catalog v2-gated-ultra policy (devlog/260709_v2_gated_ultra) and the
21
21
  * `ocx v2` toggle surface. The FLAG itself is never written here — toggling goes
22
22
  * through the official `codex features enable|disable` CLI (format-preserving).
23
- * The one write this module owns is the numeric
24
- * `features.multi_agent_v2.max_concurrent_threads_per_session` scalar
25
- * (setMaxConcurrentThreads): the codex CLI has no persisted setter for nested
26
- * feature config (`-c` is per-invocation only), so ocx does a scoped,
27
- * EOL-preserving line edit — same practice as codex/inject.ts.
23
+ * The writes this module owns are the scoped scalars —
24
+ * `features.multi_agent_v2.max_concurrent_threads_per_session` and the
25
+ * `[agents]`/`[features]` fields the management API exposes: the codex CLI has
26
+ * no persisted setter for nested feature config (`-c` is per-invocation only),
27
+ * so ocx does scoped, EOL-preserving line edits — each one a read → edit →
28
+ * rename under the shared config write lock (see config-write-lock.ts).
28
29
  *
29
30
  * CODEX_HOME is resolved at CALL time (activeCodexConfigPath pattern, mirrors
30
31
  * catalog.ts:40-54) so tests can point fixtures via env or the explicit
@@ -34,9 +35,12 @@ import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
34
35
  import { delimiter, dirname, join, resolve } from "node:path";
35
36
  import { createRequire } from "node:module";
36
37
  import { realpathSync } from "node:fs";
37
- import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWriteFile, expandUserPath, getConfigDir } from "../config";
38
+ import { homedir } from "node:os";
39
+ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWriteFile, getConfigDir } from "../config";
40
+ import { ConfigWriteDestinationChanged, assertNativeConfigWriteDestination, runConfigWriteChild, withConfigWriteRecovery, publishConfigWrite, configWriteLockFailureMessage, withConfigWriteLock, withConfigWriteLockHeld, type LockHandle } from "./config-write-lock";
41
+ import { codexHomeIsAbsent } from "./codex-home-owner";
38
42
  import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl";
39
- import { CODEX_CONFIG_PATH } from "./paths";
43
+ import { resolveCodexHomeDir } from "./home";
40
44
  import { resolveAndPersistCodexRuntime } from "./runtime";
41
45
  import { decodeOverlayState } from "./shim-state-file";
42
46
  import { canonicalizeOpenCodexModeHint } from "./multi-agent-mode-policy";
@@ -67,14 +71,14 @@ function mergeTrailingComments(existing?: string, migrated?: string): string {
67
71
  }
68
72
 
69
73
  export function activeCodexConfigPath(): string {
70
- const raw = process.env.CODEX_HOME?.trim();
71
- if (!raw) return CODEX_CONFIG_PATH;
72
- const path = resolve(expandUserPath(raw));
73
- try {
74
- return join(realpathSync.native(path), "config.toml");
75
- } catch {
76
- return join(path, "config.toml");
77
- }
74
+ // Keep home aliases observable by the held destination validator.
75
+ const home = resolveCodexHomeDir();
76
+ if (!process.env.CODEX_HOME?.trim()) {
77
+ const alias = join(homedir(), ".codex");
78
+ try { if (realpathSync.native(alias) === home) return join(alias, "config.toml"); }
79
+ catch { /* An absent local home can select the WSL Windows-home fallback. */ }
80
+ }
81
+ return join(home, "config.toml");
78
82
  }
79
83
 
80
84
  function readConfigText(configPath?: string): string | null {
@@ -87,6 +91,46 @@ function readConfigText(configPath?: string): string | null {
87
91
  }
88
92
  }
89
93
 
94
+ /**
95
+ * One edit's answer: either the replacement content or a refusal. `next` equal
96
+ * to the input is a no-change result — the helper compares before writing, so
97
+ * an edit never has to spell "unchanged" differently.
98
+ */
99
+ type TomlEditOutcome = { next: string } | { error: string };
100
+
101
+ /**
102
+ * One read → edit → write pass over config.toml under the shared write lock.
103
+ *
104
+ * The edit callback sees bytes re-read INSIDE the lock, so it is always
105
+ * computed against what is actually on disk and no cooperating writer can land
106
+ * while the section runs. Previously every writer in this file read outside
107
+ * any lock and renamed over the whole file — a foreign rewrite landing between
108
+ * read and rename was silently discarded (a prompt-layer projection, another
109
+ * scalar edit, an injection's routing keys).
110
+ *
111
+ * A busy lock maps to the shared locked message: the sections under it are
112
+ * millisecond-scale, so refusing fast and letting the caller retry beats
113
+ * queuing behind whatever holds it.
114
+ */
115
+ function editCodexConfigToml(
116
+ path: string,
117
+ edit: (content: string) => TomlEditOutcome,
118
+ heldConfigWriteLock?: LockHandle,
119
+ ): ConfigEditResult {
120
+ if (codexHomeIsAbsent(dirname(path))) return { ok: false, error: `config.toml not readable at ${path}` };
121
+ const locked = withConfigWriteLockHeld(path, heldConfigWriteLock, (held): ConfigEditResult => {
122
+ const content = readConfigText(path);
123
+ if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
124
+ const outcome = edit(content);
125
+ if ("error" in outcome) return { ok: false, error: outcome.error };
126
+ if (outcome.next === content) return { ok: true, changed: false };
127
+ publishConfigWrite(path, held, (destination, hooks) => atomicWriteFile(destination, outcome.next, undefined, hooks));
128
+ return { ok: true, changed: true };
129
+ });
130
+ if (!locked.ok) return { ok: false, error: configWriteLockFailureMessage(locked), ...(locked.error === "unsafe" ? { retryable: false } : {}) };
131
+ return locked.value;
132
+ }
133
+
90
134
  /**
91
135
  * Body lines of a TOML table `[header]` up to (not including) the next table header.
92
136
  *
@@ -409,26 +453,22 @@ export function v2TotalLimitToV1ChildLimit(totalLimit: number): number {
409
453
  }
410
454
 
411
455
  /**
412
- * Persist `features.multi_agent_v2.max_concurrent_threads_per_session = value`.
413
- * Scoped edit in either the dedicated table or `[features]` boolean/inline form.
414
- * Boolean form is upgraded to an inline config so the numeric value remains
415
- * attached to the feature without a TOML key conflict. Idempotent on equal value.
456
+ * The content-level half of `setMaxConcurrentThreads`, reused by
457
+ * `ensureDisabledV2Config` so a nested edit never re-takes the write lock on
458
+ * the file its caller already holds.
416
459
  */
417
- export function setMaxConcurrentThreads(value: number, configPath?: string, migratedComment?: string): { ok: true; changed: boolean } | { ok: false; error: string } {
418
- if (!Number.isInteger(value) || value < 1) {
419
- return { ok: false, error: "max_concurrent_threads_per_session must be an integer >= 1" };
420
- }
421
- const path = configPath ?? activeCodexConfigPath();
422
- const content = readConfigText(path);
423
- if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
424
-
460
+ function maxConcurrentThreadsEdit(
461
+ content: string,
462
+ value: number,
463
+ migratedComment?: string,
464
+ ): TomlEditOutcome {
425
465
  const eol = dominantEol(content);
426
466
  const lines = content.split(/\r?\n/);
427
467
  const headerRe = /^\s*\[features\.multi_agent_v2\]\s*(?:#.*)?$/;
428
468
  const headerIdx = lines.findIndex(l => headerRe.test(l));
429
469
  if (headerIdx === -1) {
430
470
  const featuresHeader = lines.findIndex(l => /^\s*\[features\]\s*(?:#.*)?$/.test(l));
431
- if (featuresHeader === -1) return { ok: false, error: "multi_agent_v2 feature config not found — enable v2 first (ocx v2 on)" };
471
+ if (featuresHeader === -1) return { error: "multi_agent_v2 feature config not found — enable v2 first (ocx v2 on)" };
432
472
  let featuresEnd = lines.length;
433
473
  for (let i = featuresHeader + 1; i < lines.length; i++) {
434
474
  if (/^\s*\[/.test(lines[i])) { featuresEnd = i; break; }
@@ -439,21 +479,19 @@ export function setMaxConcurrentThreads(value: number, configPath?: string, migr
439
479
  const bool = lines[i].match(boolRe);
440
480
  if (bool) {
441
481
  lines[i] = `${bool[1]}multi_agent_v2 = { enabled = ${bool[2]}, max_concurrent_threads_per_session = ${value} }${mergeTrailingComments(bool[3], migratedComment)}`;
442
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
443
- return { ok: true, changed: true };
482
+ return { next: applyEol(lines.join("\n"), eol) };
444
483
  }
445
484
  const inline = lines[i].match(inlineRe);
446
485
  if (!inline) continue;
447
486
  const existing = inline[2].match(/(?:^|,)\s*max_concurrent_threads_per_session\s*=\s*(\d+)\s*(?=,|$)/);
448
- if (existing && Number(existing[1]) === value && (!migratedComment || migratedComment === inline[3])) return { ok: true, changed: false };
487
+ if (existing && Number(existing[1]) === value && (!migratedComment || migratedComment === inline[3])) return { next: content };
449
488
  const body = existing
450
489
  ? inline[2].replace(/(^|,)\s*max_concurrent_threads_per_session\s*=\s*\d+\s*(?=,|$)/, `$1 max_concurrent_threads_per_session = ${value}`)
451
490
  : `${inline[2].trim()}${inline[2].trim() ? ", " : ""}max_concurrent_threads_per_session = ${value}`;
452
491
  lines[i] = `${inline[1]}multi_agent_v2 = { ${body.trim()} }${mergeTrailingComments(inline[3], migratedComment)}`;
453
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
454
- return { ok: true, changed: true };
492
+ return { next: applyEol(lines.join("\n"), eol) };
455
493
  }
456
- return { ok: false, error: "multi_agent_v2 feature config not found — enable v2 first (ocx v2 on)" };
494
+ return { error: "multi_agent_v2 feature config not found — enable v2 first (ocx v2 on)" };
457
495
  }
458
496
  let end = lines.length;
459
497
  for (let i = headerIdx + 1; i < lines.length; i++) {
@@ -463,17 +501,29 @@ export function setMaxConcurrentThreads(value: number, configPath?: string, migr
463
501
  for (let i = headerIdx + 1; i < end; i++) {
464
502
  const m = lines[i].match(keyRe);
465
503
  if (!m) continue;
466
- if (Number(m[2]) === value && (!migratedComment || migratedComment === m[3])) return { ok: true, changed: false };
504
+ if (Number(m[2]) === value && (!migratedComment || migratedComment === m[3])) return { next: content };
467
505
  lines[i] = `${m[1]}max_concurrent_threads_per_session = ${value}${mergeTrailingComments(m[3], migratedComment)}`;
468
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
469
- return { ok: true, changed: true };
506
+ return { next: applyEol(lines.join("\n"), eol) };
470
507
  }
471
508
  lines.splice(headerIdx + 1, 0, `max_concurrent_threads_per_session = ${value}${migratedComment ?? ""}`);
472
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
473
- return { ok: true, changed: true };
509
+ return { next: applyEol(lines.join("\n"), eol) };
510
+ }
511
+
512
+ /**
513
+ * Persist `features.multi_agent_v2.max_concurrent_threads_per_session = value`.
514
+ * Scoped edit in either the dedicated table or `[features]` boolean/inline form.
515
+ * Boolean form is upgraded to an inline config so the numeric value remains
516
+ * attached to the feature without a TOML key conflict. Idempotent on equal value.
517
+ */
518
+ export function setMaxConcurrentThreads(value: number, configPath?: string, migratedComment?: string): { ok: true; changed: boolean } | { ok: false; error: string } {
519
+ if (!Number.isInteger(value) || value < 1) {
520
+ return { ok: false, error: "max_concurrent_threads_per_session must be an integer >= 1" };
521
+ }
522
+ const path = configPath ?? activeCodexConfigPath();
523
+ return editCodexConfigToml(path, content => maxConcurrentThreadsEdit(content, value, migratedComment));
474
524
  }
475
525
 
476
- type ConfigEditResult = { ok: true; changed: boolean } | { ok: false; error: string };
526
+ type ConfigEditResult = { ok: true; changed: boolean } | { ok: false; error: string; retryable?: boolean };
477
527
 
478
528
  /**
479
529
  * Encode a string as a TOML single-line basic string.
@@ -807,14 +857,11 @@ function editScalarInTable(content: string, table: string, key: string, encoded:
807
857
  }
808
858
 
809
859
  /** Persist `[agents] enabled = value`, or remove the key when `value` is null. */
810
- export function setAgentsEnabled(value: boolean | null, configPath?: string): ConfigEditResult {
860
+ export function setAgentsEnabled(value: boolean | null, configPath?: string, heldConfigWriteLock?: LockHandle): ConfigEditResult {
811
861
  const path = configPath ?? activeCodexConfigPath();
812
- const content = readConfigText(path);
813
- if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
814
- const next = editScalarInTable(content, "agents", "enabled", value === null ? null : String(value));
815
- if (next === content) return { ok: true, changed: false };
816
- atomicWriteFile(path, next);
817
- return { ok: true, changed: true };
862
+ return editCodexConfigToml(path, content => ({
863
+ next: editScalarInTable(content, "agents", "enabled", value === null ? null : String(value)),
864
+ }), heldConfigWriteLock);
818
865
  }
819
866
 
820
867
  /**
@@ -824,17 +871,14 @@ export function setAgentsEnabled(value: boolean | null, configPath?: string): Co
824
871
  * produce a config upstream cannot deserialize, a hard parse failure for the
825
872
  * user's Codex.
826
873
  */
827
- export function setAgentsMaxDepth(value: number | null, configPath?: string): ConfigEditResult {
874
+ export function setAgentsMaxDepth(value: number | null, configPath?: string, heldConfigWriteLock?: LockHandle): ConfigEditResult {
828
875
  if (value !== null && (!Number.isInteger(value) || value < -2_147_483_648 || value > 2_147_483_647)) {
829
876
  return { ok: false, error: "max_depth must be an integer within signed i32 range" };
830
877
  }
831
878
  const path = configPath ?? activeCodexConfigPath();
832
- const content = readConfigText(path);
833
- if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
834
- const next = editScalarInTable(content, "agents", "max_depth", value === null ? null : String(value));
835
- if (next === content) return { ok: true, changed: false };
836
- atomicWriteFile(path, next);
837
- return { ok: true, changed: true };
879
+ return editCodexConfigToml(path, content => ({
880
+ next: editScalarInTable(content, "agents", "max_depth", value === null ? null : String(value)),
881
+ }), heldConfigWriteLock);
838
882
  }
839
883
 
840
884
  /**
@@ -950,11 +994,10 @@ export function getMultiAgentModeHintText(configPath?: string): string | null {
950
994
  * carries `#[serde(deny_unknown_fields)]`, so a misspelling is not ignored — it is
951
995
  * a hard config-parse failure for the user's Codex.
952
996
  */
953
- function setV2StringField(key: string, value: string | null, configPath?: string): ConfigEditResult {
997
+ function setV2StringField(key: string, value: string | null, configPath?: string, heldConfigWriteLock?: LockHandle): ConfigEditResult {
954
998
  const path = configPath ?? activeCodexConfigPath();
955
- const content = readConfigText(path);
956
- if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
957
999
  const encoded = value === null ? null : encodeTomlBasicString(value);
1000
+ return editCodexConfigToml(path, (content): TomlEditOutcome => {
958
1001
 
959
1002
  // A parsed V2 object without one of the supported source forms came from
960
1003
  // dotted/quoted path segments. Appending a dedicated table would redefine it
@@ -966,7 +1009,7 @@ function setV2StringField(key: string, value: string | null, configPath?: string
966
1009
  const featuresV2Entry = featuresBody === null ? null : findTomlAssignment(featuresBody, "multi_agent_v2");
967
1010
  const supportedFeaturesEntry = featuresV2Entry !== null;
968
1011
  if (parsedV2 !== null && !dedicatedV2 && !supportedFeaturesEntry) {
969
- return { ok: false, error: "dotted or quoted multi_agent_v2 config is not supported for managed string fields" };
1012
+ return { error: "dotted or quoted multi_agent_v2 config is not supported for managed string fields" };
970
1013
  }
971
1014
 
972
1015
  const dedicatedStringBody = tomlTableBodyForStringFields(content, "features.multi_agent_v2");
@@ -977,20 +1020,17 @@ function setV2StringField(key: string, value: string | null, configPath?: string
977
1020
  // table carrying the same key does not block an otherwise safe edit.
978
1021
  const dedicatedEntry = findTomlAssignment(dedicatedStringBody, key);
979
1022
  if (dedicatedEntry !== null && isMultilineTomlString(dedicatedStringBody, dedicatedEntry.valueStart)) {
980
- return { ok: false, error: `multi-line TOML string for ${key} is not editable; convert it to a single-line string first` };
1023
+ return { error: `multi-line TOML string for ${key} is not editable; convert it to a single-line string first` };
981
1024
  }
982
1025
  const legacyDedicatedBody = tomlTableBody(content, "features.multi_agent_v2") ?? "";
983
1026
  if (dedicatedEntry !== null && findTomlAssignment(legacyDedicatedBody, key) === null) {
984
- return { ok: false, error: `cannot edit ${key} after a header-shaped multiline value safely` };
1027
+ return { error: `cannot edit ${key} after a header-shaped multiline value safely` };
985
1028
  }
986
- const next = editScalarInTable(content, "features.multi_agent_v2", key, encoded);
987
- if (next === content) return { ok: true, changed: false };
988
- atomicWriteFile(path, next);
989
- return { ok: true, changed: true };
1029
+ return { next: editScalarInTable(content, "features.multi_agent_v2", key, encoded) };
990
1030
  }
991
1031
 
992
1032
  if (featuresBody !== null && featuresV2Entry !== null && hasInlineMultilineTomlString(featuresBody, featuresV2Entry, key)) {
993
- return { ok: false, error: `multi-line TOML string for ${key} is not editable; convert it to a single-line string first` };
1033
+ return { error: `multi-line TOML string for ${key} is not editable; convert it to a single-line string first` };
994
1034
  }
995
1035
 
996
1036
  const eol = dominantEol(content);
@@ -1007,10 +1047,10 @@ function setV2StringField(key: string, value: string | null, configPath?: string
1007
1047
  if (inlineMatch) {
1008
1048
  const openIdx = inlineMatch[0].length - 1;
1009
1049
  const closeIdx = findInlineTableEnd(line, openIdx);
1010
- if (closeIdx === -1) return { ok: false, error: "malformed multi_agent_v2 inline table" };
1050
+ if (closeIdx === -1) return { error: "malformed multi_agent_v2 inline table" };
1011
1051
  const entry = findInlineEntry(line, openIdx + 1, closeIdx, key);
1012
1052
  if (encoded === null) {
1013
- if (!entry) return { ok: true, changed: false };
1053
+ if (!entry) return { next: content };
1014
1054
  let start = entry.keyStart;
1015
1055
  let stop = entry.valueEnd;
1016
1056
  let j = stop;
@@ -1024,11 +1064,10 @@ function setV2StringField(key: string, value: string | null, configPath?: string
1024
1064
  if (line[k - 1] === ",") start = k - 1;
1025
1065
  }
1026
1066
  lines[i] = line.slice(0, start) + line.slice(stop);
1027
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
1028
- return { ok: true, changed: true };
1067
+ return { next: applyEol(lines.join("\n"), eol) };
1029
1068
  }
1030
1069
  if (entry) {
1031
- if (line.slice(entry.valueStart, entry.valueEnd).trim() === encoded) return { ok: true, changed: false };
1070
+ if (line.slice(entry.valueStart, entry.valueEnd).trim() === encoded) return { next: content };
1032
1071
  lines[i] = line.slice(0, entry.valueStart) + encoded + line.slice(entry.valueEnd);
1033
1072
  } else {
1034
1073
  let insertPos = closeIdx;
@@ -1039,32 +1078,30 @@ function setV2StringField(key: string, value: string | null, configPath?: string
1039
1078
  : ` ${key} = ${encoded} `;
1040
1079
  lines[i] = line.slice(0, insertPos) + insertion + line.slice(closeIdx);
1041
1080
  }
1042
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
1043
- return { ok: true, changed: true };
1081
+ return { next: applyEol(lines.join("\n"), eol) };
1044
1082
  }
1045
1083
  const boolMatch = line.match(/^(\s*)multi_agent_v2\s*=\s*(true|false)(\s*(?:#.*)?)$/);
1046
1084
  if (boolMatch) {
1047
- if (encoded === null) return { ok: true, changed: false };
1085
+ if (encoded === null) return { next: content };
1048
1086
  lines[i] = `${boolMatch[1]}multi_agent_v2 = { enabled = ${boolMatch[2]}, ${key} = ${encoded} }${boolMatch[3]}`;
1049
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
1050
- return { ok: true, changed: true };
1087
+ return { next: applyEol(lines.join("\n"), eol) };
1051
1088
  }
1052
1089
  }
1053
1090
  if (featuresV2Entry !== null) {
1054
- return { ok: false, error: "multi_agent_v2 inside a multiline [features] table is not editable safely" };
1091
+ return { error: "multi_agent_v2 inside a multiline [features] table is not editable safely" };
1055
1092
  }
1056
1093
  }
1057
1094
 
1058
- if (encoded === null) return { ok: true, changed: false };
1095
+ if (encoded === null) return { next: content };
1059
1096
  const suffix = content.endsWith("\n") || content.length === 0 ? "" : eol;
1060
1097
  const separator = content.length > 0 && !content.endsWith(`${eol}${eol}`) ? eol : "";
1061
1098
  const tableText = `[features.multi_agent_v2]${eol}${key} = ${encoded}${eol}`;
1062
- atomicWriteFile(path, `${content}${suffix}${separator}${tableText}`);
1063
- return { ok: true, changed: true };
1099
+ return { next: `${content}${suffix}${separator}${tableText}` };
1100
+ }, heldConfigWriteLock);
1064
1101
  }
1065
1102
 
1066
- export function setSubagentDeveloperInstructions(value: string | null, configPath?: string): ConfigEditResult {
1067
- return setV2StringField("subagent_developer_instructions", value, configPath);
1103
+ export function setSubagentDeveloperInstructions(value: string | null, configPath?: string, heldConfigWriteLock?: LockHandle): ConfigEditResult {
1104
+ return setV2StringField("subagent_developer_instructions", value, configPath, heldConfigWriteLock);
1068
1105
  }
1069
1106
 
1070
1107
  export const MODE_HINT_UNSUPPORTED_ERROR =
@@ -1076,7 +1113,7 @@ export const MODE_HINT_UNSUPPORTED_ERROR =
1076
1113
  * `setSubagentDeveloperInstructions`; the upstream struct rejects unknown fields,
1077
1114
  * so the key spelling must match codex-rs exactly.
1078
1115
  */
1079
- export function setMultiAgentModeHintText(value: string | null, configPath?: string): ConfigEditResult {
1116
+ export function setMultiAgentModeHintText(value: string | null, configPath?: string, heldConfigWriteLock?: LockHandle): ConfigEditResult {
1080
1117
  // The upstream `multi_agent_mode_hint_text` key is newer than the v2 config
1081
1118
  // surface opencodex already manages; an older Codex build rejects the unknown
1082
1119
  // member (`#[serde(deny_unknown_fields)]`) and fails to start. Probe the
@@ -1094,7 +1131,7 @@ export function setMultiAgentModeHintText(value: string | null, configPath?: str
1094
1131
  }
1095
1132
  }
1096
1133
  const canonicalValue = value === null ? null : canonicalizeOpenCodexModeHint(value);
1097
- return setV2StringField("multi_agent_mode_hint_text", canonicalValue, configPath);
1134
+ return setV2StringField("multi_agent_mode_hint_text", canonicalValue, configPath, heldConfigWriteLock);
1098
1135
  }
1099
1136
 
1100
1137
  export const MODE_HINT_CAPABILITY_CACHE_MAX_ENTRIES = 8;
@@ -1120,7 +1157,7 @@ export function probeCodexSupportsModeHint(): boolean | null {
1120
1157
  const runtime = resolveAndPersistCodexRuntime({ env: process.env }).runtime;
1121
1158
  const selectedPath = resolveSelectedCommandPath(runtime.command);
1122
1159
  let selectedIdentity = selectedPath ?? "";
1123
- try { if (selectedPath) selectedIdentity = realpathSync(selectedPath); } catch { /* keep lexical path */ }
1160
+ try { if (selectedPath) selectedIdentity = realpathSync.native(selectedPath); } catch { /* keep lexical path */ }
1124
1161
  const candidates = codexNativeBinaryCandidates(runtime.command);
1125
1162
  const binaryStatFingerprint = candidates.map(candidate => {
1126
1163
  try {
@@ -1189,7 +1226,7 @@ function codexNativeBinaryCandidates(command: string): string[] {
1189
1226
 
1190
1227
  const addSelectedTarget = (target: string) => {
1191
1228
  try {
1192
- const real = realpathSync(target);
1229
+ const real = realpathSync.native(target);
1193
1230
  out.add(target);
1194
1231
  out.add(real);
1195
1232
  if (/[\\/]@openai[\\/]codex[\\/]bin[\\/]/.test(real)) resolverBases.add(real);
@@ -1271,91 +1308,87 @@ function resolveSelectedCommandPath(command: string): string | null {
1271
1308
 
1272
1309
  function editAgentsMaxThreads(value: number | null, configPath?: string, migratedComment?: string): ConfigEditResult {
1273
1310
  const path = configPath ?? activeCodexConfigPath();
1274
- const content = readConfigText(path);
1275
- if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
1276
- const eol = dominantEol(content);
1277
- const lines = content.split(/\r?\n/);
1278
- const headerIdx = lines.findIndex(l => /^\s*\[agents\]\s*(?:#.*)?$/.test(l));
1279
- if (headerIdx === -1) {
1280
- if (value === null) return { ok: true, changed: false };
1281
- const separator = lines.length > 0 && lines[lines.length - 1] !== "" ? [""] : [];
1282
- lines.push(...separator, "[agents]", `max_threads = ${value}${migratedComment ?? ""}`);
1283
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
1284
- return { ok: true, changed: true };
1285
- }
1286
- let end = lines.length;
1287
- for (let i = headerIdx + 1; i < lines.length; i++) {
1288
- if (/^\s*\[/.test(lines[i])) { end = i; break; }
1289
- }
1290
- const keyRe = /^(\s*)max_threads\s*=\s*(\d+)(\s*#.*)?$/;
1291
- for (let i = headerIdx + 1; i < end; i++) {
1292
- const m = lines[i].match(keyRe);
1293
- if (!m) continue;
1294
- if (value === null) lines.splice(i, 1);
1295
- else if (Number(m[2]) === value && (!migratedComment || migratedComment === m[3])) return { ok: true, changed: false };
1296
- else lines[i] = `${m[1]}max_threads = ${value}${mergeTrailingComments(m[3], migratedComment)}`;
1297
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
1298
- return { ok: true, changed: true };
1299
- }
1300
- if (value === null) return { ok: true, changed: false };
1301
- lines.splice(headerIdx + 1, 0, `max_threads = ${value}${migratedComment ?? ""}`);
1302
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
1303
- return { ok: true, changed: true };
1311
+ return editCodexConfigToml(path, (content): TomlEditOutcome => {
1312
+ const eol = dominantEol(content);
1313
+ const lines = content.split(/\r?\n/);
1314
+ const headerIdx = lines.findIndex(l => /^\s*\[agents\]\s*(?:#.*)?$/.test(l));
1315
+ if (headerIdx === -1) {
1316
+ if (value === null) return { next: content };
1317
+ const separator = lines.length > 0 && lines[lines.length - 1] !== "" ? [""] : [];
1318
+ lines.push(...separator, "[agents]", `max_threads = ${value}${migratedComment ?? ""}`);
1319
+ return { next: applyEol(lines.join("\n"), eol) };
1320
+ }
1321
+ let end = lines.length;
1322
+ for (let i = headerIdx + 1; i < lines.length; i++) {
1323
+ if (/^\s*\[/.test(lines[i])) { end = i; break; }
1324
+ }
1325
+ const keyRe = /^(\s*)max_threads\s*=\s*(\d+)(\s*#.*)?$/;
1326
+ for (let i = headerIdx + 1; i < end; i++) {
1327
+ const m = lines[i].match(keyRe);
1328
+ if (!m) continue;
1329
+ if (value === null) lines.splice(i, 1);
1330
+ else if (Number(m[2]) === value && (!migratedComment || migratedComment === m[3])) return { next: content };
1331
+ else lines[i] = `${m[1]}max_threads = ${value}${mergeTrailingComments(m[3], migratedComment)}`;
1332
+ return { next: applyEol(lines.join("\n"), eol) };
1333
+ }
1334
+ if (value === null) return { next: content };
1335
+ lines.splice(headerIdx + 1, 0, `max_threads = ${value}${migratedComment ?? ""}`);
1336
+ return { next: applyEol(lines.join("\n"), eol) };
1337
+ });
1304
1338
  }
1305
1339
 
1306
1340
  function removeMaxConcurrentThreads(configPath?: string): ConfigEditResult {
1307
1341
  const path = configPath ?? activeCodexConfigPath();
1308
- const content = readConfigText(path);
1309
- if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
1310
- const eol = dominantEol(content);
1311
- const lines = content.split(/\r?\n/);
1312
- const headerIdx = lines.findIndex(l => /^\s*\[features\.multi_agent_v2\]\s*(?:#.*)?$/.test(l));
1313
- if (headerIdx !== -1) {
1314
- let end = lines.length;
1315
- for (let i = headerIdx + 1; i < lines.length; i++) {
1316
- if (/^\s*\[/.test(lines[i])) { end = i; break; }
1342
+ return editCodexConfigToml(path, (content): TomlEditOutcome => {
1343
+ const eol = dominantEol(content);
1344
+ const lines = content.split(/\r?\n/);
1345
+ const headerIdx = lines.findIndex(l => /^\s*\[features\.multi_agent_v2\]\s*(?:#.*)?$/.test(l));
1346
+ if (headerIdx !== -1) {
1347
+ let end = lines.length;
1348
+ for (let i = headerIdx + 1; i < lines.length; i++) {
1349
+ if (/^\s*\[/.test(lines[i])) { end = i; break; }
1350
+ }
1351
+ const keyIdx = lines.findIndex((line, i) => i > headerIdx && i < end && /^\s*max_concurrent_threads_per_session\s*=/.test(line));
1352
+ if (keyIdx !== -1) {
1353
+ lines.splice(keyIdx, 1);
1354
+ return { next: applyEol(lines.join("\n"), eol) };
1355
+ }
1356
+ }
1357
+ const featuresHeader = lines.findIndex(l => /^\s*\[features\]\s*(?:#.*)?$/.test(l));
1358
+ if (featuresHeader === -1) return { next: content };
1359
+ let featuresEnd = lines.length;
1360
+ for (let i = featuresHeader + 1; i < lines.length; i++) {
1361
+ if (/^\s*\[/.test(lines[i])) { featuresEnd = i; break; }
1317
1362
  }
1318
- const keyIdx = lines.findIndex((line, i) => i > headerIdx && i < end && /^\s*max_concurrent_threads_per_session\s*=/.test(line));
1319
- if (keyIdx !== -1) {
1320
- lines.splice(keyIdx, 1);
1321
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
1322
- return { ok: true, changed: true };
1363
+ const inlineRe = /^(\s*)multi_agent_v2\s*=\s*\{([^}]*)\}(\s*#.*)?$/;
1364
+ for (let i = featuresHeader + 1; i < featuresEnd; i++) {
1365
+ const inline = lines[i].match(inlineRe);
1366
+ if (!inline || !/(?:^|,)\s*max_concurrent_threads_per_session\s*=/.test(inline[2])) continue;
1367
+ const body = inline[2]
1368
+ .replace(/^\s*max_concurrent_threads_per_session\s*=\s*\d+\s*,?\s*/, "")
1369
+ .replace(/,\s*max_concurrent_threads_per_session\s*=\s*\d+\s*(?=,|$)/, "")
1370
+ .trim();
1371
+ lines[i] = `${inline[1]}multi_agent_v2 = { ${body} }${inline[3] ?? ""}`;
1372
+ return { next: applyEol(lines.join("\n"), eol) };
1323
1373
  }
1324
- }
1325
- const featuresHeader = lines.findIndex(l => /^\s*\[features\]\s*(?:#.*)?$/.test(l));
1326
- if (featuresHeader === -1) return { ok: true, changed: false };
1327
- let featuresEnd = lines.length;
1328
- for (let i = featuresHeader + 1; i < lines.length; i++) {
1329
- if (/^\s*\[/.test(lines[i])) { featuresEnd = i; break; }
1330
- }
1331
- const inlineRe = /^(\s*)multi_agent_v2\s*=\s*\{([^}]*)\}(\s*#.*)?$/;
1332
- for (let i = featuresHeader + 1; i < featuresEnd; i++) {
1333
- const inline = lines[i].match(inlineRe);
1334
- if (!inline || !/(?:^|,)\s*max_concurrent_threads_per_session\s*=/.test(inline[2])) continue;
1335
- const body = inline[2]
1336
- .replace(/^\s*max_concurrent_threads_per_session\s*=\s*\d+\s*,?\s*/, "")
1337
- .replace(/,\s*max_concurrent_threads_per_session\s*=\s*\d+\s*(?=,|$)/, "")
1338
- .trim();
1339
- lines[i] = `${inline[1]}multi_agent_v2 = { ${body} }${inline[3] ?? ""}`;
1340
- atomicWriteFile(path, applyEol(lines.join("\n"), eol));
1341
- return { ok: true, changed: true };
1342
- }
1343
- return { ok: true, changed: false };
1374
+ return { next: content };
1375
+ });
1344
1376
  }
1345
1377
 
1346
1378
  function ensureDisabledV2Config(value: number | null, configPath?: string, migratedComment?: string): ConfigEditResult {
1347
1379
  const path = configPath ?? activeCodexConfigPath();
1348
- const content = readConfigText(path);
1349
- if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
1350
- if (tomlTableBody(content, "features.multi_agent_v2") !== null || tomlTableBody(content, "features")?.match(/^\s*multi_agent_v2\s*=/m)) {
1351
- if (value === null) return { ok: true, changed: false };
1352
- return setMaxConcurrentThreads(value, path, migratedComment);
1353
- }
1354
- const eol = dominantEol(content);
1355
- const suffix = content.endsWith("\n") || content.length === 0 ? "" : eol;
1356
- const table = `[features.multi_agent_v2]${eol}enabled = false${value === null ? "" : `${eol}max_concurrent_threads_per_session = ${value}${migratedComment ?? ""}`}${eol}`;
1357
- atomicWriteFile(path, `${content}${suffix}${content.length > 0 && !content.endsWith(`${eol}${eol}`) ? eol : ""}${table}`);
1358
- return { ok: true, changed: true };
1380
+ return editCodexConfigToml(path, (content): TomlEditOutcome => {
1381
+ if (tomlTableBody(content, "features.multi_agent_v2") !== null || tomlTableBody(content, "features")?.match(/^\s*multi_agent_v2\s*=/m)) {
1382
+ if (value === null) return { next: content };
1383
+ // The content-level edit, not the exported writer: this section already
1384
+ // holds `path`'s write lock and a nested acquire would refuse itself.
1385
+ return maxConcurrentThreadsEdit(content, value, migratedComment);
1386
+ }
1387
+ const eol = dominantEol(content);
1388
+ const suffix = content.endsWith("\n") || content.length === 0 ? "" : eol;
1389
+ const table = `[features.multi_agent_v2]${eol}enabled = false${value === null ? "" : `${eol}max_concurrent_threads_per_session = ${value}${migratedComment ?? ""}`}${eol}`;
1390
+ return { next: `${content}${suffix}${content.length > 0 && !content.endsWith(`${eol}${eol}`) ? eol : ""}${table}` };
1391
+ });
1359
1392
  }
1360
1393
 
1361
1394
  /**
@@ -1425,7 +1458,16 @@ export function isAtomicResidualError(error: unknown): boolean {
1425
1458
  return error instanceof AtomicWriteResidualTempError || error instanceof AtomicWriteSecretResidualError;
1426
1459
  }
1427
1460
 
1428
- function applyConfigEditsAtomically(path: string, edit: (tempPath: string) => ConfigEditResult): ConfigEditResult {
1461
+ /**
1462
+ * Stage the temp-copy edits then swap `path` in one pass.
1463
+ *
1464
+ * Caller must already hold `path`'s config write lock —
1465
+ * `transitionMultiAgentV2` is that caller, and it holds the lock across the
1466
+ * whole migration so a foreign write cannot land between its stages or be
1467
+ * discarded by the byte-restore rollback. The per-call edits inside run on the
1468
+ * temp path, which takes its own lock harmlessly.
1469
+ */
1470
+ function applyConfigEditsAtomically(path: string, held: LockHandle, edit: (tempPath: string) => ConfigEditResult): ConfigEditResult {
1429
1471
  const content = readConfigText(path);
1430
1472
  if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
1431
1473
  const tempPath = `${path}.ocx-migration.${process.pid}.${++migrationEditSeq}`;
@@ -1439,7 +1481,7 @@ function applyConfigEditsAtomically(path: string, edit: (tempPath: string) => Co
1439
1481
  const edited = readConfigText(tempPath);
1440
1482
  if (edited === null) return { ok: false, error: "temporary config migration output is unreadable" };
1441
1483
  if (edited === content) return { ok: true, changed: false };
1442
- atomicWriteFile(path, edited);
1484
+ publishConfigWrite(path, held, (destination, hooks) => atomicWriteFile(destination, edited, undefined, hooks));
1443
1485
  return { ok: true, changed: true };
1444
1486
  } catch (error) {
1445
1487
  if (isAtomicResidualError(error)) innerResidual = true;
@@ -1459,7 +1501,7 @@ function applyConfigEditsAtomically(path: string, edit: (tempPath: string) => Co
1459
1501
 
1460
1502
  export type MultiAgentV2TransitionResult =
1461
1503
  | { ok: true; changed: boolean; threadLimit: number | null }
1462
- | { ok: false; error: string };
1504
+ | { ok: false; error: string; retryable?: boolean };
1463
1505
 
1464
1506
  function transitionConfigError(content: string): string | null {
1465
1507
  if (/^\s*(?:features\.multi_agent_v2(?:\.[A-Za-z0-9_]+)?|agents\.max_threads)\s*=/m.test(content)) {
@@ -1493,17 +1535,19 @@ function transitionConfigError(content: string): string | null {
1493
1535
  /**
1494
1536
  * Toggle native multi_agent_v2 while moving the active thread limit to the key
1495
1537
  * valid for the destination version. Any failed command/postcondition restores
1496
- * the exact original config bytes.
1538
+ * the exact original config bytes; alias drift restores only the locked canonical target.
1497
1539
  */
1498
1540
  export function transitionMultiAgentV2(
1499
1541
  enabled: boolean,
1500
- toggleFeature: (enabled: boolean) => void,
1501
- options: { configPath?: string; threadLimit?: number } = {},
1542
+ toggleFeature: (enabled: boolean, env: NodeJS.ProcessEnv, validateBeforeSpawn: () => void) => void,
1543
+ options: { configPath?: string; threadLimit?: number; heldConfigWriteLock?: LockHandle } = {},
1502
1544
  ): MultiAgentV2TransitionResult {
1503
1545
  if (options.threadLimit !== undefined && (!Number.isInteger(options.threadLimit) || options.threadLimit < 1)) {
1504
1546
  return { ok: false, error: "thread limit must be an integer >= 1" };
1505
1547
  }
1506
1548
  const path = options.configPath ?? activeCodexConfigPath();
1549
+ if (codexHomeIsAbsent(dirname(path))) return { ok: false, error: `config.toml not readable at ${path}` };
1550
+ const runTransition = (held: LockHandle): MultiAgentV2TransitionResult => {
1507
1551
  const original = readConfigText(path);
1508
1552
  if (original === null) return { ok: false, error: `config.toml not readable at ${path}` };
1509
1553
  const preflightError = transitionConfigError(original);
@@ -1531,45 +1575,58 @@ export function transitionMultiAgentV2(
1531
1575
  }
1532
1576
  const migratedComment = activeThreadComment(original, beforeEnabled);
1533
1577
  try {
1534
- if (enabled) {
1535
- if (!beforeEnabled) {
1536
- const staged = applyConfigEditsAtomically(path, tempPath => {
1537
- const v2 = ensureDisabledV2Config(threadLimit, tempPath, migratedComment);
1578
+ return withConfigWriteRecovery(path, held, () => {
1579
+ if (beforeEnabled !== enabled) assertNativeConfigWriteDestination(path, held);
1580
+ if (enabled) {
1581
+ if (!beforeEnabled) {
1582
+ const staged = applyConfigEditsAtomically(path, held, tempPath => {
1583
+ const v2 = ensureDisabledV2Config(threadLimit, tempPath, migratedComment);
1584
+ if (!v2.ok) return v2;
1585
+ return editAgentsMaxThreads(null, tempPath);
1586
+ });
1587
+ if (!staged.ok) throw new Error(staged.error);
1588
+ runConfigWriteChild(path, held, (env, validate) => toggleFeature(true, env, validate));
1589
+ }
1590
+ if (!isMultiAgentV2Enabled(path)) throw new Error("codex feature command did not enable multi_agent_v2");
1591
+ const target = applyConfigEditsAtomically(path, held, tempPath => {
1592
+ const v2 = threadLimit === null
1593
+ ? removeMaxConcurrentThreads(tempPath)
1594
+ : setMaxConcurrentThreads(threadLimit, tempPath, migratedComment);
1538
1595
  if (!v2.ok) return v2;
1539
1596
  return editAgentsMaxThreads(null, tempPath);
1540
1597
  });
1541
- if (!staged.ok) throw new Error(staged.error);
1542
- toggleFeature(true);
1598
+ if (!target.ok) throw new Error(target.error);
1599
+ if (hasAgentsMaxThreads(path) || getMaxConcurrentThreads(path) !== threadLimit) throw new Error("v2 thread-limit migration postcondition failed");
1600
+ } else {
1601
+ if (beforeEnabled) runConfigWriteChild(path, held, (env, validate) => toggleFeature(false, env, validate));
1602
+ if (isMultiAgentV2Enabled(path)) throw new Error("codex feature command did not disable multi_agent_v2");
1603
+ const target = applyConfigEditsAtomically(path, held, tempPath => {
1604
+ const v2 = removeMaxConcurrentThreads(tempPath);
1605
+ if (!v2.ok) return v2;
1606
+ return editAgentsMaxThreads(threadLimit, tempPath, migratedComment);
1607
+ });
1608
+ if (!target.ok) throw new Error(target.error);
1609
+ if (getMaxConcurrentThreads(path) !== null || getAgentsMaxThreads(path) !== threadLimit) throw new Error("v1 thread-limit migration postcondition failed");
1543
1610
  }
1544
- if (!isMultiAgentV2Enabled(path)) throw new Error("codex feature command did not enable multi_agent_v2");
1545
- const target = applyConfigEditsAtomically(path, tempPath => {
1546
- const v2 = threadLimit === null
1547
- ? removeMaxConcurrentThreads(tempPath)
1548
- : setMaxConcurrentThreads(threadLimit, tempPath, migratedComment);
1549
- if (!v2.ok) return v2;
1550
- return editAgentsMaxThreads(null, tempPath);
1551
- });
1552
- if (!target.ok) throw new Error(target.error);
1553
- if (hasAgentsMaxThreads(path) || getMaxConcurrentThreads(path) !== threadLimit) throw new Error("v2 thread-limit migration postcondition failed");
1554
- } else {
1555
- if (beforeEnabled) toggleFeature(false);
1556
- if (isMultiAgentV2Enabled(path)) throw new Error("codex feature command did not disable multi_agent_v2");
1557
- const target = applyConfigEditsAtomically(path, tempPath => {
1558
- const v2 = removeMaxConcurrentThreads(tempPath);
1559
- if (!v2.ok) return v2;
1560
- return editAgentsMaxThreads(threadLimit, tempPath, migratedComment);
1561
- });
1562
- if (!target.ok) throw new Error(target.error);
1563
- if (getMaxConcurrentThreads(path) !== null || getAgentsMaxThreads(path) !== threadLimit) throw new Error("v1 thread-limit migration postcondition failed");
1564
- }
1565
- return { ok: true, changed: readConfigText(path) !== original, threadLimit };
1611
+ return { ok: true, changed: readConfigText(path) !== original, threadLimit };
1612
+ });
1566
1613
  } catch (err) {
1567
1614
  const message = err instanceof Error ? err.message : String(err);
1568
- try {
1569
- atomicWriteFile(path, original);
1570
- return { ok: false, error: message };
1571
- } catch (rollbackErr) {
1572
- return { ok: false, error: `${message}; rollback failed: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}` };
1573
- }
1615
+ if (err instanceof ConfigWriteDestinationChanged) return { ok: false, error: message, retryable: false };
1616
+ return { ok: false, error: message };
1574
1617
  }
1618
+ };
1619
+ /*
1620
+ * The whole migration — staged edits, the codex-features subprocess, the
1621
+ * postcondition checks, and a possible byte-restore rollback — runs under
1622
+ * `path`'s write lock. A foreign write that could once land between stages
1623
+ * is now refused instead of silently surviving into the rolled-back bytes.
1624
+ *
1625
+ * A caller that already holds the lock (the injector, whose wider section
1626
+ * must also cover the artifact commit) passes its handle: re-acquiring the
1627
+ * same lock file would refuse itself.
1628
+ */
1629
+ const locked = withConfigWriteLockHeld(path, options.heldConfigWriteLock, runTransition);
1630
+ if (!locked.ok) return { ok: false, error: configWriteLockFailureMessage(locked), ...(locked.error === "unsafe" ? { retryable: false } : {}) };
1631
+ return locked.value;
1575
1632
  }