@bitkyc08/opencodex 2.81.0 → 2.83.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 (272) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ba8uG696.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.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-BHRZdJuv.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.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/cursor/catalog.ts +2 -2
  11. package/src/adapters/cursor/effort-map.ts +1 -1
  12. package/src/adapters/devin/live-models.ts +2 -2
  13. package/src/adapters/google-truncation.ts +22 -0
  14. package/src/adapters/google.ts +7 -3
  15. package/src/adapters/ollama-native.ts +18 -14
  16. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  17. package/src/adapters/openai-responses/passthrough.ts +11 -4
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  21. package/src/bridge/errors.ts +7 -1
  22. package/src/claude/agents-inject.ts +14 -3
  23. package/src/claude/context-windows.ts +22 -20
  24. package/src/claude/desktop-3p.ts +88 -12
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/desktop-profile.ts +2 -1
  27. package/src/claude/gateway-cache.ts +18 -1
  28. package/src/claude/inbound.ts +77 -3
  29. package/src/claude/intercept/cli-catalog.ts +3 -0
  30. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  31. package/src/claude/intercept/picker-models.ts +14 -6
  32. package/src/claude/intercept/settings.ts +3 -0
  33. package/src/claude/long-context.ts +66 -0
  34. package/src/claude/model-info.ts +19 -16
  35. package/src/claude/outbound.ts +94 -9
  36. package/src/claude/subagent-model.ts +40 -11
  37. package/src/cli/account-auth.ts +20 -12
  38. package/src/cli/agent.ts +9 -5
  39. package/src/cli/aside-profile-recovery.ts +33 -0
  40. package/src/cli/capabilities-base.ts +5 -5
  41. package/src/cli/capabilities-integrations.ts +2 -2
  42. package/src/cli/cli-path-diagnostics.ts +132 -0
  43. package/src/cli/desktop-runtime-guidance.ts +65 -0
  44. package/src/cli/dispatch.ts +5 -0
  45. package/src/cli/doctor.ts +16 -3
  46. package/src/cli/index.ts +14 -9
  47. package/src/cli/integration-aside-sync.ts +2 -1
  48. package/src/cli/integrations.ts +16 -3
  49. package/src/cli/resolve.ts +26 -3
  50. package/src/cli/root.ts +4 -2
  51. package/src/cli/status.ts +64 -12
  52. package/src/cli/update-restart-child.ts +4 -2
  53. package/src/cli/update-restart-home.ts +28 -10
  54. package/src/cli/update-restart-service-record.ts +116 -0
  55. package/src/cli/update-restart-supervision.ts +96 -0
  56. package/src/cli/update-restart.ts +59 -13
  57. package/src/cli/v2.ts +27 -20
  58. package/src/cli/version-skew-notice.ts +77 -0
  59. package/src/clients/config-export/model-metadata.ts +16 -0
  60. package/src/clients/config-export/zcode-store.ts +46 -11
  61. package/src/clients/config-export/zcode.ts +3 -5
  62. package/src/clients/config-export.ts +1 -1
  63. package/src/clients/lazycodex.ts +1 -1
  64. package/src/clients/omo-role-models.ts +32 -7
  65. package/src/codex/auth-api/account-selection.ts +107 -0
  66. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  67. package/src/codex/auth-api/routes.ts +10 -49
  68. package/src/codex/auth-context.ts +28 -4
  69. package/src/codex/autostart-health.ts +12 -3
  70. package/src/codex/catalog/build-entries.ts +18 -0
  71. package/src/codex/catalog/bundled.ts +155 -26
  72. package/src/codex/catalog/combo-member.ts +3 -2
  73. package/src/codex/catalog/effort.ts +26 -5
  74. package/src/codex/catalog/gather-capture.ts +5 -0
  75. package/src/codex/catalog/model-hints.ts +15 -8
  76. package/src/codex/catalog/model-visibility.ts +3 -1
  77. package/src/codex/catalog/native-models.ts +1 -1
  78. package/src/codex/catalog/operator-tiers.ts +38 -0
  79. package/src/codex/catalog/provider-models.ts +68 -35
  80. package/src/codex/catalog/retained-sync.ts +7 -5
  81. package/src/codex/catalog/routed-gather.ts +15 -1
  82. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  83. package/src/codex/catalog-auto-refresh.ts +19 -0
  84. package/src/codex/catalog.ts +1 -1
  85. package/src/codex/codex-home-owner.ts +3 -0
  86. package/src/codex/config-write-lock.ts +333 -0
  87. package/src/codex/convergence.ts +2 -1
  88. package/src/codex/desktop-app/darwin.ts +1 -1
  89. package/src/codex/desktop-app/handoff.ts +1 -1
  90. package/src/codex/desktop-app/linux.ts +1 -1
  91. package/src/codex/desktop-app/lock.ts +1 -1
  92. package/src/codex/desktop-app/types.ts +1 -1
  93. package/src/codex/desktop-app/windows.ts +1 -1
  94. package/src/codex/desktop-app-restart.ts +1 -1
  95. package/src/codex/features.ts +257 -200
  96. package/src/codex/inject/config-write-section.ts +34 -0
  97. package/src/codex/inject/multi-agent-v2.ts +14 -6
  98. package/src/codex/inject/remove.ts +36 -11
  99. package/src/codex/inject/restore.ts +85 -20
  100. package/src/codex/inject-coordination.ts +15 -6
  101. package/src/codex/inject.ts +62 -20
  102. package/src/codex/journal.ts +62 -17
  103. package/src/codex/main-account-cache.ts +43 -0
  104. package/src/codex/main-account.ts +6 -7
  105. package/src/codex/paths.ts +2 -0
  106. package/src/codex/prepared-home.ts +18 -0
  107. package/src/codex/prompt-journal.ts +25 -12
  108. package/src/codex/prompt-layers.ts +49 -26
  109. package/src/codex/prompt-lock-claim.ts +134 -0
  110. package/src/codex/prompt-lock-evidence.ts +26 -0
  111. package/src/codex/prompt-lock-io.ts +16 -0
  112. package/src/codex/prompt-lock-owner.ts +130 -0
  113. package/src/codex/prompt-lock.ts +84 -53
  114. package/src/codex/routing/failure-window.ts +146 -0
  115. package/src/codex/routing/selection.ts +4 -1
  116. package/src/codex/routing.ts +13 -17
  117. package/src/codex/runtime.ts +106 -18
  118. package/src/codex/shim.ts +45 -19
  119. package/src/combos/jev-service-exchange.ts +281 -0
  120. package/src/combos/jev.ts +34 -220
  121. package/src/config/atomic-write.ts +1 -1
  122. package/src/config/codex-ws-reuse-setting.ts +10 -0
  123. package/src/config/live-reconcile.ts +17 -0
  124. package/src/config/load-degrade.ts +8 -0
  125. package/src/config/rebase-provenance.ts +11 -0
  126. package/src/config/schema/config-schema.ts +4 -0
  127. package/src/generated/compatibility-version.json +375 -227
  128. package/src/generated/model-metadata.ts +2 -2
  129. package/src/grok/inject.ts +276 -206
  130. package/src/grok/reset-coupon-ledger.ts +223 -57
  131. package/src/grok/reset-coupons.ts +13 -6
  132. package/src/grok/sync.ts +2 -1
  133. package/src/images/loop.ts +29 -11
  134. package/src/integrations/aside-profiles.ts +10 -3
  135. package/src/integrations/catalog-refresh.ts +18 -2
  136. package/src/integrations/cursor-detect.ts +1 -1
  137. package/src/integrations/writer.ts +9 -3
  138. package/src/lib/bun-path-runtime.d.mts +32 -0
  139. package/src/lib/bun-path-runtime.mjs +95 -0
  140. package/src/lib/bun-runtime-preflight.ts +140 -0
  141. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  142. package/src/lib/desktop-cli-handoff.mjs +92 -0
  143. package/src/lib/desktop-cli-record.d.mts +29 -0
  144. package/src/lib/desktop-cli-record.mjs +102 -0
  145. package/src/lib/errors.ts +25 -0
  146. package/src/lib/redact.ts +2 -2
  147. package/src/lib/request-execution-budget.ts +8 -0
  148. package/src/lib/synced-state-location.ts +46 -6
  149. package/src/lib/test-home-guard.ts +87 -1
  150. package/src/lib/translator-budget.ts +17 -4
  151. package/src/lib/windows-atomic-replace.ts +38 -16
  152. package/src/lib/windows-elevation.ts +6 -0
  153. package/src/lib/windows-secret-acl.ts +92 -25
  154. package/src/lib/winsw.ts +2 -3
  155. package/src/oauth/anthropic-account-refusal.ts +39 -1
  156. package/src/oauth/anthropic-routing.ts +1 -1
  157. package/src/oauth/anthropic-send-ownership.ts +3 -3
  158. package/src/oauth/chatgpt.ts +113 -41
  159. package/src/oauth/index.ts +7 -3
  160. package/src/oauth/meta-muse-device.ts +1 -1
  161. package/src/oauth/store.ts +1 -1
  162. package/src/oauth/types.ts +1 -1
  163. package/src/providers/anthropic-fast.ts +1 -1
  164. package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
  165. package/src/providers/azure-model-metadata.ts +115 -0
  166. package/src/providers/derive.ts +21 -0
  167. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  168. package/src/providers/devin-provider-merge-migration.ts +1 -1
  169. package/src/providers/github-copilot-auto.ts +169 -0
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/muse-subscription-usage.ts +1 -1
  172. package/src/providers/quota/vendor-probes-key.ts +127 -17
  173. package/src/providers/quota.ts +1 -1
  174. package/src/providers/registry/entries-core.ts +6 -6
  175. package/src/providers/registry/entries-extended.ts +4 -0
  176. package/src/providers/registry/model-seeds.ts +3 -3
  177. package/src/providers/xai-fast-model.ts +1 -1
  178. package/src/responses/reasoning-envelope.ts +29 -1
  179. package/src/responses/reasoning-replay-cache.ts +17 -1
  180. package/src/responses/spill-store.ts +15 -2
  181. package/src/responses/state/metrics.ts +8 -0
  182. package/src/responses/state/spill-failure.ts +12 -0
  183. package/src/responses/state/spill-queue.ts +12 -5
  184. package/src/responses/state.ts +18 -10
  185. package/src/responses/task-input.ts +17 -0
  186. package/src/server/background-lifecycle.ts +27 -0
  187. package/src/server/chat-completions.ts +1 -0
  188. package/src/server/chat-native.ts +28 -12
  189. package/src/server/claude-messages.ts +43 -6
  190. package/src/server/inbound-body-admission.ts +25 -7
  191. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  192. package/src/server/index/link-listener.ts +30 -17
  193. package/src/server/index/serve-options.ts +18 -4
  194. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  195. package/src/server/index/startup-warnings.ts +19 -0
  196. package/src/server/index.ts +2 -1
  197. package/src/server/lifecycle.ts +31 -4
  198. package/src/server/management/agent-settings-routes.ts +93 -32
  199. package/src/server/management/codex-agent-role-routes.ts +4 -2
  200. package/src/server/management/codex-prompt-routes.ts +1 -0
  201. package/src/server/management/codex-role-auto-assign.ts +13 -2
  202. package/src/server/management/config-routes.ts +59 -8
  203. package/src/server/management/context.ts +2 -2
  204. package/src/server/management/grok-coupon-routes.ts +277 -52
  205. package/src/server/management/link-routes.ts +42 -20
  206. package/src/server/management/route-registry.ts +2 -2
  207. package/src/server/management/shared.ts +3 -3
  208. package/src/server/messages-native-oauth.ts +9 -8
  209. package/src/server/messages-native.ts +49 -13
  210. package/src/server/messages-response-headers.ts +14 -0
  211. package/src/server/relay-eager.ts +2 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-conversation.ts +48 -5
  214. package/src/server/request-log-failure-attribution.ts +3 -0
  215. package/src/server/request-log-terminal-status.ts +186 -0
  216. package/src/server/request-log.ts +15 -89
  217. package/src/server/responses/adapter-continuation.ts +11 -3
  218. package/src/server/responses/adapter-dispatch.ts +99 -19
  219. package/src/server/responses/codex-ws-exchange.ts +17 -2
  220. package/src/server/responses/codex-ws-pool.ts +100 -13
  221. package/src/server/responses/codex-ws-wire.ts +9 -0
  222. package/src/server/responses/combo-requested-effort.ts +3 -1
  223. package/src/server/responses/compact.ts +3 -2
  224. package/src/server/responses/core-codex-account.ts +22 -1
  225. package/src/server/responses/core-combo-native.ts +20 -3
  226. package/src/server/responses/core-combo.ts +22 -14
  227. package/src/server/responses/core-lifetime.ts +3 -36
  228. package/src/server/responses/core-opaque-recovery.ts +5 -5
  229. package/src/server/responses/core-options.ts +5 -0
  230. package/src/server/responses/core-replay.ts +73 -0
  231. package/src/server/responses/core.ts +9 -9
  232. package/src/server/responses/empty-completion-guard.ts +18 -2
  233. package/src/server/responses/fetch-helpers.ts +26 -3
  234. package/src/server/responses/jev-model-invoke.ts +1 -1
  235. package/src/server/responses/passthrough-delivery.ts +24 -2
  236. package/src/server/responses/passthrough-dispatch.ts +134 -62
  237. package/src/server/responses/passthrough-execution.ts +3 -3
  238. package/src/server/responses/request-prepare.ts +4 -0
  239. package/src/server/responses/request-send-budget.ts +112 -49
  240. package/src/server/responses/request-transport.ts +125 -5
  241. package/src/server/responses/response-effects.ts +1 -0
  242. package/src/server/responses/run-turn-execution.ts +15 -5
  243. package/src/server/responses/sidecar-execution.ts +30 -16
  244. package/src/server/responses/sidecar-send-budget.ts +58 -0
  245. package/src/server/responses/spend-storage-error.ts +33 -0
  246. package/src/server/responses/ws-upstream.ts +19 -6
  247. package/src/server/startup-health-cache.ts +2 -1
  248. package/src/server/workflow-refusal.ts +3 -0
  249. package/src/service/cli.ts +20 -4
  250. package/src/service/desktop-command-guard.ts +32 -0
  251. package/src/service/desktop-startup.ts +66 -40
  252. package/src/service/desktop-supervision.d.mts +26 -0
  253. package/src/service/desktop-supervision.mjs +140 -0
  254. package/src/service/managing-cli.ts +1 -0
  255. package/src/service/orchestration.ts +46 -20
  256. package/src/service/repair.ts +25 -9
  257. package/src/service/state.ts +4 -3
  258. package/src/service/systemd.ts +17 -8
  259. package/src/service/windows-ops.ts +61 -22
  260. package/src/storage/policy-job.ts +5 -1
  261. package/src/storage/policy-worker.ts +3 -1
  262. package/src/storage/policy.ts +9 -0
  263. package/src/types/config.ts +21 -0
  264. package/src/types/request.ts +10 -0
  265. package/src/types/wire.ts +1 -0
  266. package/src/update/index.ts +57 -9
  267. package/src/update/restart-ownership.ts +10 -3
  268. package/src/update/runtime-ownership.d.mts +6 -0
  269. package/src/update/runtime-ownership.mjs +23 -7
  270. package/src/usage/expected-prices.ts +1 -1
  271. package/src/web-search/loop.ts +5 -2
  272. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -1,9 +1,13 @@
