@bitkyc08/opencodex 2.60.0 → 2.61.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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-CH6C5H7x.js +50 -0
  5. package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
  6. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  7. package/gui/dist/assets/index-wpTOyepx.js +86 -0
  8. package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -3,7 +3,13 @@ import {
3
3
  tryAcquireNativeMainProfileClaim as tryAcquireLifecycleNativeMainProfileClaim,
4
4
  tryClaimNativeMainProfileForTurn as tryClaimLifecycleNativeMainProfileForTurn,
5
5
  } from "../server/lifecycle";
6
+ import {
7
+ MAIN_CODEX_ACCOUNT_ID,
8
+ MainAccountTokenRefreshError,
9
+ MainAuthJsonChangedDuringRefreshError,
10
+ } from "./main-account";
6
11
  import { isNativeMainTrafficBlocked } from "./native-profile-startup";
12
+ import { NativeProfileError } from "./native-profile-types";
7
13
 
8
14
  export interface NativeMainTurnClaimDeps {
9
15
  /** Test seams for the synchronous precheck/claim/postcheck transition. */
@@ -45,3 +51,80 @@ export function tryAcquireNativeMainProfileClaim(): AdmissionLease | null {
45
51
  claim.release();
46
52
  return null;
47
53
  }
54
+
55
+ export interface NativeMainCredentialAdmissionDeps {
56
+ /** Test seam for the synchronous admission precheck. */
57
+ readonly acquireNativeMain?: () => AdmissionLease | null;
58
+ }
59
+
60
+ const NO_EXCLUDED_ACCOUNT_IDS: ReadonlySet<string> = new Set();
61
+ const NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS: ReadonlySet<string> = new Set([MAIN_CODEX_ACCOUNT_ID]);
62
+ const RELEASE_NOTHING = () => {};
63
+
64
+ /**
65
+ * Run credential-backed work inside the native-main lifecycle fence.
66
+ *
67
+ * The lease keeps startup recovery and profile drains from owning the physical
68
+ * credential while the operation reads it. Cross-process ownership of the file
69
+ * itself is already coordinated inside the refresh path's exclusive claim, so
70
+ * this fence deliberately does not take the shared claim: holding it across an
71
+ * operation that may refresh would ask for exclusive ownership against our own
72
+ * shared lock, and holding it across the upstream work that follows would
73
+ * stall an unrelated credential commit behind a network fetch.
74
+ *
75
+ * The fence covers only the credential read. The operation must invoke
76
+ * `releaseMainLease` as soon as the native-main credential settles — on both
77
+ * the success and the credential-error path — and before any upstream model
78
+ * listing, so a profile drain never waits on a network fetch while this turn
79
+ * is still counted. The wrapper releases on settle regardless, so an
80
+ * operation without a fenced credential phase may ignore the callback.
81
+ *
82
+ * When the gate refuses, or the credential cannot be read because another
83
+ * lifecycle owns it, the operation reruns with main excluded so independent
84
+ * Pool work is never suppressed by main's unavailability.
85
+ */
86
+ export async function withNativeMainCredentialAdmission<T>(
87
+ operation: (
88
+ excludeAccountIds: ReadonlySet<string>,
89
+ releaseMainLease?: () => void,
90
+ ) => Promise<T>,
91
+ deps: NativeMainCredentialAdmissionDeps = {},
92
+ ): Promise<T> {
93
+ const lease = (deps.acquireNativeMain ?? tryAcquireNativeMainProfileClaim)();
94
+ if (!lease) return operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING);
95
+ let released = false;
96
+ const releaseMainLease = () => {
97
+ if (released) return;
98
+ released = true;
99
+ lease.release();
100
+ };
101
+ try {
102
+ return await operation(NO_EXCLUDED_ACCOUNT_IDS, releaseMainLease);
103
+ } catch (error) {
104
+ // The Pool-only retry never reads the native-main credential; release first
105
+ // so a profile drain is not kept waiting behind Pool network work.
106
+ releaseMainLease();
107
+ if (!isNativeMainCredentialUnavailableError(error)) throw error;
108
+ return await operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING);
109
+ } finally {
110
+ releaseMainLease();
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The credential-ownership failures that make main unavailable for one
116
+ * operation: a foreign exclusive holder or an unsupported claim filesystem
117
+ * (NATIVE_MAIN_CLAIM_BUSY / NATIVE_MAIN_CLAIM_UNAVAILABLE), a writer that moved
118
+ * auth.json mid-refresh, or a grant that no longer refreshes. Like a Pool
119
+ * credential failure, none of them may suppress independent Pool discovery.
120
+ * Any other NativeProfileError — MAIN_REQUESTS_ACTIVE, VAULT_INVALID,
121
+ * INTERNAL_ERROR — is not a credential-ownership failure and propagates.
122
+ */
123
+ function isNativeMainCredentialUnavailableError(error: unknown): boolean {
124
+ if (error instanceof NativeProfileError) {
125
+ return error.code === "NATIVE_MAIN_CLAIM_BUSY"
126
+ || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE";
127
+ }
128
+ return error instanceof MainAuthJsonChangedDuringRefreshError
129
+ || error instanceof MainAccountTokenRefreshError;
130
+ }
@@ -350,6 +350,45 @@ export function deleteAccountHealth(accountId: string): void {
350
350
  upstreamHealth.delete(accountId);
351
351
  }
352
352
 
353
+ export function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean {
354
+ return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402;
355
+ }
356
+
357
+ /**
358
+ * Has this account refused a request on quota without serving one since?
359
+ *
360
+ * Thread affinity is a prompt-cache optimization and every rule around it is a preference:
361
+ * `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity`
362
+ * deliberately raises that bar further. A refusal is not a preference, and once the account has
363
+ * told THIS thread it cannot serve, the binding has nothing left to optimize.
364
+ *
365
+ * The distinction matters because the cooldown a 429 writes is deliberately short. A reset
366
+ * announcement is advisory — plan quota routinely frees up before the advertised instant — so
367
+ * {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that
368
+ * announcement describes is not capped, so an account whose burst window is spent looks
369
+ * selectable again long before it is. For an unbound request that is correct: going back to find
370
+ * out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit —
371
+ * the cooldown lapses, the account still scores lowest on the only window this proxy has a
372
+ * reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the
373
+ * identical 429. Cleared affinity does not help: the next request re-derives the same choice.
374
+ * From the Codex side that reads exactly as reported — a new session rotates normally while an
375
+ * existing one is locked to an exhausted account until the proxy is restarted, because a restart
376
+ * is the only thing that drops the binding and the stale health together.
377
+ *
378
+ * `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields}
379
+ * strips it from every recovery write, so it survives exactly until the account actually serves a
380
+ * request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes
381
+ * the account and the first success releases every thread this refused.
382
+ *
383
+ * Scope follows where the refusal was recorded. An account-wide throttle lands in
384
+ * `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native
385
+ * quota group, so a spent Spark window still cannot displace the same thread's Terra binding.
386
+ */
387
+ export function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean {
388
+ if (carriesQuotaRefusal(getAccountHealth(accountId))) return true;
389
+ return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope));
390
+ }
391
+
353
392
  export function listScopedHealthEntries(accountId: string): Array<[CodexQuotaScope, CodexUpstreamHealth]> {
354
393
  return [...(quotaScopedHealth.get(accountId) ?? [])];
355
394
  }
