@bitkyc08/opencodex 2.80.0 → 2.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (376) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +166 -73
  3. package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
  4. package/gui/dist/assets/Tray-BqHF5Trd.js +1 -0
  5. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  6. package/gui/dist/assets/index-D3FBCNiP.js +94 -0
  7. package/gui/dist/assets/quota-summary-DTBC8n7-.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/claude-green.svg +1 -0
  10. package/package.json +3 -2
  11. package/src/adapters/anthropic-model-contract.ts +8 -2
  12. package/src/adapters/anthropic.ts +8 -3
  13. package/src/adapters/codebuddy/mcp-server.ts +2 -25
  14. package/src/adapters/codebuddy/tool-bridge.ts +1 -7
  15. package/src/adapters/coding-agent/mcp-server.ts +33 -0
  16. package/src/adapters/coding-agent/protocol.ts +122 -17
  17. package/src/adapters/coding-agent/tool-catalog.ts +75 -0
  18. package/src/adapters/coding-agent/turn.ts +101 -44
  19. package/src/adapters/cursor/catalog.ts +8 -0
  20. package/src/adapters/cursor/effort-map.ts +2 -0
  21. package/src/adapters/devin/live-models.ts +2 -0
  22. package/src/adapters/kiro-retry.ts +9 -4
  23. package/src/adapters/openai-chat/messages.ts +7 -6
  24. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  25. package/src/adapters/openai-responses/passthrough.ts +9 -4
  26. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  27. package/src/adapters/physical-send.ts +8 -5
  28. package/src/adapters/qoder/adapter.ts +41 -6
  29. package/src/adapters/qoder/scaffold-guard.ts +2 -3
  30. package/src/adapters/zed.ts +16 -1
  31. package/src/claude/agents-inject.ts +14 -3
  32. package/src/claude/context-windows.ts +19 -17
  33. package/src/claude/desktop-3p.ts +88 -12
  34. package/src/claude/desktop-profile.ts +2 -1
  35. package/src/claude/gateway-cache.ts +18 -1
  36. package/src/claude/inbound-cache-stabilize.ts +4 -9
  37. package/src/claude/inbound.ts +78 -3
  38. package/src/claude/intercept/cli-catalog.ts +3 -0
  39. package/src/claude/intercept/model-bindings.ts +1 -0
  40. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  41. package/src/claude/intercept/picker-listener.ts +71 -4
  42. package/src/claude/intercept/picker-models.ts +14 -6
  43. package/src/claude/intercept/settings.ts +3 -0
  44. package/src/claude/long-context.ts +66 -0
  45. package/src/claude/model-info.ts +19 -16
  46. package/src/claude/outbound.ts +94 -9
  47. package/src/claude/subagent-model.ts +40 -11
  48. package/src/cli/account-anthropic-threshold.ts +14 -5
  49. package/src/cli/account-api.ts +3 -1
  50. package/src/cli/account-extended.ts +13 -9
  51. package/src/cli/account-policy-dto.ts +1 -1
  52. package/src/cli/account-policy.ts +13 -4
  53. package/src/cli/account.ts +3 -3
  54. package/src/cli/agent.ts +9 -5
  55. package/src/cli/aside-profile-recovery.ts +33 -0
  56. package/src/cli/capabilities-accounts.ts +2 -2
  57. package/src/cli/capabilities-agents-routing.ts +24 -0
  58. package/src/cli/capabilities-base.ts +5 -5
  59. package/src/cli/capabilities-integrations.ts +18 -2
  60. package/src/cli/cli-path-diagnostics.ts +132 -0
  61. package/src/cli/codex-shim-autorestore.ts +3 -0
  62. package/src/cli/desktop-runtime-guidance.ts +65 -0
  63. package/src/cli/dispatch.ts +15 -1
  64. package/src/cli/doctor.ts +16 -3
  65. package/src/cli/help.ts +4 -1
  66. package/src/cli/index.ts +25 -14
  67. package/src/cli/integration-aside-sync.ts +2 -1
  68. package/src/cli/integration-plan-dto.ts +1 -0
  69. package/src/cli/integration-preview.ts +8 -1
  70. package/src/cli/integrations.ts +48 -6
  71. package/src/cli/message-args.ts +34 -0
  72. package/src/cli/message-command.ts +43 -0
  73. package/src/cli/message-runtime.ts +6 -0
  74. package/src/cli/observe.ts +3 -1
  75. package/src/cli/provider.ts +29 -6
  76. package/src/cli/registry.ts +24 -2
  77. package/src/cli/resolve.ts +25 -2
  78. package/src/cli/root.ts +4 -2
  79. package/src/cli/status.ts +64 -12
  80. package/src/cli/update-restart-child.ts +4 -2
  81. package/src/cli/update-restart-home.ts +28 -10
  82. package/src/cli/update-restart-service-record.ts +116 -0
  83. package/src/cli/update-restart-supervision.ts +96 -0
  84. package/src/cli/update-restart.ts +59 -13
  85. package/src/cli/v2.ts +27 -20
  86. package/src/cli/version-skew-notice.ts +77 -0
  87. package/src/clients/config-export/commandcode.ts +131 -0
  88. package/src/clients/config-export/contracts.ts +12 -0
  89. package/src/clients/config-export.ts +64 -1
  90. package/src/clients/lazycodex.ts +1 -1
  91. package/src/clients/omo-role-models.ts +32 -7
  92. package/src/codex/auth-api/account-list.ts +11 -3
  93. package/src/codex/auth-api/account-selection.ts +107 -0
  94. package/src/codex/auth-api/main-account-probe.ts +25 -8
  95. package/src/codex/auth-api/pool-mode-gate.ts +33 -9
  96. package/src/codex/auth-api/pool-quota-probe.ts +15 -1
  97. package/src/codex/auth-api/routes.ts +11 -49
  98. package/src/codex/auth-context.ts +20 -1
  99. package/src/codex/autostart-health.ts +12 -3
  100. package/src/codex/catalog/auto-review.ts +13 -6
  101. package/src/codex/catalog/build-entries.ts +33 -7
  102. package/src/codex/catalog/bundled.ts +155 -26
  103. package/src/codex/catalog/combo-member.ts +3 -2
  104. package/src/codex/catalog/control-plane.ts +45 -0
  105. package/src/codex/catalog/effort.ts +37 -6
  106. package/src/codex/catalog/gather-capture.ts +10 -3
  107. package/src/codex/catalog/model-hints.ts +15 -8
  108. package/src/codex/catalog/model-visibility.ts +3 -1
  109. package/src/codex/catalog/operator-tiers.ts +38 -0
  110. package/src/codex/catalog/parsing.ts +2 -1
  111. package/src/codex/catalog/provider-models.ts +58 -44
  112. package/src/codex/catalog/retained-sync.ts +7 -5
  113. package/src/codex/catalog/routed-gather.ts +15 -1
  114. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  115. package/src/codex/catalog-auto-refresh.ts +19 -0
  116. package/src/codex/catalog.ts +1 -1
  117. package/src/codex/codex-home-owner.ts +3 -0
  118. package/src/codex/config-write-lock.ts +333 -0
  119. package/src/codex/control-plane-models.ts +6 -0
  120. package/src/codex/convergence.ts +2 -1
  121. package/src/codex/features.ts +257 -200
  122. package/src/codex/inject/config-write-section.ts +34 -0
  123. package/src/codex/inject/multi-agent-v2.ts +14 -6
  124. package/src/codex/inject/remove.ts +36 -11
  125. package/src/codex/inject/restore.ts +85 -20
  126. package/src/codex/inject-coordination.ts +15 -6
  127. package/src/codex/inject.ts +62 -20
  128. package/src/codex/journal.ts +62 -17
  129. package/src/codex/main-account-cache.ts +43 -0
  130. package/src/codex/paths.ts +2 -0
  131. package/src/codex/prepared-home.ts +18 -0
  132. package/src/codex/prompt-journal.ts +25 -12
  133. package/src/codex/prompt-layers.ts +49 -26
  134. package/src/codex/prompt-lock-claim.ts +134 -0
  135. package/src/codex/prompt-lock-evidence.ts +26 -0
  136. package/src/codex/prompt-lock-io.ts +16 -0
  137. package/src/codex/prompt-lock-owner.ts +130 -0
  138. package/src/codex/prompt-lock.ts +84 -53
  139. package/src/codex/routing/failure-window.ts +146 -0
  140. package/src/codex/routing/selection.ts +4 -1
  141. package/src/codex/routing.ts +13 -17
  142. package/src/codex/runtime.ts +106 -18
  143. package/src/codex/shim.ts +45 -19
  144. package/src/combos/jev-decision-contract.ts +25 -2
  145. package/src/combos/jev-service-exchange.ts +281 -0
  146. package/src/combos/jev.ts +34 -219
  147. package/src/combos/types.ts +1 -1
  148. package/src/config/atomic-write.ts +1 -1
  149. package/src/config/codex-ws-reuse-setting.ts +10 -0
  150. package/src/config/diagnostics.ts +27 -16
  151. package/src/config/live-reconcile.ts +17 -0
  152. package/src/config/load-degrade.ts +42 -0
  153. package/src/config/rebase-provenance.ts +11 -0
  154. package/src/config/schema/anthropic-account-pool.ts +143 -0
  155. package/src/config/schema/config-schema.ts +11 -1
  156. package/src/config/schema/leaf-validators.ts +4 -0
  157. package/src/config.ts +2 -0
  158. package/src/generated/compatibility-version.json +566 -298
  159. package/src/generated/model-metadata.ts +7 -6
  160. package/src/grok/inject.ts +50 -4
  161. package/src/grok/sync.ts +2 -1
  162. package/src/images/loop.ts +30 -11
  163. package/src/integrations/aside-profiles.ts +10 -3
  164. package/src/integrations/catalog-refresh.ts +19 -3
  165. package/src/integrations/mutation-plan.ts +7 -0
  166. package/src/integrations/registry.ts +8 -0
  167. package/src/integrations/state.ts +9 -2
  168. package/src/integrations/writer.ts +17 -5
  169. package/src/lib/bun-path-runtime.d.mts +32 -0
  170. package/src/lib/bun-path-runtime.mjs +95 -0
  171. package/src/lib/bun-runtime-preflight.ts +140 -0
  172. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  173. package/src/lib/desktop-cli-handoff.mjs +92 -0
  174. package/src/lib/desktop-cli-record.d.mts +29 -0
  175. package/src/lib/desktop-cli-record.mjs +102 -0
  176. package/src/lib/lab-live-route-production.ts +9 -1
  177. package/src/lib/request-execution-budget.ts +244 -58
  178. package/src/lib/spend-pool-alias-validation.ts +79 -0
  179. package/src/lib/spend-pool-continuity.ts +34 -0
  180. package/src/lib/spend-reservation-ledger.ts +412 -50
  181. package/src/lib/state-store-registrations.ts +20 -3
  182. package/src/lib/synced-state-location.ts +46 -6
  183. package/src/lib/test-home-guard.ts +87 -1
  184. package/src/lib/translator-budget.ts +17 -4
  185. package/src/lib/upstream-retry.ts +17 -5
  186. package/src/lib/windows-atomic-replace.ts +38 -16
  187. package/src/lib/windows-elevation.ts +6 -0
  188. package/src/lib/windows-secret-acl.ts +92 -25
  189. package/src/lib/winsw.ts +2 -3
  190. package/src/lib/workflow-budget.ts +53 -11
  191. package/src/messaging/budget.ts +45 -0
  192. package/src/messaging/discovery.ts +57 -0
  193. package/src/messaging/envelope.ts +49 -0
  194. package/src/messaging/input.ts +37 -0
  195. package/src/messaging/rpc.ts +196 -0
  196. package/src/messaging/send.ts +58 -0
  197. package/src/messaging/socket.ts +72 -0
  198. package/src/messaging/types.ts +29 -0
  199. package/src/oauth/anthropic-account-refusal.ts +90 -18
  200. package/src/oauth/anthropic-account-threshold.ts +8 -2
  201. package/src/oauth/anthropic-continuity.ts +15 -1
  202. package/src/oauth/anthropic-model-quota.ts +120 -48
  203. package/src/oauth/anthropic-model-routes.ts +11 -3
  204. package/src/oauth/anthropic-oauth-definitions.ts +98 -0
  205. package/src/oauth/anthropic-pool-config.ts +30 -0
  206. package/src/oauth/anthropic-rate-limit-policy.ts +64 -9
  207. package/src/oauth/anthropic-routing.ts +1202 -866
  208. package/src/oauth/anthropic-send-ownership.ts +41 -0
  209. package/src/oauth/anthropic.ts +4 -1
  210. package/src/oauth/chatgpt.ts +113 -41
  211. package/src/oauth/generic-account-failover.ts +3 -1
  212. package/src/oauth/health.ts +8 -6
  213. package/src/oauth/index.ts +96 -175
  214. package/src/oauth/kiro.ts +22 -9
  215. package/src/oauth/login-cli.ts +2 -0
  216. package/src/oauth/model-discovery-auth.ts +43 -0
  217. package/src/oauth/pool-kernel.ts +11 -5
  218. package/src/oauth/pool-settings-capability.ts +5 -3
  219. package/src/oauth/store-anthropic-instance.ts +87 -0
  220. package/src/oauth/store.ts +68 -10
  221. package/src/oauth/token-guardian.ts +2 -0
  222. package/src/oauth/types.ts +2 -0
  223. package/src/protocols/plan-snapshot.ts +5 -0
  224. package/src/protocols/settings.ts +20 -5
  225. package/src/providers/anthropic-instance-id.ts +47 -0
  226. package/src/providers/anthropic-instance.ts +48 -0
  227. package/src/providers/anthropic-reset-grant-ledger.ts +11 -1
  228. package/src/providers/azure-model-metadata.ts +115 -0
  229. package/src/providers/derive.ts +23 -0
  230. package/src/providers/kiro-models.ts +3 -0
  231. package/src/providers/label.ts +1 -1
  232. package/src/providers/model-rename-fields.ts +2 -0
  233. package/src/providers/quota/account-cache.ts +37 -11
  234. package/src/providers/quota/anthropic-account-quota.ts +93 -0
  235. package/src/providers/quota/anthropic-cooldown-recovery.ts +222 -111
  236. package/src/providers/quota/report-cache.ts +24 -1
  237. package/src/providers/quota/vendor-probes-key.ts +127 -17
  238. package/src/providers/quota/vendor-probes-oauth.ts +46 -14
  239. package/src/providers/quota.ts +23 -33
  240. package/src/providers/registry/entries-core.ts +35 -30
  241. package/src/providers/registry/model-ids.ts +1 -0
  242. package/src/providers/registry/model-seeds.ts +7 -4
  243. package/src/providers/registry/types.ts +3 -0
  244. package/src/providers/registry.ts +3 -3
  245. package/src/responses/reasoning-envelope.ts +29 -1
  246. package/src/responses/reasoning-replay-cache.ts +17 -1
  247. package/src/responses/spill-store.ts +20 -3
  248. package/src/responses/state/metrics.ts +95 -0
  249. package/src/responses/state/snapshot-codec.ts +6 -0
  250. package/src/responses/state/spill-failure.ts +12 -0
  251. package/src/responses/state/spill-queue.ts +13 -5
  252. package/src/responses/state/unforced-store-false.ts +55 -0
  253. package/src/responses/state.ts +68 -76
  254. package/src/responses/task-input.ts +17 -0
  255. package/src/router.ts +28 -4
  256. package/src/routing/compatibility/assemble.ts +1 -1
  257. package/src/routing/quota.ts +7 -3
  258. package/src/server/auth-cors.ts +5 -0
  259. package/src/server/background-lifecycle.ts +27 -0
  260. package/src/server/chat-completions.ts +7 -1
  261. package/src/server/chat-native.ts +64 -28
  262. package/src/server/claude-messages.ts +59 -8
  263. package/src/server/gui-static.ts +38 -4
  264. package/src/server/inbound-body-admission.ts +1 -0
  265. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  266. package/src/server/index/serve-options.ts +10 -2
  267. package/src/server/index/spend-ledger-lifecycle.ts +12 -5
  268. package/src/server/index/startup-warnings.ts +19 -0
  269. package/src/server/index.ts +5 -2
  270. package/src/server/inference/context.ts +1 -1
  271. package/src/server/lifecycle.ts +1 -1
  272. package/src/server/management/agent-settings-routes.ts +123 -38
  273. package/src/server/management/anthropic-account-threshold.ts +12 -9
  274. package/src/server/management/anthropic-pool-settings.ts +72 -0
  275. package/src/server/management/anthropic-reset-grant-routes.ts +98 -39
  276. package/src/server/management/codex-agent-role-routes.ts +4 -2
  277. package/src/server/management/codex-prompt-routes.ts +1 -0
  278. package/src/server/management/codex-role-auto-assign.ts +13 -2
  279. package/src/server/management/config-routes.ts +85 -14
  280. package/src/server/management/context.ts +2 -2
  281. package/src/server/management/decision-routes.ts +2 -1
  282. package/src/server/management/integration-routes.ts +25 -2
  283. package/src/server/management/oauth-account-routes.ts +79 -98
  284. package/src/server/management/provider-instance-ownership.ts +44 -0
  285. package/src/server/management/provider-routes.ts +21 -28
  286. package/src/server/management/route-registry.ts +3 -1
  287. package/src/server/management/routing-profile-routes.ts +4 -4
  288. package/src/server/management/shared.ts +3 -3
  289. package/src/server/management/system-restart.ts +44 -14
  290. package/src/server/management/system-routes.ts +24 -2
  291. package/src/server/management/vision-sidecar-options.ts +9 -6
  292. package/src/server/management/web-search-sidecar-options.ts +51 -2
  293. package/src/server/management-auth.ts +6 -0
  294. package/src/server/messages-native-eligibility.ts +3 -2
  295. package/src/server/messages-native-oauth.ts +91 -45
  296. package/src/server/messages-native-selector.ts +39 -0
  297. package/src/server/messages-native.ts +133 -38
  298. package/src/server/messages-response-headers.ts +14 -0
  299. package/src/server/request-log-conversation.ts +48 -5
  300. package/src/server/request-log.ts +36 -2
  301. package/src/server/responses/adapter-continuation.ts +31 -19
  302. package/src/server/responses/adapter-dispatch.ts +75 -24
  303. package/src/server/responses/antigravity-validation-refusal.ts +20 -6
  304. package/src/server/responses/codex-ws-exchange.ts +17 -2
  305. package/src/server/responses/codex-ws-pool.ts +100 -13
  306. package/src/server/responses/codex-ws-wire.ts +9 -0
  307. package/src/server/responses/combo-requested-effort.ts +3 -1
  308. package/src/server/responses/compact.ts +25 -8
  309. package/src/server/responses/compaction-recovery.ts +1 -1
  310. package/src/server/responses/core-codex-account.ts +22 -1
  311. package/src/server/responses/core-combo-native.ts +24 -4
  312. package/src/server/responses/core-combo.ts +38 -25
  313. package/src/server/responses/core-lifetime.ts +3 -36
  314. package/src/server/responses/core-normalize.ts +1 -0
  315. package/src/server/responses/core-options.ts +8 -1
  316. package/src/server/responses/core-replay.ts +78 -0
  317. package/src/server/responses/core.ts +3 -3
  318. package/src/server/responses/fetch-helpers.ts +26 -3
  319. package/src/server/responses/passthrough-delivery.ts +30 -4
  320. package/src/server/responses/passthrough-dispatch.ts +39 -9
  321. package/src/server/responses/request-prepare.ts +41 -5
  322. package/src/server/responses/request-send-budget.ts +150 -50
  323. package/src/server/responses/request-spend.ts +325 -33
  324. package/src/server/responses/request-transport.ts +132 -52
  325. package/src/server/responses/response-effects.ts +1 -0
  326. package/src/server/responses/run-turn-execution.ts +13 -3
  327. package/src/server/responses/sidecar-execution.ts +113 -22
  328. package/src/server/responses/sidecar-send-budget.ts +58 -0
  329. package/src/server/responses/ws-upstream.ts +19 -6
  330. package/src/server/responses-undeclared-tool-guard.ts +5 -1
  331. package/src/server/startup-health-cache.ts +2 -1
  332. package/src/server/workflow-refusal.ts +33 -0
  333. package/src/service/cli.ts +20 -4
  334. package/src/service/desktop-command-guard.ts +32 -0
  335. package/src/service/desktop-startup.ts +66 -40
  336. package/src/service/desktop-supervision.d.mts +26 -0
  337. package/src/service/desktop-supervision.mjs +140 -0
  338. package/src/service/managing-cli.ts +1 -0
  339. package/src/service/orchestration.ts +46 -20
  340. package/src/service/ownership-mutation-lease.d.mts +1 -1
  341. package/src/service/ownership-mutation-lease.mjs +5 -7
  342. package/src/service/repair.ts +25 -9
  343. package/src/service/state.ts +4 -3
  344. package/src/service/systemd.ts +17 -8
  345. package/src/service/windows-ops.ts +61 -22
  346. package/src/sidecar/anthropic-binding.ts +79 -0
  347. package/src/sidecar/auth.ts +76 -2
  348. package/src/storage/policy-job.ts +5 -1
  349. package/src/storage/policy-worker.ts +3 -1
  350. package/src/storage/policy.ts +9 -0
  351. package/src/types/anthropic-account-pool.ts +33 -0
  352. package/src/types/config.ts +33 -28
  353. package/src/types/provider.ts +5 -0
  354. package/src/types/request.ts +5 -0
  355. package/src/types/tools.ts +7 -0
  356. package/src/types/wire.ts +1 -0
  357. package/src/types.ts +4 -0
  358. package/src/update/index.ts +57 -9
  359. package/src/update/restart-ownership.ts +10 -3
  360. package/src/update/runtime-ownership.d.mts +6 -0
  361. package/src/update/runtime-ownership.mjs +23 -7
  362. package/src/usage/cost.ts +3 -2
  363. package/src/usage/expected-prices.ts +66 -14
  364. package/src/vision/anthropic-describe.ts +16 -3
  365. package/src/vision/index.ts +3 -0
  366. package/src/vision/plan.ts +19 -6
  367. package/src/web-search/alpha-search.ts +7 -1
  368. package/src/web-search/anthropic-executor.ts +16 -4
  369. package/src/web-search/index.ts +7 -3
  370. package/src/web-search/loop.ts +32 -7
  371. package/src/web-search/passthrough-bridge.ts +4 -1
  372. package/src/web-search/sidecar-providers.ts +4 -3
  373. package/gui/dist/assets/App-MbDVfUeC.js +0 -54
  374. package/gui/dist/assets/Tray-CzkxK82A.js +0 -1
  375. package/gui/dist/assets/index-UJt6npXk.js +0 -94
  376. package/gui/dist/assets/quota-summary-DU3sD6Z-.js +0 -1