1
+ import { beginCodexWriteSection, publishCodexArtifact } from "./inject/config-write-section";
1
2
  import { createHash } from "node:crypto";
3
+ import type { AtomicWriteHooks } from "../config/atomic-write";
2
4
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
5
  import { join } from "node:path";
4
6
  import { atomicWriteFile } from "../config";
5
- import { assertCodexHomeOwner, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
7
+ import { assertCodexHomeOwner, codexHomeIsAbsent, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
6
8
  import { hasInjectedCodexRouting } from "./injected-marker";
9
+ import { configWriteLockFailureMessage, withConfigWriteLockHeld } from "./config-write-lock";
10
+ import type { LockHandle } from "./config-write-lock";
7
11
  import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
8
12
 
9
13
  /**
@@ -80,6 +84,9 @@ interface Journal {
80
84
  }
81
85
 
82
86
  export interface RestoreJournalResult {
87
+ /** The shared config write lock was held by another opencodex writer; restore did not run. */
88
+ lockBusy?: true;
89
+ lockUnsafe?: string;
83
90
  /** An unchanged generated profile could not be restored; distinct from a preserved user edit. */
84
91
  profileRestoreFailed?: true;
85
92
  ownershipRefusal?: CodexHomeOwnerRefusalReason;
@@ -154,7 +161,7 @@ export interface WriteJournalOptions {
154
161
  * so an unclean shutdown days later replays a day-one config over the user's
155
162
  * plugins, model choice, and trusted projects.
156
163
  */
157
- export function writeJournal(options: WriteJournalOptions = {}): void {
164
+ export function writeJournal(options: WriteJournalOptions = {}, hooks: AtomicWriteHooks = {}): void {
158
165
  assertCodexHomeOwner(CODEX_HOME);
159
166
  if (!existsSync(CODEX_CONFIG_PATH)) return;
160
167
  const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8");
@@ -185,7 +192,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
185
192
  : { kind: "process", pid: process.pid },
186
193
  timestamp: new Date().toISOString(),
187
194
  };
188
- atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
195
+ atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal), undefined, hooks);
189
196
  }
190
197
 
191
198
  export interface InjectedJournalOwnership {
@@ -202,6 +209,7 @@ export function markJournalInjectedState(
202
209
  config: string,
203
210
  profile: string | null,
204
211
  ownership: InjectedJournalOwnership,
212
+ hooks: AtomicWriteHooks = {},
205
213
  ): void {
206
214
  assertCodexHomeOwner(CODEX_HOME);
207
215
  const inspection = readCodexHomeJournal(JOURNAL_PATH);
@@ -224,7 +232,7 @@ export function markJournalInjectedState(
224
232
  journal.replacedRootWebSearch = ownership.replacedRootWebSearch ?? null;
225
233
  journal.injectedCatalogPath = ownership.injectedCatalogPath;
226
234
  journal.opencodexHome = opencodexHomeForInjection(journal.opencodexHome);
227
- atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
235
+ atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal), undefined, hooks);
228
236
  }
229
237
 
230
238
  /**
@@ -259,19 +267,21 @@ export function journaledInjectedCatalogPath(): string | null {
259
267
  return readJournal()?.injectedCatalogPath ?? null;
260
268
  }
261
269
 
262
- export function removeJournal(): void {
270
+ export function removeJournal(hooks: AtomicWriteHooks = {}): void {
263
271
  assertCodexHomeOwner(CODEX_HOME);
264
- try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ }
272
+ hooks.validateBeforeRename?.(JOURNAL_PATH);
273
+ try { unlinkSync(JOURNAL_PATH); } catch { return; }
274
+ hooks.afterRename?.(JOURNAL_PATH);
265
275
  }
266
276
 
267
277
  /** Successful field-level native restore releases ownership while retaining recovery evidence. */
268
- export function releaseJournalHomeBinding(): void {
278
+ export function releaseJournalHomeBinding(hooks: AtomicWriteHooks = {}): void {
269
279
  assertCodexHomeOwner(CODEX_HOME);
270
280
  const inspection = readCodexHomeJournal(JOURNAL_PATH);
271
281
  if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown");
272
282
  if (inspection.kind === "missing" || inspection.journal.opencodexHome === undefined) return;
273
283
  delete inspection.journal.opencodexHome;
274
- atomicWriteFile(JOURNAL_PATH, JSON.stringify(inspection.journal));
284
+ atomicWriteFile(JOURNAL_PATH, JSON.stringify(inspection.journal), undefined, hooks);
275
285
  }
276
286
 
277
287
  // Kept compatible with existing read-only callers; invalid evidence is never cleanup authority.
@@ -298,13 +308,21 @@ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner
298
308
  : null;
299
309
  }
300
310
 
301
- export function restoreJournalState(): RestoreJournalResult {
311
+ export function restoreJournalState(
312
+ options: { heldConfigWriteLock?: LockHandle } = {},
313
+ ): RestoreJournalResult {
314
+ if (codexHomeIsAbsent(CODEX_HOME)) return {
315
+ configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
316
+ configChanged: false, profileChanged: false, complete: false, unverified: false,
317
+ };
318
+ const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, options.heldConfigWriteLock, held => {
302
319
  try { assertCodexHomeOwner(CODEX_HOME); }
303
320
  catch (error) {
304
321
  if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
305
322
  return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
306
323
  configChanged: false, profileChanged: false, complete: false, unverified: true, ownershipRefusal: error.reason };
307
324
  }
325
+ beginCodexWriteSection(held);
308
326
  const journal = readJournal();
309
327
  if (!journal) {
310
328
  return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
@@ -335,13 +353,13 @@ export function restoreJournalState(): RestoreJournalResult {
335
353
  let configRewritten = false;
336
354
  let profileRewritten = false;
337
355
  if (configUnchanged && !configRestored) {
338
- atomicWriteFile(CODEX_CONFIG_PATH, comparison.originalConfig);
356
+ publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, comparison.originalConfig, undefined, hooks));
339
357
  configRestored = true;
340
358
  configRewritten = true;
341
359
  }
342
360
  if (profileUnchanged && !profileRestored) {
343
361
  if (comparison.originalProfile !== null) {
344
- atomicWriteFile(CODEX_PROFILE_PATH, comparison.originalProfile);
362
+ publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => atomicWriteFile(path, comparison.originalProfile!, undefined, hooks));
345
363
  profileRestored = true;
346
364
  profileRewritten = true;
347
365
  } else if (existsSync(CODEX_PROFILE_PATH)) {
@@ -351,7 +369,7 @@ export function restoreJournalState(): RestoreJournalResult {
351
369
  // not be there. ENOENT is the one benign outcome: the file is already gone, which is
352
370
  // the state we wanted.
353
371
  try {
354
- unlinkSync(CODEX_PROFILE_PATH);
372
+ publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => { hooks.validateBeforeRename?.(path); unlinkSync(path); hooks.afterRename?.(path); });
355
373
  profileRestored = true;
356
374
  profileRewritten = true;
357
375
  } catch (error) {
@@ -361,8 +379,11 @@ export function restoreJournalState(): RestoreJournalResult {
361
379
  profileRestored = true;
362
380
  }
363
381
  }
364
- const complete = configRestored && profileRestored;
365
- if (complete) removeJournal();
382
+ let complete = configRestored && profileRestored;
383
+ if (complete) {
384
+ try { publishCodexArtifact(JOURNAL_PATH, held, (_path, hooks) => removeJournal(hooks)); }
385
+ catch { complete = false; }
386
+ }
366
387
  return {
367
388
  configRestored,
368
389
  profileRestored,
@@ -374,6 +395,24 @@ export function restoreJournalState(): RestoreJournalResult {
374
395
  unverified: false,
375
396
  ...(profileUnchanged && !profileRestored ? { profileRestoreFailed: true as const } : {}),
376
397
  };
398
+ });
399
+ if (!locked.ok) {
400
+ // Busy is not a verdict on the journal — report it as its own fact so a
401
+ // caller cannot mistake it for a failed or unneeded restore. Nothing was
402
+ // read yet, so the changed-file fields cannot be computed.
403
+ return {
404
+ configRestored: false,
405
+ profileRestored: false,
406
+ configRewritten: false,
407
+ profileRewritten: false,
408
+ configChanged: false,
409
+ profileChanged: false,
410
+ complete: false,
411
+ unverified: false,
412
+ ...(locked.error === "unsafe" ? { lockUnsafe: configWriteLockFailureMessage(locked) } : { lockBusy: true as const }),
413
+ };
414
+ }
415
+ return locked.value;
377
416
  }
378
417
 
379
418
  export function restoreJournal(): boolean {
@@ -390,18 +429,21 @@ export interface ReconcileJournalOptions {
390
429
  }
391
430
 
392
431
  export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean {
432
+ if (codexHomeIsAbsent(CODEX_HOME)) return false;
433
+ const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, handle => {
393
434
  try { assertCodexHomeOwner(CODEX_HOME); }
394
435
  catch (error) {
395
436
  if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
396
437
  console.error(error.message);
397
438
  return false;
398
439
  }
440
+ beginCodexWriteSection(handle);
399
441
  const journal = readJournal();
400
442
  if (!journal) return false;
401
- const owner = journalOwner();
443
+ const owner = journal.owner ?? (Number.isSafeInteger(journal.pid) && journal.pid > 0 ? { kind: "process" as const, pid: journal.pid } : null);
402
444
  if (owner?.kind === "client") {
403
445
  if (options.activeClientApiKeyId === owner.apiKeyId) return false;
404
- const restored = restoreJournalState();
446
+ const restored = restoreJournalState({ heldConfigWriteLock: handle });
405
447
  if (restored.unverified) {
406
448
  console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
407
449
  return false;
@@ -425,7 +467,7 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
425
467
  return false;
426
468
  }
427
469
  }
428
- const restored = restoreJournalState();
470
+ const restored = restoreJournalState({ heldConfigWriteLock: handle });
429
471
  if (restored.unverified) {
430
472
  console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
431
473
  return false;
@@ -440,4 +482,7 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
440
482
  if (!restored.configRewritten && !restored.profileRewritten) { warnIfJournalRetained(); return false; }
441
483
  console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`);
442
484
  return true;
485
+ });
486
+ if (!locked.ok && locked.error === "unsafe") console.error(configWriteLockFailureMessage(locked));
487
+ return locked.ok ? locked.value : false;
443
488
  }
@@ -1,4 +1,5 @@
1
1
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
2
3
  import type { StoredAccountQuota } from "./quota-types";
3
4
  import { truncateRetainedUtf8 } from "../lib/admission";
4
5
 
@@ -73,6 +74,48 @@ export function isMainQuotaWriterLive(writer: MainQuotaWriter): boolean {
73
74
  && writer.identityGeneration === mainAccountIdentityGeneration;
74
75
  }
75
76
 
77
+ /** Proof that a dispatch used the observed main credential; process-local, never persisted. */
78
+ export type MainQuotaDispatch = Readonly<{
79
+ writer: MainQuotaWriter;
80
+ credentialGeneration: number;
81
+ credentialMutationEpoch: number;
82
+ configGeneration: number;
83
+ }>;
84
+
85
+ // WS quota frames publish through their observer; prelude quota can only come from those frames.
86
+ // A real HTTP fallback after a failed upgrade never invokes the observer and stays unclaimed.
87
+ const wsObservedMainDispatches = new WeakSet<MainQuotaDispatch>();
88
+
89
+ export function claimMainQuotaDispatchForWs(dispatch: MainQuotaDispatch): void {
90
+ wsObservedMainDispatches.add(dispatch);
91
+ }
92
+
93
+ export function isMainQuotaDispatchWsClaimed(dispatch: MainQuotaDispatch): boolean {
94
+ return wsObservedMainDispatches.has(dispatch);
95
+ }
96
+
97
+ /** Give a replacement physical attempt its own quota ownership without recapturing credential fences. */
98
+ export function renewMainQuotaDispatchForAttempt(dispatch: MainQuotaDispatch): MainQuotaDispatch {
99
+ return { ...dispatch };
100
+ }
101
+
102
+ export function captureMainQuotaDispatch(
103
+ accessToken: string, accountId: string | undefined, configGeneration: number,
104
+ ): MainQuotaDispatch | undefined {
105
+ if (!accountId || !matchesMainQuotaCredential(accessToken, accountId)) return undefined;
106
+ const writer = captureMainQuotaWriter(accountId);
107
+ return writer ? { writer, credentialGeneration: mainQuotaCredentialGeneration,
108
+ credentialMutationEpoch: codexCredentialMutationEpoch(), configGeneration } : undefined;
109
+ }
110
+
111
+ export function isMainQuotaDispatchLive(dispatch: MainQuotaDispatch): boolean {
112
+ // Other OpenCodex-owned credential publications also advance this epoch;
113
+ // dropping a main quota update after any such publication is the intended safe direction.
114
+ return isMainQuotaWriterLive(dispatch.writer)
115
+ && dispatch.credentialGeneration === mainQuotaCredentialGeneration
116
+ && dispatch.credentialMutationEpoch === codexCredentialMutationEpoch();
117
+ }
118
+
76
119
  export function getObservedMainQuotaIdentityKey(): string | undefined {
77
120
  return observedMainQuotaIdentityKey;
78
121
  }
@@ -510,8 +510,9 @@ async function resolveMainAccountToken(
510
510
  }
511
511
  const refresh = dependencies.refreshToken ?? refreshNativeMainGrant;
512
512
  let refreshed: OAuthCredentials;
513
+ if (signal.aborted) throw signal.reason;
513
514
  try {
514
- refreshed = await refresh(locked.refreshToken, { signal });
515
+ refreshed = await refresh(locked.refreshToken, { signal: refreshTimeout });
515
516
  } catch (cause) {
516
517
  // A refusal already classified at the endpoint keeps that verdict. An injected refresh
517
518
  // threw an ordinary Error and has only its message, so prose still decides there --
@@ -535,16 +536,14 @@ async function resolveMainAccountToken(
535
536
  }
536
537
  throw new MainAccountTokenRefreshError(reason, { cause });
537
538
  }
538
- // The refresh may resolve after the caller went away (an implementation that does
539
- // not observe the signal, or an abort landing in the window between resolution and
540
- // commit). A cancelled request's late refresh must not rewrite auth.json on behalf
541
- // of a request that no longer exists -- the same fence the reauth twin applies
542
- // before its own commit above.
543
- if (dependencies.signal?.aborted) throw dependencies.signal.reason;
539
+ // Once the exchange starts, only its own timeout may abort it: a successful
540
+ // rotating grant retires the old refresh token. Save the only live grant before
541
+ // honoring caller cancellation, while retaining the external-writer guards.
544
542
  const result = persistRefreshedMainAuthJson(locked, refreshed);
545
543
  // A permitted in-flight success speaks only for its own profile and grant.
546
544
  clearMainRefreshGrantRejection(mainRefreshGrantKey(locked)!);
547
545
  if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
546
+ if (dependencies.signal?.aborted) throw dependencies.signal.reason;
548
547
  return result;
549
548
  }),
550
549
  { waitMs: 30_000, signal },
@@ -2,6 +2,7 @@ import { readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { isAbsolute, join, resolve } from "node:path";
3
3
  import { expandUserPath } from "../config";
4
4
  import { defaultCodexHome } from "./home";
5
+ import { codexHomeIsAbsent } from "./codex-home-owner";
5
6
 
6
7
  function resolveCodexHome(): string {
7
8
  const raw = process.env.CODEX_HOME?.trim();
@@ -11,6 +12,7 @@ function resolveCodexHome(): string {
11
12
  try {
12
13
  stat = statSync(path);
13
14
  } catch (err) {
15
+ if ((err as NodeJS.ErrnoException).code === "ENOENT" && codexHomeIsAbsent(path)) return path;
14
16
  const message = err instanceof Error ? err.message : String(err);
15
17
  throw new Error(`CODEX_HOME points to ${raw}, but that path could not be read: ${message}`);
16
18
  }
@@ -0,0 +1,18 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { codexHomeIsAbsent } from "./codex-home-owner";
4
+
5
+ /**
6
+ * Create a proven-missing home before a writer that has always created it.
7
+ * A failed request keeps the directory: a pathname-based removal cannot prove it
8
+ * still names the directory this request created, so deleting it could remove a
9
+ * concurrent replacement. An empty home is what Codex itself would create.
10
+ */
11
+ export function prepareCodexHome(home: string, mode?: number): void {
12
+ if (!codexHomeIsAbsent(home)) return;
13
+ mkdirSync(dirname(home), { recursive: true, mode });
14
+ try { mkdirSync(home, { mode }); }
15
+ catch (error) {
16
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
17
+ }
18
+ }
@@ -1,3 +1,5 @@
1
+ import { configWriteLockFailureMessage, withConfigWriteLockHeld, publishConfigWrite, watchConfigWriteTargets, publishConfigWriteTarget, type LockHandle } from "./config-write-lock";
2
+ import { codexHomeIsAbsent } from "./codex-home-owner";
1
3
  /**
2
4
  * prompt-journal.ts — the write transaction behind the prompt-layer surface.
3
5
  *
@@ -19,6 +21,7 @@
19
21
  * concrete: crash after writing config.toml, user or Codex then edits it,
20
22
  * recovery sees a mismatch and overwrites their work with a stale image.
21
23
  */
24
+ import type { AtomicWriteHooks } from "../config/atomic-write";
22
25
  import { existsSync, mkdirSync, openSync, closeSync, fsyncSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
23
26
  import { dirname, resolve } from "node:path";
24
27
  import { createHash, randomBytes } from "node:crypto";
@@ -68,7 +71,7 @@ function fsyncDir(path: string): void {
68
71
  }
69
72
 
70
73
  /** Write at mode 0600 from creation — a later chmod leaves a readable window. */
71
- export function durableWrite(path: string, content: string): void {
74
+ export function durableWrite(path: string, content: string, hooks: AtomicWriteHooks = {}): void {
72
75
  const tmp = `${path}.ocx.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
73
76
  let fd: number | undefined;
74
77
  try {
@@ -83,7 +86,9 @@ export function durableWrite(path: string, content: string): void {
83
86
  // Windows can refuse the replace with EBUSY/EPERM/EACCES while a scanner
84
87
  // still holds the target; the shared helper retries that briefly. Losing
85
88
  // this publish breaks journal restore, so it should not fail on a blink.
86
- renameAtomicFile(tmp, path, undefined, "prompt-journal");
89
+ hooks.beforeRename?.(tmp, path);
90
+ renameAtomicFile(tmp, path, undefined, "prompt-journal", hooks);
91
+ hooks.afterRename?.(path);
87
92
  // The temp is renamed away: proven absent — release its ACL memos.
88
93
  forgetEphemeralSecretPath(tmp);
89
94
  fsyncDir(path);
@@ -108,9 +113,11 @@ export function durableWrite(path: string, content: string): void {
108
113
  }
109
114
  }
110
115
 
111
- export function durableDelete(path: string): void {
116
+ export function durableDelete(path: string, hooks: AtomicWriteHooks = {}): void {
112
117
  try {
118
+ hooks.validateBeforeRename?.(path);
113
119
  if (existsSync(path)) unlinkSync(path);
120
+ hooks.afterRename?.(path);
114
121
  fsyncDir(path);
115
122
  } catch (error) {
116
123
  // Only absence is the state we wanted; every other deletion failure must
@@ -212,7 +219,7 @@ export function classify(actual: string | null, pre: string, post: string): Targ
212
219
 
213
220
  export type RecoveryOutcome =
214
221
  | { ok: true; action: "none" | "committed" | "rolled-back" }
215
- | { ok: false; error: "recovery_required"; detail: string };
222
+ | { ok: false; error: "recovery_required" | "unsafe"; detail: string };
216
223
 
217
224
  export interface RecoveryTargets {
218
225
  configPath: string;
@@ -247,7 +254,11 @@ export function sameRecoveryPath(
247
254
  export function recoverIfNeeded(
248
255
  journalPath: string,
249
256
  expectedTargets: RecoveryTargets,
257
+ heldConfigWriteLock?: LockHandle,
250
258
  ): RecoveryOutcome {
259
+ if (codexHomeIsAbsent(dirname(expectedTargets.configPath)) && !existsSync(journalPath)) return { ok: true, action: "none" };
260
+ const locked = withConfigWriteLockHeld(expectedTargets.configPath, heldConfigWriteLock, held => {
261
+ watchConfigWriteTargets(held, [journalPath, expectedTargets.storePath]);
251
262
  const raw = readOrNull(journalPath);
252
263
  if (raw === null) return { ok: true, action: "none" };
253
264
 
@@ -285,7 +296,7 @@ export function recoverIfNeeded(
285
296
 
286
297
  if (config === "post" && store === "post") {
287
298
  try {
288
- durableDelete(journalPath);
299
+ publishConfigWriteTarget(expectedTargets.configPath, held, journalPath, (destination, hooks) => durableDelete(destination, hooks));
289
300
  } catch (error) {
290
301
  return {
291
302
  ok: false,
@@ -307,7 +318,7 @@ export function recoverIfNeeded(
307
318
  };
308
319
  }
309
320
  try {
310
- restore(expectedTargets.configPath, record.preConfigBytes);
321
+ publishConfigWrite(expectedTargets.configPath, held, (destination, hooks) => restore(destination, record.preConfigBytes, hooks));
311
322
  } catch (error) {
312
323
  return {
313
324
  ok: false,
@@ -325,7 +336,7 @@ export function recoverIfNeeded(
325
336
  };
326
337
  }
327
338
  try {
328
- restore(expectedTargets.storePath, record.preStoreBytes);
339
+ publishConfigWriteTarget(expectedTargets.configPath, held, expectedTargets.storePath, (destination, hooks) => restore(destination, record.preStoreBytes, hooks));
329
340
  } catch (error) {
330
341
  return {
331
342
  ok: false,
@@ -335,7 +346,7 @@ export function recoverIfNeeded(
335
346
  }
336
347
  }
337
348
  try {
338
- durableDelete(journalPath);
349
+ publishConfigWriteTarget(expectedTargets.configPath, held, journalPath, (destination, hooks) => durableDelete(destination, hooks));
339
350
  } catch (error) {
340
351
  return {
341
352
  ok: false,
@@ -343,10 +354,12 @@ export function recoverIfNeeded(
343
354
  detail: `rollback restored but the journal could not be removed: ${error instanceof Error ? error.message : String(error)}`,
344
355
  };
345
356
  }
346
- return { ok: true, action: "rolled-back" };
357
+ return { ok: true, action: "rolled-back" } as RecoveryOutcome;
358
+ });
359
+ return locked.ok ? locked.value as RecoveryOutcome : { ok: false, error: locked.error === "unsafe" ? "unsafe" : "recovery_required", detail: configWriteLockFailureMessage(locked) };
347
360
  }
348
361
 
349
- function restore(path: string, bytes: string | null): void {
350
- if (bytes === null) durableDelete(path);
351
- else durableWrite(path, bytes);
362
+ function restore(path: string, bytes: string | null, hooks: AtomicWriteHooks): void {
363
+ if (bytes === null) durableDelete(path, hooks);
364
+ else durableWrite(path, bytes, hooks);
352
365
  }
@@ -31,6 +31,7 @@ import { dirname, join, resolve } from "node:path";
31
31
  import { createHash, randomBytes } from "node:crypto";
32
32
  import { expandUserPath } from "../config";
33
33
  import { resolveCodexHomeDir } from "./home";
34
+ import { prepareCodexHome } from "./prepared-home";
34
35
  import {
35
36
  durableWrite,
36
37
  durableWriteExclusive,
@@ -42,6 +43,7 @@ import {
42
43
  type JournalRecord,
43
44
  } from "./prompt-journal";
44
45
  import { release, stillHeld, tryAcquire } from "./prompt-lock";
46
+ import { configWriteLockFailureMessage, withConfigWriteLockHeld, publishConfigWrite, publishConfigWriteTarget, watchConfigWriteTargets, type LockHandle } from "./config-write-lock";
45
47
 
46
48
  // ---------------------------------------------------------------------------
47
49
  // Inventory — ONE definition, consumed by the route and the GUI alike.
@@ -644,7 +646,8 @@ export type WriteError =
644
646
  // which means another writer won a race — here nobody won and nothing landed.
645
647
  | "write_failed"
646
648
  | "recovery_required"
647
- | "locked";
649
+ | "locked"
650
+ | "unsafe";
648
651
 
649
652
  export type WriteResult =
650
653
  | { ok: true; changed: boolean; snapshot: PromptLayerSnapshot }
@@ -669,24 +672,34 @@ function commit(
669
672
  revision: string,
670
673
  build: (snapshot: PromptLayerSnapshot, configBytes: string | null, storeBytes: string | null)
671
674
  => Mutation | { error: WriteError; detail?: string },
675
+ ): WriteResult {
676
+ for (const path of [activeConfigPath(opts), activeStorePath(opts)]) prepareCodexHome(dirname(path), 0o700);
677
+ return commitPrepared(opts, revision, build);
678
+ }
679
+
680
+ function commitPrepared(
681
+ opts: Paths | undefined,
682
+ revision: string,
683
+ build: (snapshot: PromptLayerSnapshot, configBytes: string | null, storeBytes: string | null)
684
+ => Mutation | { error: WriteError; detail?: string },
672
685
  ): WriteResult {
673
686
  const configPath = activeConfigPath(opts);
674
687
  const storePath = activeStorePath(opts);
675
688
  const journalPath = journalPathFor(storePath);
676
689
  const lockPath = lockPathFor(storePath);
677
690
 
678
- ensureDir(configPath);
679
- ensureDir(storePath);
680
-
681
691
  const acquired = tryAcquire(lockPath);
682
- if (!acquired.ok) return { ok: false, error: "locked" };
692
+ if (!acquired.ok) return { ok: false, error: acquired.error, ...(acquired.error === "unsafe" ? { detail: configWriteLockFailureMessage(acquired) } : {}) };
683
693
  const handle = acquired.handle;
684
694
 
685
695
  try {
696
+ // Store -> config; cleanup protection starts before config acquisition.
697
+ const configLocked = withConfigWriteLockHeld(configPath, undefined, configHandle => {
698
+ watchConfigWriteTargets(configHandle, [storePath, journalPath]);
686
699
  // 1. recovery first: a journal on disk means an earlier attempt never
687
700
  // committed, and we must not stack a second transaction on top of it.
688
- const recovered = recoverJournal(journalPath, { configPath, storePath });
689
- if (!recovered.ok) return { ok: false, error: "recovery_required", detail: recovered.detail };
701
+ const recovered = recoverJournal(journalPath, { configPath, storePath }, configHandle);
702
+ if (!recovered.ok) return { ok: false, error: recovered.error, detail: recovered.detail };
690
703
 
691
704
  // 2. re-read and compare against the caller's edit base.
692
705
  const configBytes = readFileOrNull(configPath);
@@ -722,7 +735,7 @@ function commit(
722
735
  preStoreBytes: storeBytes,
723
736
  postStoreBytes: nextStore,
724
737
  };
725
- durableWrite(journalPath, encodeJournal(record));
738
+ publishConfigWriteTarget(configPath, configHandle, journalPath, (destination, hooks) => durableWrite(destination, encodeJournal(record), hooks));
726
739
 
727
740
  // 4/5. each target re-verifies ITS OWN bytes immediately before its rename,
728
741
  // so a third party writing between step 2 and here is not overwritten.
@@ -740,24 +753,28 @@ function commit(
740
753
  try {
741
754
  if (configChanged) {
742
755
  if (hashBytes(readFileOrNull(configPath)) !== record.preConfig) {
743
- return rollback(record, journalPath, "stale_revision");
756
+ return rollback(record, journalPath, "stale_revision", configHandle);
744
757
  }
745
- if (nextConfig === null) durableDelete(configPath);
746
- else durableWrite(configPath, nextConfig);
758
+ publishConfigWrite(configPath, configHandle, (destination, hooks) => {
759
+ if (nextConfig === null) durableDelete(destination, hooks);
760
+ else durableWrite(destination, nextConfig, hooks);
761
+ });
747
762
  }
748
763
  if (storeChanged) {
749
764
  if (hashBytes(readFileOrNull(storePath)) !== record.preStore) {
750
- return rollback(record, journalPath, "stale_revision");
765
+ return rollback(record, journalPath, "stale_revision", configHandle);
751
766
  }
752
- if (nextStore === null) durableDelete(storePath);
753
- else durableWrite(storePath, nextStore);
767
+ publishConfigWriteTarget(configPath, configHandle, storePath, (destination, hooks) => {
768
+ if (nextStore === null) durableDelete(destination, hooks);
769
+ else durableWrite(destination, nextStore, hooks);
770
+ });
754
771
  }
755
772
  } catch (error) {
756
773
  // `rollback` is byte-hash driven and refuses to touch a file it does not
757
774
  // recognise, so it is safe to run against a partially applied pair. If it
758
775
  // cannot account for what it finds it returns recovery_required, which is the
759
776
  // honest answer — better than a silent half-write either way.
760
- const undone = rollback(record, journalPath, "write_failed");
777
+ const undone = rollback(record, journalPath, "write_failed", configHandle);
761
778
  return { ...undone, detail: error instanceof Error ? error.message : String(error) } as WriteResult;
762
779
  }
763
780
 
@@ -768,20 +785,21 @@ function commit(
768
785
  if (finalConfig !== record.postConfig || finalStore !== record.postStore) {
769
786
  return { ok: false, error: "write_superseded" };
770
787
  }
771
- if (!stillHeld(handle)) return { ok: false, error: "write_superseded" };
788
+ if (!stillHeld(handle) || !stillHeld(configHandle)) return { ok: false, error: "write_superseded" };
772
789
 
773
- durableDelete(journalPath); // this deletion is the commit
790
+ publishConfigWriteTarget(configPath, configHandle, journalPath, (destination, hooks) => durableDelete(destination, hooks)); // this deletion is the commit
774
791
  // The FULL opts, not just the two paths this transaction owns: rebuilding the
775
792
  // snapshot from a narrowed object dropped the injected variant directory, so every
776
793
  // successful write reported an empty variant list back to its caller.
777
794
  return { ok: true, changed: true, snapshot: readPromptLayers({ ...opts, configPath, storePath }) };
778
- } finally {
779
- release(handle);
780
- }
795
+ });
796
+ return configLocked.ok ? configLocked.value as WriteResult : { ok: false, error: configLocked.error === "locked" ? "locked" : "unsafe", ...(configLocked.error !== "locked" ? { detail: configWriteLockFailureMessage(configLocked) } : {}) };
797
+ } finally { release(handle); }
781
798
  }
782
799
 
783
800
  /** Undo whatever landed, then drop the journal. Never touches an unknown file. */
784
- function rollback(record: JournalRecord, journalPath: string, error: WriteError): WriteResult {
801
+ function rollback(record: JournalRecord, journalPath: string, error: WriteError, held: LockHandle): WriteResult {
802
+ try {
785
803
  const configNow = hashBytes(readFileOrNull(record.configPath));
786
804
  const storeNow = hashBytes(readFileOrNull(record.storePath));
787
805
  if (configNow !== record.preConfig && configNow !== record.postConfig) {
@@ -791,15 +809,20 @@ function rollback(record: JournalRecord, journalPath: string, error: WriteError)
791
809
  return { ok: false, error: "recovery_required", detail: record.storePath };
792
810
  }
793
811
  if (configNow === record.postConfig) {
794
- if (record.preConfigBytes === null) durableDelete(record.configPath);
795
- else durableWrite(record.configPath, record.preConfigBytes);
812
+ publishConfigWrite(record.configPath, held, (destination, hooks) => {
813
+ if (record.preConfigBytes === null) durableDelete(destination, hooks);
814
+ else durableWrite(destination, record.preConfigBytes, hooks);
815
+ });
796
816
  }
797
817
  if (storeNow === record.postStore) {
798
- if (record.preStoreBytes === null) durableDelete(record.storePath);
799
- else durableWrite(record.storePath, record.preStoreBytes);
818
+ publishConfigWriteTarget(record.configPath, held, record.storePath, (destination, hooks) => {
819
+ if (record.preStoreBytes === null) durableDelete(destination, hooks);
820
+ else durableWrite(destination, record.preStoreBytes, hooks);
821
+ });
800
822
  }
801
- durableDelete(journalPath);
823
+ publishConfigWriteTarget(record.configPath, held, journalPath, (destination, hooks) => durableDelete(destination, hooks));
802
824
  return { ok: false, error };
825
+ } catch { return { ok: false, error: "recovery_required" }; }
803
826
  }
804
827
 
805
828
  /** Flip one of the five prompt toggles. */