@@ -22,6 +22,7 @@ import {
22
22
  dropSpentCredentialFailure,
23
23
  getAccountHealth,
24
24
  getCodexQuotaHealthSnapshot,
25
+ hasUnrecoveredCodexQuotaRefusal,
25
26
  isCodexAccountSoftAvoided,
26
27
  isCodexQuotaAvoided,
27
28
  isIndependentCodexQuotaScope,
@@ -276,6 +277,40 @@ export function isCacheAffinityEnabled(config: OcxConfig): boolean {
276
277
  return config.pool?.cacheAffinity !== false;
277
278
  }
278
279
 
280
+ /**
281
+ * Whether quota may retire shared state while cache affinity is active.
282
+ *
283
+ * A threshold crossing is a hint that an account is getting busy, not evidence it cannot
284
+ * serve — the same bar {@link mayRebindAffinityForQuota} applies to a live binding. Shared
285
+ * state held across a model detour gets that exhaustion boundary for the same reason: the
286
+ * detour is request-scoped, so retiring the binding over a hint pays a cold prefix for
287
+ * nothing. New/unbound selection still reads {@link hasCodexQuotaHeadroom}; only
288
+ * preservation of an existing shared selection or thread binding qualifies here. Like the
289
+ * live-binding rule, the configured threshold plays no role once retention applies: a
290
+ * genuinely exhausted (>=100%) account releases even with threshold switching disabled,
291
+ * while the fallback above keeps a disabled threshold's "never drained on quota alone".
292
+ */
293
+ export function hasCodexSharedStateQuotaHeadroom(
294
+ config: OcxConfig,
295
+ accountId: string,
296
+ quotaScope: CodexQuotaScope | undefined,
297
+ selectionOptions?: CodexAccountUsabilityOptions,
298
+ now: number = Date.now(),
299
+ ): boolean {
300
+ if (
301
+ !isCacheAffinityEnabled(config)
302
+ || accountPoolStrategyForScope(config, quotaScope) !== "quota"
303
+ ) {
304
+ return hasCodexQuotaHeadroom(config, accountId, selectionOptions, now);
305
+ }
306
+ const usage = computeCodexUsageScore(
307
+ getAccountQuota(accountId),
308
+ getPoolAccountPlanForSelection(config, accountId, selectionOptions),
309
+ now,
310
+ );
311
+ return isUnknownUsage(usage) || usage < 100;
312
+ }
313
+
279
314
  /** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */
280
315
  export function pickResetFirstCodexAccount(
281
316
  config: OcxConfig,
@@ -726,7 +761,8 @@ export function isHealthySharedCodexSelection(
726
761
  selectionOptions: CodexAccountUsabilityOptions | undefined,
727
762
  ): boolean {
728
763
  return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions)
729
- && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)
764
+ && hasCodexSharedStateQuotaHeadroom(config, accountId, quotaScope, selectionOptions, now)
765
+ && !hasUnrecoveredCodexQuotaRefusal(accountId, quotaScope)
730
766
  && !shouldFailover(config, accountId, now);
731
767
  }