@@ -0,0 +1,134 @@
1
+ import { linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { ownEvidence, ownerState, safeNamespace, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
4
+ import { ChangedLock as ChangedClaim, checkEntry, checkIdentity, guarded, type Entry, type Namespace } from "./prompt-lock-evidence";
5
+ import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
6
+
7
+ export interface ClaimRecord extends OwnerEvidence { ticket: number; token?: string }
8
+ export class UnsafeLockNamespace extends Error {
9
+ constructor(readonly path: string) { super(`Unsafe lock state at ${path}; deliberate removal is required.`); }
10
+ }
11
+ interface AbandonedClaim { token: string; parents: Namespace[]; entry: Entry }
12
+ // Only a completed invocation may enter this map; live/reentrant reservations never do.
13
+ const abandoned = new Map<string, AbandonedClaim>();
14
+ function recoverAbandoned(directory: string, deps: OwnerDeps): void {
15
+ for (const [path, claim] of abandoned) {
16
+ if (dirname(path) !== directory) continue;
17
+ // The token is bound to the captured bytes and unique name, never to PID alone.
18
+ const record = JSON.parse(claim.entry.body) as ClaimRecord;
19
+ if (record.pid !== process.pid || record.token !== claim.token) throw new ChangedClaim("Unknown local claim");
20
+ try { guarded(claim.parents, [claim.entry], deps, () => unlinkSync(path)); }
21
+ catch (error) {
22
+ if (!(error instanceof ChangedClaim) && (error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
23
+ // A replacement is not ours; forget custody without deleting it.
24
+ }
25
+ abandoned.delete(path);
26
+ }
27
+ }
28
+ /** Publish owner evidence together with choosing=0; an empty reservation is never published. */
29
+ export function withLockClaim<T>(
30
+ path: string, token: string, deps: OwnerDeps, run: () => T,
31
+ initialized?: () => void,
32
+ ): { ok: true; value: T } | { ok: false } {
33
+ const io = <R>(operation: () => R): R => lockFileOperation(operation, deps.platform);
34
+ const directory = `${path}.claims`;
35
+ try { recoverAbandoned(directory, deps); }
36
+ catch (error) { if (error instanceof LockFileBusy) return { ok: false }; throw error; }
37
+ if (!safeNamespace(dirname(path), "directory", deps)) throw new UnsafeLockNamespace(dirname(path));
38
+ const root: Namespace = { path: dirname(path), stat: io(() => deps.lstat(dirname(path))) };
39
+ try { guarded([root], [], deps, () => mkdirSync(directory, { mode: 0o700 })); }
40
+ catch (error) {
41
+ if (error instanceof LockFileBusy) return { ok: false };
42
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
43
+ }
44
+ if (!safeNamespace(directory, "directory", deps)) throw new UnsafeLockNamespace(directory);
45
+ const parents = [root, { path: directory, stat: io(() => deps.lstat(directory)) }];
46
+ const claimDirectoryGone = (): boolean => {
47
+ try { const now = deps.lstat(directory); return now.dev !== parents[1].stat.dev || now.ino !== parents[1].stat.ino; }
48
+ catch { return true; }
49
+ };
50
+ const ownName = `${process.pid}-${token}.claim`, ownPath = join(directory, ownName);
51
+ const temporary = `${path}.claim-init-${token}`;
52
+ let ownEntry: Entry | undefined, temporaryEntry: Entry | undefined;
53
+ const evidence = { ...ownEvidence(deps), token };
54
+ const capture = (entryPath: string, expectedBody?: string): Entry => guarded(parents, [], deps, () => {
55
+ if (!safeNamespace(entryPath, "file", deps, true)) throw new UnsafeLockNamespace(entryPath);
56
+ const entry = { path: entryPath, stat: deps.lstat(entryPath), body: readFileSync(entryPath, "utf8") };
57
+ checkEntry(entry, deps);
58
+ if (expectedBody !== undefined && entry.body !== expectedBody) throw new ChangedClaim("Local claim evidence changed");
59
+ return entry;
60
+ });
61
+ const peers = (): Array<{ name: string; ticket: number }> => {
62
+ const result: Array<{ name: string; ticket: number }> = [];
63
+ for (const name of guarded(parents, [], deps, () => readdirSync(directory))) {
64
+ if (name === ownName) continue;
65
+ const peerPath = join(directory, name);
66
+ if (!/^([1-9][0-9]*)-[0-9a-f]{16}\.claim$/.test(name)
67
+ || !safeNamespace(peerPath, "file", deps, true)) throw new UnsafeLockNamespace(peerPath);
68
+ let entry: Entry, peer: ClaimRecord | null;
69
+ try { entry = capture(peerPath); peer = JSON.parse(entry.body); }
70
+ catch (error) {
71
+ if (error instanceof LockFileBusy || error instanceof UnsafeLockNamespace) throw error;
72
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
73
+ throw new UnsafeLockNamespace(peerPath);
74
+ }
75
+ const state = ownerState(peer, deps);
76
+ if (state === "unsafe") throw new UnsafeLockNamespace(peerPath);
77
+ if (state === "dead") {
78
+ try { guarded(parents, [entry], deps, () => {
79
+ if (ownerState(peer, deps) !== "dead") throw new ChangedClaim("Peer is no longer dead");
80
+ checkEntry(entry, deps);
81
+ for (const parent of parents) checkIdentity(parent, deps);
82
+ unlinkSync(peerPath);
83
+ }); }
84
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
85
+ continue;
86
+ }
87
+ const ticket = peer?.ticket;
88
+ if (typeof ticket !== "number" || !Number.isSafeInteger(ticket) || ticket < 0) throw new UnsafeLockNamespace(peerPath);
89
+ result.push({ name, ticket });
90
+ }
91
+ return result;
92
+ };
93
+ try {
94
+ const choosingBody = JSON.stringify({ ...evidence, ticket: 0 });
95
+ guarded(parents, [], deps, () => writeFileSync(temporary, choosingBody, { mode: 0o600, flag: "wx" }));
96
+ temporaryEntry = capture(temporary, choosingBody);
97
+ guarded(parents, [temporaryEntry], deps, () => linkSync(temporary, ownPath));
98
+ ownEntry = { ...temporaryEntry, path: ownPath };
99
+ guarded(parents, [temporaryEntry], deps, () => unlinkSync(temporary)); temporaryEntry = undefined;
100
+ initialized?.();
101
+ let ticket = 1;
102
+ for (const peer of peers()) ticket = Math.max(ticket, peer.ticket + 1);
103
+ if (!Number.isSafeInteger(ticket)) return { ok: false };
104
+ const ticketBody = JSON.stringify({ ...evidence, ticket });
105
+ guarded(parents, [], deps, () => writeFileSync(temporary, ticketBody, { mode: 0o600, flag: "wx" }));
106
+ temporaryEntry = capture(temporary, ticketBody);
107
+ guarded(parents, [temporaryEntry, ownEntry], deps, () => renameSync(temporary, ownPath));
108
+ ownEntry = { ...temporaryEntry, path: ownPath }; temporaryEntry = undefined;
109
+ for (const peer of peers()) {
110
+ if (peer.ticket === 0 || peer.ticket < ticket || (peer.ticket === ticket && peer.name < ownName)) return { ok: false };
111
+ }
112
+ if (!safeNamespace(path, "file", deps, true)) throw new UnsafeLockNamespace(path);
113
+ return { ok: true, value: run() };
114
+ } catch (error) {
115
+ const code = (error as NodeJS.ErrnoException).code;
116
+ // A peer's final rmdir can remove the claims directory between our identity check and the
117
+ // link/rename into it; platforms report that as ENOENT or (macOS link) EINVAL. Either is contention.
118
+ if (error instanceof LockFileBusy || code === "ENOENT" || (code === "EINVAL" && claimDirectoryGone())) return { ok: false };
119
+ throw error;
120
+ } finally {
121
+ if (temporaryEntry) {
122
+ try { guarded(parents, [temporaryEntry], deps, () => unlinkSync(temporary)); } catch { /* Preserve changed or busy entries. */ }
123
+ }
124
+ if (ownEntry) {
125
+ try { guarded(parents, [ownEntry], deps, () => unlinkSync(ownPath)); }
126
+ catch (error) {
127
+ if (!(error instanceof ChangedClaim) && (error as NodeJS.ErrnoException).code !== "ENOENT") {
128
+ abandoned.set(ownPath, { token, parents, entry: ownEntry });
129
+ }
130
+ }
131
+ try { guarded(parents, [], deps, () => rmdirSync(directory)); } catch { /* A peer or replacement still owns the directory. */ }
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,26 @@
1
+ import { readFileSync, type Stats } from "node:fs";
2
+ import { type OwnerDeps } from "./prompt-lock-owner";
3
+ import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
4
+
5
+ export class ChangedLock extends LockFileBusy {}
6
+ export interface Namespace { path: string; stat: Stats }
7
+ export interface Entry extends Namespace { body: string }
8
+ export function checkIdentity(saved: Namespace, deps: OwnerDeps): void {
9
+ const current = deps.lstat(saved.path);
10
+ if (current.isSymbolicLink() || current.dev !== saved.stat.dev || current.ino !== saved.stat.ino
11
+ || current.uid !== saved.stat.uid || current.isDirectory() !== saved.stat.isDirectory()
12
+ || current.isFile() !== saved.stat.isFile()) throw new ChangedLock("Lock namespace changed");
13
+ }
14
+ export function checkEntry(saved: Entry, deps: OwnerDeps): void {
15
+ checkIdentity(saved, deps);
16
+ if (readFileSync(saved.path, "utf8") !== saved.body) throw new ChangedLock("Claim evidence changed");
17
+ checkIdentity(saved, deps);
18
+ }
19
+ export function guarded<T>(parents: Namespace[], entries: Entry[], deps: OwnerDeps, operation: () => T): T {
20
+ return lockFileOperation(() => {
21
+ for (const parent of parents) checkIdentity(parent, deps);
22
+ for (const entry of entries) checkEntry(entry, deps);
23
+ for (const parent of parents) checkIdentity(parent, deps);
24
+ return operation();
25
+ }, deps.platform);
26
+ }
@@ -0,0 +1,16 @@
1
+ import { retryWindowsFileOperation, transientWindowsReplaceCode } from "../lib/windows-atomic-replace";
2
+
3
+ /** A held Windows handle is contention, never owner-death evidence. */
4
+ export class LockFileBusy extends Error {}
5
+
6
+ export function lockFileOperation<T>(operation: () => T, platform: NodeJS.Platform): T {
7
+ let contended = false;
8
+ try {
9
+ return retryWindowsFileOperation({ run: operation, onRetry: () => { contended = true; } }, { platform, sleep: Bun.sleepSync });
10
+ }
11
+ catch (error) {
12
+ if (transientWindowsReplaceCode(platform, error)
13
+ || (contended && (error as NodeJS.ErrnoException).code === "EEXIST")) throw new LockFileBusy("Lock filesystem operation is busy");
14
+ throw error;
15
+ }
16
+ }
@@ -0,0 +1,130 @@
1
+ import { lstatSync, readFileSync, type Stats } from "node:fs";
2
+ import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsRegExe } from "../lib/windows-elevation";
3
+ import { hostname } from "node:os";
4
+
5
+ import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
6
+
7
+ export interface HostIdentity { hostname: string; machine: string }
8
+ export interface OwnerEvidence { pid: number; host?: HostIdentity; processStart?: string }
9
+ export interface OwnerDeps {
10
+ isProcessAlive: (pid: number) => boolean | undefined;
11
+ hostIdentity: () => HostIdentity | undefined;
12
+ processStart: (pid: number) => string | undefined;
13
+ lstat: (path: string) => Stats;
14
+ uid: () => number | undefined;
15
+ platform: NodeJS.Platform;
16
+ }
17
+
18
+ function command(args: string[], timeoutMs: number): string | undefined {
19
+ try {
20
+ const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe", timeout: timeoutMs });
21
+ const value = result.stdout.toString().trim();
22
+ return result.exitCode === 0 && value ? value : undefined;
23
+ } catch { return undefined; }
24
+ }
25
+ const MACHINE_GUID_KEY = "HKLM\\SOFTWARE\\Microsoft\\Cryptography";
26
+ function parseMachineGuid(output: string | undefined): string | undefined {
27
+ const lines = output?.trim().split(/\r?\n/);
28
+ if (lines?.length !== 2 || lines[0]!.trim().toLowerCase() !== "hkey_local_machine\\software\\microsoft\\cryptography") return undefined;
29
+ return /^[ \t]+MachineGuid[ \t]+REG_SZ[ \t]+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})[ \t]*$/i.exec(lines[1]!)?.[1]?.toLowerCase();
30
+ }
31
+ /** One lazy identity cache per process; unavailable results are cached too. */
32
+ export function createOwnerIdentity({
33
+ platform = process.platform, runCommand = command,
34
+ powerShellExe = resolveTrustedWindowsPowerShellExe,
35
+ regExe = resolveTrustedWindowsRegExe,
36
+ readFile = (path: string) => readFileSync(path, "utf8"),
37
+ }: {
38
+ platform?: NodeJS.Platform;
39
+ runCommand?: (args: string[], timeoutMs: number) => string | undefined;
40
+ powerShellExe?: () => string;
41
+ regExe?: () => string;
42
+ readFile?: (path: string) => string;
43
+ } = {}): Pick<OwnerDeps, "hostIdentity" | "processStart"> {
44
+ let cachedHost: HostIdentity | undefined;
45
+ let hostRead = false;
46
+ function hostIdentity(): HostIdentity | undefined {
47
+ if (hostRead) return cachedHost;
48
+ hostRead = true;
49
+ let machine: string | undefined;
50
+ try {
51
+ if (platform === "linux") {
52
+ for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
53
+ try {
54
+ const value = readFile(path).trim();
55
+ if (/^[0-9a-f]{32}$/i.test(value)) { machine = value.toLowerCase(); break; }
56
+ } catch { /* Try the fallback machine-id file. */ }
57
+ }
58
+ }
59
+ else if (platform === "darwin") machine = runCommand(["/usr/sbin/sysctl", "-n", "kern.bootsessionuuid"], 1_000);
60
+ else if (platform === "win32") machine = parseMachineGuid(runCommand([regExe(), "query", MACHINE_GUID_KEY, "/v", "MachineGuid"], 3_000));
61
+ if (machine) cachedHost = { hostname: hostname(), machine };
62
+ } catch { /* Missing identity means no automatic takeover. */ }
63
+ return cachedHost;
64
+ }
65
+ function probeProcessStart(pid: number): string | undefined {
66
+ try {
67
+ if (platform === "linux") {
68
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
69
+ return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
70
+ }
71
+ if (platform === "darwin") return runCommand(["/bin/ps", "-p", String(pid), "-o", "lstart="], 1_000);
72
+ if (platform === "win32") return runCommand([powerShellExe(), "-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`], 1_000);
73
+ } catch { /* Unknown start identity never proves death. */ }
74
+ return undefined;
75
+ }
76
+ let ownStartRead = false;
77
+ let cachedOwnStart: string | undefined;
78
+ function processStart(pid: number): string | undefined {
79
+ // No spawn-free Windows start-time source is available here. Missing start
80
+ // evidence disables only the PID-reuse check, never the live-PID veto.
81
+ if (platform === "win32" && pid === process.pid) return undefined;
82
+ if (pid !== process.pid) return probeProcessStart(pid);
83
+ if (!ownStartRead) { ownStartRead = true; cachedOwnStart = probeProcessStart(pid); }
84
+ return cachedOwnStart;
85
+ }
86
+ return { hostIdentity, processStart };
87
+ }
88
+ const identity = createOwnerIdentity();
89
+ export const ownerDefaults: OwnerDeps = {
90
+ isProcessAlive(pid) {
91
+ try { process.kill(pid, 0); return true; }
92
+ catch (error) {
93
+ const code = (error as NodeJS.ErrnoException).code;
94
+ return code === "ESRCH" ? false : code === "EPERM" ? true : undefined;
95
+ }
96
+ },
97
+ ...identity, lstat: lstatSync,
98
+ uid: () => process.getuid?.(), platform: process.platform,
99
+ };
100
+ export function ownEvidence(deps: OwnerDeps): OwnerEvidence {
101
+ let host: HostIdentity | undefined, start: string | undefined;
102
+ try { host = deps.hostIdentity(); } catch { /* hostless */ }
103
+ try { start = deps.processStart(process.pid); } catch { /* unknown */ }
104
+ return { pid: process.pid, ...(host ? { host } : {}), ...(start ? { processStart: start } : {}) };
105
+ }
106
+ export function ownerState(record: OwnerEvidence | null, deps: OwnerDeps): "dead" | "live" | "unsafe" {
107
+ let host: HostIdentity | undefined;
108
+ try { host = deps.hostIdentity(); } catch { return "unsafe"; }
109
+ if (!host || !record?.host || (record.processStart !== undefined && (typeof record.processStart !== "string" || !record.processStart))
110
+ || !Number.isSafeInteger(record.pid) || record.pid <= 0
111
+ || host.hostname !== record.host.hostname || host.machine !== record.host.machine) return "unsafe";
112
+ try {
113
+ // Probe another PID's start only when liveness permits a takeover decision.
114
+ if (deps.isProcessAlive(record.pid) !== false) return "live";
115
+ const start = record.processStart === undefined ? undefined : deps.processStart(record.pid);
116
+ // A reused PID is not permission to remove another process's record.
117
+ if (start !== undefined && start !== record.processStart) return "live";
118
+ return deps.isProcessAlive(record.pid) === false ? "dead" : "live";
119
+ } catch { return "live"; }
120
+ }
121
+ export function safeNamespace(path: string, kind: "file" | "directory", deps: OwnerDeps, missing = false): boolean {
122
+ try {
123
+ const stat = lockFileOperation(() => deps.lstat(path), deps.platform), uid = deps.uid();
124
+ return !stat.isSymbolicLink() && (kind === "file" ? stat.isFile() : stat.isDirectory())
125
+ && (deps.platform === "win32" || (uid !== undefined && stat.uid === uid));
126
+ } catch (error) {
127
+ if (error instanceof LockFileBusy) throw error;
128
+ return missing && (error as NodeJS.ErrnoException).code === "ENOENT";
129
+ }
130
+ }
@@ -9,8 +9,9 @@
9
9
  * the lock stale, B removes it and acquires its own, then A unlinks *B's live
10
10
  * lock* and both proceed. Unlinking a path you did not verify is the bug. Here
11
11
  * the contender renames the observed stale lock to a token-quarantined name —
12
- * an atomic operation exactly one contender can win — and only the winner
13
- * creates the real lock.
12
+ * a serialized operation under a short, unique per-process reservation. The
13
+ * reservation covers observation through creation: rename alone cannot stop
14
+ * an old observation from moving a successor's live lock.
14
15
  *
15
16
  * RELEASE ONLY DELETES A LOCK WHOSE TOKEN IS STILL OURS. A mismatch means we
16
17
  * were superseded, and deleting it would hand the critical section to two
@@ -20,15 +21,22 @@
20
21
  * is handled by the per-target byte checks in the write path, and the rename
21
22
  * window itself is documented as irreducible from user space.
22
23
  */
23
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
24
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
25
+ import { dirname } from "node:path";
26
+ import { checkEntry, guarded, type Entry } from "./prompt-lock-evidence";
24
27
  import { randomBytes } from "node:crypto";
28
+ import { UnsafeLockNamespace, withLockClaim } from "./prompt-lock-claim";
29
+
30
+ import { ownerDefaults, ownEvidence, ownerState, safeNamespace, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
31
+
32
+ import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
25
33
 
26
34
  const FILE_MODE = 0o600;
27
35
 
28
36
  /** A lock younger than this is respected even if its pid looks gone. */
29
37
  export const STALE_AFTER_MS = 10_000;
30
38
 
31
- export interface LockRecord {
39
+ export interface LockRecord extends OwnerEvidence {
32
40
  token: string;
33
41
  pid: number;
34
42
  acquiredAt: number;
@@ -41,83 +49,100 @@ export interface LockHandle {
41
49
 
42
50
  export type AcquireResult =
43
51
  | { ok: true; handle: LockHandle }
44
- | { ok: false; error: "locked" };
52
+ | { ok: false; error: "locked" }
53
+ | { ok: false; error: "unsafe"; detail: string };
45
54
 
46
- export interface LockDeps {
47
- /** Injectable so tests can simulate a live or dead owner. */
48
- isProcessAlive: (pid: number) => boolean;
55
+ export interface LockDeps extends Partial<OwnerDeps> {
56
+ isProcessAlive: (pid: number) => boolean | undefined;
49
57
  now: () => number;
58
+ onClaimInitialized?: () => void;
50
59
  }
60
+ const defaultDeps: LockDeps = { ...ownerDefaults, now: () => Date.now() };
51
61
 
52
- const defaultDeps: LockDeps = {
53
- isProcessAlive: pid => {
54
- try {
55
- process.kill(pid, 0);
56
- return true;
57
- } catch (error) {
58
- // EPERM means it exists but belongs to another user.
59
- return (error as NodeJS.ErrnoException).code === "EPERM";
60
- }
61
- },
62
- now: () => Date.now(),
63
- };
64
-
65
- function readRecord(path: string): LockRecord | null {
62
+ function readRecord(path: string, platform: NodeJS.Platform): LockRecord | null {
66
63
  try {
67
- const parsed = JSON.parse(readFileSync(path, "utf8")) as LockRecord;
64
+ const parsed = JSON.parse(lockFileOperation(() => readFileSync(path, "utf8"), platform)) as LockRecord;
68
65
  if (typeof parsed?.token !== "string" || typeof parsed?.pid !== "number") return null;
69
66
  return parsed;
70
- } catch {
67
+ } catch (error) {
68
+ if (error instanceof LockFileBusy) throw error;
71
69
  return null;
72
70
  }
73
71
  }
74
72
 
75
- /** True when the holder is gone AND the lock is older than the grace window. */
76
- function isStale(record: LockRecord | null, deps: LockDeps): boolean {
77
- if (record === null) return true; // unparseable: treat as debris
78
- if (deps.isProcessAlive(record.pid)) return false;
79
- return deps.now() - record.acquiredAt > STALE_AFTER_MS;
80
- }
81
-
82
- /**
83
- * One attempt. The caller decides whether to retry — a loser must go back to
84
- * re-reading the lock rather than assuming its quarantine still applies.
85
- */
73
+ /** One attempt, including namespace validation and serialized stale observation. */
86
74
  export function tryAcquire(path: string, deps: LockDeps = defaultDeps): AcquireResult {
75
+ const resolved = { ...ownerDefaults, ...deps };
87
76
  const token = randomBytes(8).toString("hex");
88
- const record: LockRecord = { token, pid: process.pid, acquiredAt: deps.now() };
77
+ try {
78
+ if (!safeNamespace(path, "file", resolved, true)) throw new UnsafeLockNamespace(path);
79
+ const reserved = withLockClaim(path, token, resolved,
80
+ () => acquireReserved(path, token, resolved), deps.onClaimInitialized);
81
+ return reserved.ok ? reserved.value : { ok: false, error: "locked" };
82
+ } catch (error) {
83
+ if (error instanceof LockFileBusy) return { ok: false, error: "locked" };
84
+ if (error instanceof UnsafeLockNamespace) return { ok: false, error: "unsafe", detail: error.path };
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ function acquireReserved(path: string, token: string, deps: OwnerDeps & LockDeps): AcquireResult {
90
+ const parentPath = dirname(path);
91
+ if (!safeNamespace(parentPath, "directory", deps)) throw new UnsafeLockNamespace(parentPath);
92
+ const parents = [{ path: parentPath, stat: lockFileOperation(() => deps.lstat(parentPath), deps.platform) }];
93
+ const io = <R>(operation: () => R): R => guarded(parents, [], deps, operation);
94
+ const record: LockRecord = { token, ...ownEvidence(deps), acquiredAt: deps.now() };
89
95
  const body = JSON.stringify(record);
90
96
 
91
97
  try {
92
- writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
98
+ io(() => writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" }));
93
99
  return { ok: true, handle: { path, token } };
94
100
  } catch (error) {
95
101
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
96
102
  }
97
103
 
98
- if (!isStale(readRecord(path), deps)) return { ok: false, error: "locked" };
99
-
100
- // Quarantine by rename: atomic, and exactly one contender wins it.
104
+ const observed = io(() => {
105
+ const entry: Entry = { path, stat: deps.lstat(path), body: readFileSync(path, "utf8") };
106
+ checkEntry(entry, deps);
107
+ return entry;
108
+ });
109
+ let previous: LockRecord | null;
110
+ // Same shape gate as readRecord: a malformed record is evidence, never a takeover target.
111
+ try {
112
+ const parsed = JSON.parse(observed.body) as LockRecord;
113
+ previous = typeof parsed?.token === "string" && typeof parsed?.pid === "number" ? parsed : null;
114
+ } catch { previous = null; }
115
+ const state = ownerState(previous, deps);
116
+ if (state === "unsafe") return { ok: false, error: "unsafe", detail: path };
117
+ if (state !== "dead" || !previous || !Number.isFinite(previous.acquiredAt)
118
+ || deps.now() - previous.acquiredAt <= STALE_AFTER_MS) return { ok: false, error: "locked" };
119
+
120
+ // The reservation excludes cooperating writers; fence external replacements
121
+ // and parent retargets again on every sharing-error retry.
101
122
  const quarantine = `${path}.stale-${token}`;
102
123
  try {
103
- renameSync(path, quarantine);
124
+ guarded(parents, [observed], deps, () => renameSync(path, quarantine));
104
125
  } catch {
105
126
  // Someone else won the rename, or the owner released between our checks.
106
127
  // Either way we do NOT touch the path — retry from the top.
107
128
  return { ok: false, error: "locked" };
108
129
  }
109
130
 
131
+ const moved = { ...observed, path: quarantine };
132
+ const cleanup = () => {
133
+ try { guarded(parents, [moved], deps, () => unlinkSync(quarantine)); } catch { /* Preserve changed or busy debris. */ }
134
+ };
110
135
  try {
111
- writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
136
+ io(() => writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" }));
112
137
  } catch (error) {
113
138
  // A successor acquired the real lock between our rename and this create.
114
139
  // Its lock is live and is not ours to remove.
115
- try { unlinkSync(quarantine); } catch { /* debris */ }
140
+ cleanup();
116
141
  if ((error as NodeJS.ErrnoException).code === "EEXIST") return { ok: false, error: "locked" };
117
142
  throw error;
118
143
  }
119
144
 
120
- try { unlinkSync(quarantine); } catch { /* debris */ }
145
+ cleanup();
121
146
  return { ok: true, handle: { path, token } };
122
147
  }
123
148
 
@@ -126,18 +151,24 @@ export function tryAcquire(path: string, deps: LockDeps = defaultDeps): AcquireR
126
151
  * when we were superseded, which the caller surfaces as `write_superseded`.
127
152
  */
128
153
  export function release(handle: LockHandle): boolean {
129
- const record = readRecord(handle.path);
130
- if (record === null || record.token !== handle.token) return false;
131
154
  try {
132
- unlinkSync(handle.path);
133
- return true;
134
- } catch {
135
- return false;
136
- }
155
+ const deps = ownerDefaults;
156
+ if (!safeNamespace(handle.path, "file", deps) || !safeNamespace(dirname(handle.path), "directory", deps)) return false;
157
+ const parents = [{ path: dirname(handle.path), stat: lockFileOperation(() => deps.lstat(dirname(handle.path)), deps.platform) }];
158
+ const entry = guarded(parents, [], deps, () => {
159
+ const saved = { path: handle.path, stat: deps.lstat(handle.path), body: readFileSync(handle.path, "utf8") };
160
+ checkEntry(saved, deps);
161
+ return saved;
162
+ });
163
+ if (JSON.parse(entry.body)?.token !== handle.token) return false;
164
+ return guarded(parents, [entry], deps, () => { unlinkSync(handle.path); return true; });
165
+ } catch { return false; }
137
166
  }
138
167
 
139
168
  /** True when the on-disk lock is still the one this handle acquired. */
140
169
  export function stillHeld(handle: LockHandle): boolean {
141
- if (!existsSync(handle.path)) return false;
142
- return readRecord(handle.path)?.token === handle.token;
170
+ try {
171
+ return safeNamespace(handle.path, "file", ownerDefaults)
172
+ && readRecord(handle.path, ownerDefaults.platform)?.token === handle.token;
173
+ } catch { return false; }
143
174
  }
@@ -0,0 +1,146 @@
1
+ import { pinnedCodexAccountId } from "../account-priority";
2
+ import type { OcxConfig } from "../../types";
3
+ import {
4
+ deleteAccountHealth,
5
+ setAccountHealth,
6
+ type CodexUpstreamHealth,
7
+ } from "./health-store";
8
+
9
+ /** Sliding window for transient upstream faults. Distinct from the 5-minute consecutive-streak gap. */
10
+ export const CODEX_SLIDING_FAILURE_WINDOW_MS = 60_000;
11
+ export const CODEX_SLIDING_FAILURE_MIN_SAMPLES = 20;
12
+ export const CODEX_SLIDING_FAILURE_DEGRADE_RATIO = 0.25;
13
+ export const CODEX_SLIDING_FAILURE_RECOVER_RATIO = 0.1;
14
+ export const CODEX_SLIDING_FAILURE_RECOVER_SUSTAIN_MS = 30_000;
15
+ const MAX_SAMPLES = 512;
16
+
17
+ type Sample = { at: number; failed: boolean };
18
+ type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number };
19
+
20
+ const windows = new Map<string, WindowState>();
21
+ const pinnedDegradeWarned = new Set<string>();
22
+
23
+ export function codexFailureWindowEnabled(config: OcxConfig): boolean {
24
+ return config.codexFailureWindow !== false;
25
+ }
26
+
27
+ function stateFor(accountId: string): WindowState {
28
+ let state = windows.get(accountId);
29
+ if (!state) {
30
+ state = { samples: [], degraded: false };
31
+ windows.set(accountId, state);
32
+ }
33
+ return state;
34
+ }
35
+
36
+ function recompute(state: WindowState, now: number): void {
37
+ const cutoff = now - CODEX_SLIDING_FAILURE_WINDOW_MS;
38
+ if (state.samples.length > 0 && state.samples[0]!.at <= cutoff) {
39
+ state.samples = state.samples.filter(sample => sample.at > cutoff);
40
+ }
41
+ const samples = state.samples.length;
42
+ const failed = state.samples.reduce((count, sample) => count + (sample.failed ? 1 : 0), 0);
43
+ const degraded = samples >= CODEX_SLIDING_FAILURE_MIN_SAMPLES
44
+ && failed * 4 >= samples;
45
+ const recovering = state.degraded
46
+ && samples >= CODEX_SLIDING_FAILURE_MIN_SAMPLES
47
+ && failed * 10 <= samples;
48
+ if (degraded) {
49
+ state.degraded = true;
50
+ state.healthySince = undefined;
51
+ return;
52
+ }
53
+ if (!state.degraded) {
54
+ state.healthySince = undefined;
55
+ return;
56
+ }
57
+ if (!recovering) {
58
+ state.healthySince = undefined;
59
+ return;
60
+ }
61
+ state.healthySince ??= now;
62
+ if (now - state.healthySince >= CODEX_SLIDING_FAILURE_RECOVER_SUSTAIN_MS) {
63
+ state.degraded = false;
64
+ state.healthySince = undefined;
65
+ }
66
+ }
67
+
68
+ /** Record one terminal success or transient fault. Quota and caller errors are not samples. */
69
+ export function noteCodexFailureWindowSample(
70
+ config: OcxConfig,
71
+ accountId: string,
72
+ failed: boolean,
73
+ now: number,
74
+ ): void {
75
+ if (!codexFailureWindowEnabled(config)) return;
76
+ const state = stateFor(accountId);
77
+ state.samples.push({ at: now, failed });
78
+ if (state.samples.length > MAX_SAMPLES) state.samples.splice(0, state.samples.length - MAX_SAMPLES);
79
+ recompute(state, now);
80
+ if (!state.degraded) pinnedDegradeWarned.delete(accountId);
81
+ }
82
+
83
+ export function isCodexFailureWindowDegraded(accountId: string, now = Date.now()): boolean {
84
+ const state = windows.get(accountId);
85
+ if (!state) return false;
86
+ recompute(state, now);
87
+ if (!state.degraded) pinnedDegradeWarned.delete(accountId);
88
+ return state.degraded;
89
+ }
90
+
91
+ /**
92
+ * Unbound placement may leave a degraded account. A manual pin stays unless the operator
93
+ * opts into detouring new threads. Bound threads never consult this predicate.
94
+ */
95
+ export function failureWindowSteersNewThreads(
96
+ config: OcxConfig,
97
+ accountId: string,
98
+ now: number,
99
+ ): boolean {
100
+ if (!codexFailureWindowEnabled(config)) return false;
101
+ if (!isCodexFailureWindowDegraded(accountId, now)) return false;
102
+ if (pinnedCodexAccountId(config) !== accountId) return true;
103
+ if (config.codexPinnedTransientPolicy === "detour-new-threads") return true;
104
+ if (!pinnedDegradeWarned.has(accountId)) {
105
+ pinnedDegradeWarned.add(accountId);
106
+ console.warn("[opencodex] pinned Codex account is degraded; new threads stay on it until it recovers");
107
+ }
108
+ return false;
109
+ }
110
+
111
+ export function forgetCodexFailureWindow(accountId: string): void {
112
+ windows.delete(accountId);
113
+ pinnedDegradeWarned.delete(accountId);
114
+ }
115
+
116
+ export function forgetCodexFailureWindows(): void {
117
+ windows.clear();
118
+ pinnedDegradeWarned.clear();
119
+ }
120
+
121
+ /** Consecutive-success bookkeeping for a healthy terminal, moved out of the routing module. */
122
+ export function settleCodexSuccessStreak(
123
+ config: OcxConfig,
124
+ accountId: string,
125
+ current: CodexUpstreamHealth | undefined,
126
+ base: CodexUpstreamHealth | undefined,
127
+ preserved: Partial<CodexUpstreamHealth>,
128
+ cooldownUntil: number | null,
129
+ ): void {
130
+ const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
131
+ if (failoverEnabled && current && current.consecutiveFailures >= 2) {
132
+ const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1;
133
+ if (consecutiveSuccesses < 2) {
134
+ setAccountHealth(accountId, {
135
+ ...base!,
136
+ ...preserved,
137
+ consecutiveSuccesses,
138
+ });
139
+ return;
140
+ }
141
+ }
142
+ // Level 1 clears immediately; escalated accounts need two consecutive healthy terminals.
143
+ // Hard quota cooldown intentionally survives either recovery path.
144
+ if (cooldownUntil) setAccountHealth(accountId, { consecutiveFailures: 0, ...preserved });
145
+ else deleteAccountHealth(accountId);
146
+ }