732
768
 
@@ -28,6 +28,7 @@ import {
28
28
  type CodexUpstreamOutcomeMeta,
29
29
  } from "./routing/cooldown-math";
30
30
  import {
31
+ carriesQuotaRefusal,
31
32
  codexPoolKeyForScope,
32
33
  codexQuotaScopeForModel,
33
34
  deleteAccountHealth,
@@ -38,6 +39,7 @@ import {
38
39
  getCodexAccountCooldownUntil,
39
40
  getCodexAccountSoftAvoidUntil,
40
41
  getCodexQuotaHealthSnapshot,
42
+ hasUnrecoveredCodexQuotaRefusal,
41
43
  isCodexAccountSoftAvoided,
42
44
  isCodexQuotaAvoided,
43
45
  isHealthAccountAdmissible,
@@ -93,6 +95,7 @@ import {
93
95
  getEligiblePoolAccounts,
94
96
  getPoolAccountPlanForSelection,
95
97
  hasCodexQuotaHeadroom,
98
+ hasCodexSharedStateQuotaHeadroom,
96
99
  isCodexAccountPlanExcluded,
97
100
  isCodexAccountSelectable,
98
101
  isHealthySharedCodexSelection,
@@ -489,45 +492,6 @@ export function resolveCodexAccountForThread(
489
492
  return resolution.status === "selected" ? resolution.accountId : null;
490
493
  }
491
494
 
492
- function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean {
493
- return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402;
494
- }
495
-
496
- /**
497
- * Has this account refused a request on quota without serving one since?
498
- *
499
- * Thread affinity is a prompt-cache optimization and every rule around it is a preference:
500
- * `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity`
501
- * deliberately raises that bar further. A refusal is not a preference, and once the account has
502
- * told THIS thread it cannot serve, the binding has nothing left to optimize.
503
- *
504
- * The distinction matters because the cooldown a 429 writes is deliberately short. A reset
505
- * announcement is advisory — plan quota routinely frees up before the advertised instant — so
506
- * {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that
507
- * announcement describes is not capped, so an account whose burst window is spent looks
508
- * selectable again long before it is. For an unbound request that is correct: going back to find
509
- * out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit —
510
- * the cooldown lapses, the account still scores lowest on the only window this proxy has a
511
- * reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the
512
- * identical 429. Cleared affinity does not help: the next request re-derives the same choice.
513
- * From the Codex side that reads exactly as reported — a new session rotates normally while an
514
- * existing one is locked to an exhausted account until the proxy is restarted, because a restart
515
- * is the only thing that drops the binding and the stale health together.
516
- *
517
- * `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields}
518
- * strips it from every recovery write, so it survives exactly until the account actually serves a
519
- * request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes
520
- * the account and the first success releases every thread this refused.
521
- *
522
- * Scope follows where the refusal was recorded. An account-wide throttle lands in
523
- * `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native
524
- * quota group, so a spent Spark window still cannot displace the same thread's Terra binding.
525
- */
526
- function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean {
527
- if (carriesQuotaRefusal(getAccountHealth(accountId))) return true;
528
- return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope));
529
- }
530
-
531
495
  function previewReusableAffinityAccount(
532
496
  entry: ThreadAffinityEntry | undefined,
533
497
  config: OcxConfig,
@@ -952,7 +916,7 @@ export function resolveCodexAccountForThreadDetailed(
952
916
  // the account has already told this thread it cannot serve it.
953
917
  const quotaRefused = hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope);
954
918
  const healthyForSharedAffinity = selectableForSharedState
955
- && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions, now)
919
+ && hasCodexSharedStateQuotaHeadroom(config, entry.accountId, quotaScope, sharedSelectionOptions, now)
956
920
  && !quotaRefused
957
921
  && !failoverReady;
958
922
  if (
@@ -1150,7 +1114,7 @@ export function resolveCodexAccountForThreadDetailed(
1150
1114
  sharedSelectionOptions,
1151
1115
  );
1152
1116
  const activeHealthyForSharedSelection = activeSelectableForSharedState
1153
- && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions, now)
1117
+ && hasCodexSharedStateQuotaHeadroom(config, active, quotaScope, sharedSelectionOptions, now)
1154
1118
  && !shouldFailover(config, active, now);
1155
1119
  if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
1156
1120
  const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
@@ -4,11 +4,29 @@ import { serviceApiTokenFilePath } from "../lib/service-secrets";
4
4
  import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
5
5
 
6
6
  const SHIM_MARKER = "opencodex codex autostart shim";
7
- const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2";
7
+ const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 3";
8
8
 
9
9
  const CODEX_SHIM_REENTRY_EXIT_CODE = 126;
10
10
  const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart.";
11
11
 
12
+ /**
13
+ * Said once, on stderr, when `ocx ensure` could not bring the proxy up (#5261).
14
+ *
15
+ * The shim used to discard both of ensure's streams and ignore its exit status, so a failed
16
+ * autostart was completely silent: Codex launched against injected routing pointing at a port
17
+ * nothing was listening on, and every request — sign-in included — failed with no mention of
18
+ * opencodex anywhere.
19
+ *
20
+ * Ensure's own streams stay discarded rather than being let through. Ensure prints progress and
21
+ * warnings on exit-zero runs too, and a wrapper that leaked those would put noise in front of
22
+ * every ordinary Codex launch, which is how a diagnostic gets ignored. The exit status is the
23
+ * signal; this line is the whole message.
24
+ *
25
+ * It names `ocx restore` because bringing the proxy back is only half the choice. A user who
26
+ * cannot sign in needs the way out that does not require the proxy at all.
27
+ */
28
+ export const CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC = "opencodex: proxy autostart failed; launching Codex anyway. Run 'ocx doctor' for details, or 'ocx restore' to hand Codex back to its own account.";
29
+
12
30
  const CODEX_INTERNAL_COMMANDS = [
13
31
  "app-server",
14
32
  "archive",
@@ -136,7 +154,9 @@ case "$ocx_subcommand" in
136
154
  ;;
137
155
  *)
138
156
  if [ -z "$OCX_SHIM_BYPASS" ]; then
139
- ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1 || true
157
+ if ! ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1; then
158
+ printf '%s\\n' ${shQuote(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)} >&2
159
+ fi
140
160
  fi
141
161
  ;;
142
162
  esac
@@ -197,6 +217,7 @@ setlocal\r
197
217
  ${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r
198
218
  ${windowsBatchSet(BUN_RUNTIME_PATH_ENV, bunPath)}\r
199
219
  "%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r
220
+ if errorlevel 1 echo ${CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC} 1>&2\r
200
221
  endlocal\r
201
222
  :run_codex\r
202
223
  "%OCX_REAL_CODEX%" %*\r
@@ -239,13 +260,18 @@ if (-not $skipEnsure) {
239
260
  $priorRuntimePath = $env:${BUN_RUNTIME_PATH_ENV}
240
261
  $env:${BUN_RUNTIME_SOURCE_ENV} = ${psString(bunRuntimeSource)}
241
262
  $env:${BUN_RUNTIME_PATH_ENV} = ${psString(bunPath)}
242
- try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null }
263
+ $ocxEnsureFailed = $false
264
+ # Caught, not propagated: a throwing ensure used to escape this wrapper and Codex never
265
+ # launched at all, which is a lockout produced by the autostart helper itself (#5261).
266
+ try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null; if ($LASTEXITCODE -ne 0) { $ocxEnsureFailed = $true } }
267
+ catch { $ocxEnsureFailed = $true }
243
268
  finally {
244
269
  if ($null -eq $priorRuntimeSource) { Remove-Item Env:\\${BUN_RUNTIME_SOURCE_ENV} -ErrorAction SilentlyContinue }
245
270
  else { $env:${BUN_RUNTIME_SOURCE_ENV} = $priorRuntimeSource }
246
271
  if ($null -eq $priorRuntimePath) { Remove-Item Env:\\${BUN_RUNTIME_PATH_ENV} -ErrorAction SilentlyContinue }
247
272
  else { $env:${BUN_RUNTIME_PATH_ENV} = $priorRuntimePath }
248
273
  }
274
+ if ($ocxEnsureFailed) { [Console]::Error.WriteLine(${psString(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)}) }
249
275
  }
250
276
  & ${psString(realCodexPath)} @args
251
277
  $codexExitCode = $LASTEXITCODE
@@ -0,0 +1,132 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getConfigDir } from "../config/paths";
4
+ import {
5
+ TIMELINE_HOURS,
6
+ isTimelineModelId,
7
+ type TimelineAggregation,
8
+ type TimelineGrouping,
9
+ type TimelineMetric,
10
+ } from "../usage/timeline";
11
+
12
+ export interface CompanionSettings {
13
+ menuBarMetric: "requests" | "tokens" | "cost" | "quota" | "none";
14
+ menuBarTemplate: string | null;
15
+ showToday: boolean;
16
+ showChart: boolean;
17
+ showModels: boolean;
18
+ showCost: boolean;
19
+ showAccounts: boolean;
20
+ chartHours: typeof TIMELINE_HOURS[number];
21
+ bucketMinutes: number;
22
+ chartStyle: "line" | "stackedBar";
23
+ tokenMetric: TimelineMetric;
24
+ aggregation: TimelineAggregation;
25
+ chartGrouping: TimelineGrouping;
26
+ models: string[] | null;
27
+ hiddenProviders: string[];
28
+ }
29
+
30
+ export const DEFAULT_COMPANION_SETTINGS: CompanionSettings = {
31
+ menuBarMetric: "tokens",
32
+ menuBarTemplate: null,
33
+ showToday: true,
34
+ showChart: true,
35
+ showModels: true,
36
+ showCost: true,
37
+ showAccounts: true,
38
+ chartHours: 24,
39
+ bucketMinutes: 60,
40
+ chartStyle: "line",
41
+ tokenMetric: "total",
42
+ aggregation: "sum",
43
+ chartGrouping: "model",
44
+ models: null,
45
+ hiddenProviders: [],
46
+ };
47
+
48
+ const TEMPLATE_FIELDS = new Set(["requests", "totalTokens", "inputTokens", "outputTokens", "costUsd", "quotaPercent"]);
49
+ const MENU_BAR_METRICS = new Set(["requests", "tokens", "cost", "quota", "none"]);
50
+ const CHART_STYLES = new Set(["line", "stackedBar"]);
51
+ const TIMELINE_METRICS = new Set(["total", "input", "output", "cached"]);
52
+ const AGGREGATIONS = new Set(["sum", "average", "max"]);
53
+ const GROUPINGS = new Set(["model", "modelAccount"]);
54
+ const SETTINGS_KEYS = Object.keys(DEFAULT_COMPANION_SETTINGS) as (keyof CompanionSettings)[];
55
+
56
+ export function companionSettingsPath(): string {
57
+ return join(getConfigDir(), "companion.json");
58
+ }
59
+
60
+ function invalid(message: string): { error: string } {
61
+ return { error: message };
62
+ }
63
+
64
+ function validModels(value: unknown, key: string): value is string[] | null {
65
+ return value === null
66
+ || (Array.isArray(value)
67
+ && value.length <= 100
68
+ && value.every(isTimelineModelId));
69
+ }
70
+
71
+ function validateValue(key: keyof CompanionSettings, value: unknown): string | null {
72
+ if (key === "menuBarMetric") return typeof value === "string" && MENU_BAR_METRICS.has(value) ? null : "menuBarMetric is invalid";
73
+ if (key === "menuBarTemplate") {
74
+ if (value === null) return null;
75
+ if (typeof value !== "string" || value.length > 200) return "menuBarTemplate must be null or at most 200 characters";
76
+ for (const match of value.matchAll(/\{([^{}]+)\}/g)) {
77
+ if (!TEMPLATE_FIELDS.has(match[1]!)) return `menuBarTemplate contains unknown placeholder: ${match[1]}`;
78
+ }
79
+ return null;
80
+ }
81
+ if (["showToday", "showChart", "showModels", "showCost", "showAccounts"].includes(key)) {
82
+ return typeof value === "boolean" ? null : `${key} must be a boolean`;
83
+ }
84
+ if (key === "chartHours") return TIMELINE_HOURS.includes(value as typeof TIMELINE_HOURS[number]) ? null : "chartHours is invalid";
85
+ if (key === "bucketMinutes") return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 1440 ? null : "bucketMinutes must be an integer from 1 through 1440";
86
+ if (key === "chartStyle") return typeof value === "string" && CHART_STYLES.has(value) ? null : "chartStyle is invalid";
87
+ if (key === "tokenMetric") return typeof value === "string" && TIMELINE_METRICS.has(value) ? null : "tokenMetric is invalid";
88
+ if (key === "aggregation") return typeof value === "string" && AGGREGATIONS.has(value) ? null : "aggregation is invalid";
89
+ if (key === "chartGrouping") return typeof value === "string" && GROUPINGS.has(value) ? null : "chartGrouping is invalid";
90
+ if (key === "models") return validModels(value, key) ? null : "models must be null or at most 100 provider/model identifiers";
91
+ if (key === "hiddenProviders") return Array.isArray(value) && value.length <= 100 && value.every(item => typeof item === "string" && item.length > 0 && !/\s/.test(item))
92
+ ? null : "hiddenProviders must contain at most 100 provider names";
93
+ return `${key} is unsupported`;
94
+ }
95
+
96
+ export function applyCompanionSettingsPatch(
97
+ current: CompanionSettings,
98
+ patch: unknown,
99
+ ): CompanionSettings | { error: string } {
100
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) return invalid("settings must be an object");
101
+ const values = patch as Record<string, unknown>;
102
+ for (const key of Object.keys(values)) {
103
+ if (!SETTINGS_KEYS.includes(key as keyof CompanionSettings)) return invalid(`unknown settings key: ${key}`);
104
+ const error = validateValue(key as keyof CompanionSettings, values[key]);
105
+ if (error) return invalid(error);
106
+ }
107
+ return { ...current, ...values } as CompanionSettings;
108
+ }
109
+
110
+ export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null; corrupt?: true } {
111
+ const path = companionSettingsPath();
112
+ if (!existsSync(path)) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null };
113
+ try {
114
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
115
+ const settings = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, parsed);
116
+ if ("error" in settings) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true };
117
+ return { settings, updatedAt: statSync(path).mtimeMs };
118
+ } catch {
119
+ return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true };
120
+ }
121
+ }
122
+
123
+ export function saveCompanionSettings(settings: CompanionSettings): void {
124
+ const path = companionSettingsPath();
125
+ const dir = getConfigDir();
126
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
127
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
128
+ writeFileSync(temp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
129
+ chmodSync(temp, 0o600);
130
+ renameSync(temp, path);
131
+ chmodSync(path, 0o600);
132
+ }
@@ -2,6 +2,7 @@ import {
2
2
  chmodSync,
3
3
  closeSync,
4
4
  fchmodSync,
5
+ fsyncSync,
5
6
  fstatSync,
6
7
  lstatSync,
7
8
  openSync,
@@ -10,7 +11,7 @@ import {
10
11
  unlinkSync,
11
12
  writeFileSync,
12
13
  } from "node:fs";
13
- import { dirname } from "node:path";
14
+ import { basename, dirname, join } from "node:path";
14
15
  import { recordOwnedConfigPath } from "../lib/config-ownership";
15
16
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
16
17
  import {
@@ -162,6 +163,27 @@ function carryHardenAcrossContentWrite(path: string): void {
162
163
  reattributeHardenedSecretPath(path);
163
164
  }
164
165
 
166
+ /**
167
+ * Commit the directory entry a rename just wrote.
168
+ *
169
+ * Best effort by platform, not by importance: Windows has no directory descriptor to sync and
170
+ * some filesystems refuse the open, and failing a replacement that already happened would be
171
+ * worse than reporting it. The throw that matters is the temp's own `fsync`, which runs before
172
+ * the rename and stops it.
173
+ */
174
+ function syncParentDirectory(target: string): void {
175
+ if (process.platform === "win32") return;
176
+ let descriptor: number | undefined;
177
+ try {
178
+ descriptor = openSync(dirname(target), "r");
179
+ fsyncSync(descriptor);
180
+ } catch {
181
+ /* the rename already landed; a directory that cannot be synced is not a reason to undo it */
182
+ } finally {
183
+ if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* already closed */ } }
184
+ }
185
+ }
186
+
165
187
  function writePrivateTempFile(
166
188
  path: string,
167
189
  content: string,
@@ -191,6 +213,40 @@ function writePrivateTempFile(
191
213
  carryHardenAcrossContentWrite(path);
192
214
  }
193
215
 
216
+ /**
217
+ * The same private temp, filled by a writer that streams into the descriptor.
218
+ *
219
+ * For content that must not be held in memory as one string. The identity assertions, the
220
+ * ownership handshake and the hardening are the same; the difference is that the bytes arrive in
221
+ * bounded chunks and the descriptor is flushed before it closes.
222
+ *
223
+ * The `fsync` is not optional here and its failure is not swallowed. A replacement whose
224
+ * REPLACEMENT is not on disk can lose the rows it was supposed to retain, so the throw is what
225
+ * stops the rename from happening at all.
226
+ */
227
+ function writePrivateTempFileWith(
228
+ path: string,
229
+ write: (descriptor: number) => void,
230
+ timeoutMemoKey: string,
231
+ onCreated: () => void,
232
+ ): void {
233
+ const descriptor = openSync(path, "wx", 0o600);
234
+ onCreated();
235
+ try {
236
+ if (windowsHardeningApplies()) {
237
+ hardenSecretPath(path, { required: true, timeoutMemoKey });
238
+ }
239
+ if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
240
+ assertPrivateTempDescriptor(path, descriptor);
241
+ write(descriptor);
242
+ assertPrivateTempDescriptor(path, descriptor);
243
+ fsyncSync(descriptor);
244
+ } finally {
245
+ closeSync(descriptor);
246
+ }
247
+ carryHardenAcrossContentWrite(path);
248
+ }
249
+
194
250
  async function writePrivateTempFileAsync(
195
251
  path: string,
196
252
  content: string,
@@ -215,14 +271,14 @@ async function writePrivateTempFileAsync(
215
271
  carryHardenAcrossContentWrite(path);
216
272
  }
217
273
 
218
- export function atomicWriteFile(
274
+ function atomicWriteFileToTarget(
219
275
  path: string,
220
- content: string,
276
+ content: string | ((descriptor: number) => void),
277
+ target: string,
221
278
  io?: AtomicWriteIO,
222
279
  hooks: AtomicWriteHooks = {},
223
280
  ): void {
224
281
  recordOwnedConfigPath(getConfigDir(), path);
225
- const target = resolveWriteTarget(path);
226
282
  assertResolvedTargetAllowed(path, target);
227
283
  const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
228
284
  let hardened = false;
@@ -246,13 +302,26 @@ export function atomicWriteFile(
246
302
  };
247
303
  try {
248
304
  if (io) ownsTemp = true;
249
- effective.write(tmp, content);
305
+ // A streaming writer bypasses the string form of `write` and nothing else. Every later
306
+ // step -- harden, the pre-rename hooks, the rename and the whole residual-cleanup path,
307
+ // which still scrubs through `effective.write(tmp, "")` -- is shared with the string form.
308
+ if (typeof content === "function") writePrivateTempFileWith(tmp, content, path, () => { ownsTemp = true; });
309
+ else effective.write(tmp, content);
250
310
  hooks.afterTempWrite?.(tmp, target);
251
311
  effective.harden(tmp);
252
312
  hardened = true;
253
313
  hooks.beforeRename?.(tmp, target);
254
314
  hooks.validateBeforeRename?.(target);
255
315
  effective.rename(tmp, target);
316
+ // The rename is only as durable as the directory entry recording it. Fsyncing the temp's
317
+ // CONTENT and then losing the entry in a power cut leaves the old file in place, or the
318
+ // directory in an indeterminate state, while the caller was told the replacement landed.
319
+ //
320
+ // Only the streaming form does this. It is the one that makes a durability claim -- a
321
+ // replacement is not an append, and losing it can lose the rows it was meant to keep -- and
322
+ // adding a directory sync to the string form would charge every config write for a promise
323
+ // its callers have never been given.
324
+ if (typeof content === "function") syncParentDirectory(target);
256
325
  forgetEphemeralSecretPath(tmp);
257
326
  } catch (cause) {
258
327
  if (!ownsTemp) throw cause;
@@ -287,6 +356,49 @@ export function atomicWriteFile(
287
356
  }
288
357
  }
289
358
 
359
+ export function atomicWriteFile(
360
+ path: string,
361
+ content: string,
362
+ io?: AtomicWriteIO,
363
+ hooks: AtomicWriteHooks = {},
364
+ ): void {
365
+ atomicWriteFileToTarget(path, content, resolveWriteTarget(path), io, hooks);
366
+ }
367
+
368
+ /**
369
+ * Atomically replace a file with bytes produced straight into the temporary descriptor.
370
+ *
371
+ * Same publication contract as {@link atomicWriteFile}: an exclusively created private temp, the
372
+ * identity assertions around the write, `hooks.validateBeforeRename` immediately before the
373
+ * rename, the platform-aware replace, and the residual cleanup on any failure. A custom
374
+ * {@link AtomicWriteIO} is not accepted, because the point of this form is that the default
375
+ * writer owns the descriptor.
376
+ */
377
+ export function atomicWriteFileStreamed(
378
+ path: string,
379
+ write: (descriptor: number) => void,
380
+ hooks: AtomicWriteHooks = {},
381
+ ): void {
382
+ atomicWriteFileToTarget(path, write, resolveWriteTarget(path), undefined, hooks);
383
+ }
384
+
385
+ /**
386
+ * Atomically replace the named directory entry without resolving a symlink at
387
+ * that entry. This is for files in directories writable by another process:
388
+ * a raced symlink is replaced, never followed to a more privileged target.
389
+ */
390
+ export function atomicWriteFileNoFollow(
391
+ path: string,
392
+ content: string,
393
+ io?: AtomicWriteIO,
394
+ hooks: AtomicWriteHooks = {},
395
+ ): void {
396
+ // Only the final entry is no-follow: the parent still resolves, because an
397
+ // OS alias above the configured root (a home junction, /tmp) is legitimate
398
+ // and Windows cannot exclusive-create a temp through a junction.
399
+ atomicWriteFileToTarget(path, content, join(resolveWriteTarget(dirname(path)), basename(path)), io, hooks);
400
+ }
401
+
290
402
  export interface AtomicWriteAsyncIO {
291
403
  write: (path: string, content: string) => void | Promise<void>;
292
404
  harden: (path: string) => void | Promise<void>;