oh-my-codex 0.20.3 → 0.20.5

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 (661) hide show
  1. package/Cargo.lock +35 -18
  2. package/Cargo.toml +1 -1
  3. package/README.md +2 -2
  4. package/crates/omx-runtime/Cargo.toml +3 -0
  5. package/crates/omx-runtime/src/main.rs +390 -0
  6. package/crates/omx-runtime/tests/execution.rs +264 -0
  7. package/dist/adapt/__tests__/herdr-target.test.d.ts +2 -0
  8. package/dist/adapt/__tests__/herdr-target.test.d.ts.map +1 -0
  9. package/dist/adapt/__tests__/herdr-target.test.js +72 -0
  10. package/dist/adapt/__tests__/herdr-target.test.js.map +1 -0
  11. package/dist/adapt/contracts.d.ts +1 -1
  12. package/dist/adapt/contracts.d.ts.map +1 -1
  13. package/dist/adapt/contracts.js +1 -1
  14. package/dist/adapt/contracts.js.map +1 -1
  15. package/dist/adapt/herdr/__tests__/authority.test.d.ts +2 -0
  16. package/dist/adapt/herdr/__tests__/authority.test.d.ts.map +1 -0
  17. package/dist/adapt/herdr/__tests__/authority.test.js +49 -0
  18. package/dist/adapt/herdr/__tests__/authority.test.js.map +1 -0
  19. package/dist/adapt/herdr/__tests__/bridge.test.d.ts +2 -0
  20. package/dist/adapt/herdr/__tests__/bridge.test.d.ts.map +1 -0
  21. package/dist/adapt/herdr/__tests__/bridge.test.js +176 -0
  22. package/dist/adapt/herdr/__tests__/bridge.test.js.map +1 -0
  23. package/dist/adapt/herdr/__tests__/sanitize.test.d.ts +2 -0
  24. package/dist/adapt/herdr/__tests__/sanitize.test.d.ts.map +1 -0
  25. package/dist/adapt/herdr/__tests__/sanitize.test.js +45 -0
  26. package/dist/adapt/herdr/__tests__/sanitize.test.js.map +1 -0
  27. package/dist/adapt/herdr/__tests__/semantic.test.d.ts +2 -0
  28. package/dist/adapt/herdr/__tests__/semantic.test.d.ts.map +1 -0
  29. package/dist/adapt/herdr/__tests__/semantic.test.js +130 -0
  30. package/dist/adapt/herdr/__tests__/semantic.test.js.map +1 -0
  31. package/dist/adapt/herdr/__tests__/seq-store.test.d.ts +2 -0
  32. package/dist/adapt/herdr/__tests__/seq-store.test.d.ts.map +1 -0
  33. package/dist/adapt/herdr/__tests__/seq-store.test.js +54 -0
  34. package/dist/adapt/herdr/__tests__/seq-store.test.js.map +1 -0
  35. package/dist/adapt/herdr/__tests__/socket-live.test.d.ts +2 -0
  36. package/dist/adapt/herdr/__tests__/socket-live.test.d.ts.map +1 -0
  37. package/dist/adapt/herdr/__tests__/socket-live.test.js +104 -0
  38. package/dist/adapt/herdr/__tests__/socket-live.test.js.map +1 -0
  39. package/dist/adapt/herdr/__tests__/team-map.test.d.ts +2 -0
  40. package/dist/adapt/herdr/__tests__/team-map.test.d.ts.map +1 -0
  41. package/dist/adapt/herdr/__tests__/team-map.test.js +46 -0
  42. package/dist/adapt/herdr/__tests__/team-map.test.js.map +1 -0
  43. package/dist/adapt/herdr/__tests__/transport.test.d.ts +2 -0
  44. package/dist/adapt/herdr/__tests__/transport.test.d.ts.map +1 -0
  45. package/dist/adapt/herdr/__tests__/transport.test.js +160 -0
  46. package/dist/adapt/herdr/__tests__/transport.test.js.map +1 -0
  47. package/dist/adapt/herdr/__tests__/wiring.test.d.ts +2 -0
  48. package/dist/adapt/herdr/__tests__/wiring.test.d.ts.map +1 -0
  49. package/dist/adapt/herdr/__tests__/wiring.test.js +118 -0
  50. package/dist/adapt/herdr/__tests__/wiring.test.js.map +1 -0
  51. package/dist/adapt/herdr/authority.d.ts +34 -0
  52. package/dist/adapt/herdr/authority.d.ts.map +1 -0
  53. package/dist/adapt/herdr/authority.js +71 -0
  54. package/dist/adapt/herdr/authority.js.map +1 -0
  55. package/dist/adapt/herdr/bridge.d.ts +84 -0
  56. package/dist/adapt/herdr/bridge.d.ts.map +1 -0
  57. package/dist/adapt/herdr/bridge.js +222 -0
  58. package/dist/adapt/herdr/bridge.js.map +1 -0
  59. package/dist/adapt/herdr/sanitize.d.ts +20 -0
  60. package/dist/adapt/herdr/sanitize.d.ts.map +1 -0
  61. package/dist/adapt/herdr/sanitize.js +70 -0
  62. package/dist/adapt/herdr/sanitize.js.map +1 -0
  63. package/dist/adapt/herdr/semantic.d.ts +67 -0
  64. package/dist/adapt/herdr/semantic.d.ts.map +1 -0
  65. package/dist/adapt/herdr/semantic.js +170 -0
  66. package/dist/adapt/herdr/semantic.js.map +1 -0
  67. package/dist/adapt/herdr/seq-store.d.ts +32 -0
  68. package/dist/adapt/herdr/seq-store.d.ts.map +1 -0
  69. package/dist/adapt/herdr/seq-store.js +126 -0
  70. package/dist/adapt/herdr/seq-store.js.map +1 -0
  71. package/dist/adapt/herdr/team-map.d.ts +30 -0
  72. package/dist/adapt/herdr/team-map.d.ts.map +1 -0
  73. package/dist/adapt/herdr/team-map.js +37 -0
  74. package/dist/adapt/herdr/team-map.js.map +1 -0
  75. package/dist/adapt/herdr/transport.d.ts +115 -0
  76. package/dist/adapt/herdr/transport.d.ts.map +1 -0
  77. package/dist/adapt/herdr/transport.js +260 -0
  78. package/dist/adapt/herdr/transport.js.map +1 -0
  79. package/dist/adapt/herdr/wiring.d.ts +24 -0
  80. package/dist/adapt/herdr/wiring.d.ts.map +1 -0
  81. package/dist/adapt/herdr/wiring.js +62 -0
  82. package/dist/adapt/herdr/wiring.js.map +1 -0
  83. package/dist/adapt/herdr.d.ts +19 -0
  84. package/dist/adapt/herdr.d.ts.map +1 -0
  85. package/dist/adapt/herdr.js +115 -0
  86. package/dist/adapt/herdr.js.map +1 -0
  87. package/dist/adapt/index.d.ts +4 -0
  88. package/dist/adapt/index.d.ts.map +1 -1
  89. package/dist/adapt/index.js +48 -0
  90. package/dist/adapt/index.js.map +1 -1
  91. package/dist/adapt/registry.d.ts.map +1 -1
  92. package/dist/adapt/registry.js +11 -0
  93. package/dist/adapt/registry.js.map +1 -1
  94. package/dist/auth/__tests__/hotswap.test.js +60 -1
  95. package/dist/auth/__tests__/hotswap.test.js.map +1 -1
  96. package/dist/auth/__tests__/storage.test.js +33 -1
  97. package/dist/auth/__tests__/storage.test.js.map +1 -1
  98. package/dist/auth/hotswap.d.ts +16 -2
  99. package/dist/auth/hotswap.d.ts.map +1 -1
  100. package/dist/auth/hotswap.js +22 -7
  101. package/dist/auth/hotswap.js.map +1 -1
  102. package/dist/auth/storage.js +1 -1
  103. package/dist/auth/storage.js.map +1 -1
  104. package/dist/autopilot/__tests__/ralplan-gate.test.js +86 -1333
  105. package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
  106. package/dist/autopilot/ralplan-gate.d.ts.map +1 -1
  107. package/dist/autopilot/ralplan-gate.js +7 -6
  108. package/dist/autopilot/ralplan-gate.js.map +1 -1
  109. package/dist/cli/__tests__/agents-init.test.js +4 -23
  110. package/dist/cli/__tests__/agents-init.test.js.map +1 -1
  111. package/dist/cli/__tests__/api.test.js +61 -24
  112. package/dist/cli/__tests__/api.test.js.map +1 -1
  113. package/dist/cli/__tests__/auth.test.js +3 -1
  114. package/dist/cli/__tests__/auth.test.js.map +1 -1
  115. package/dist/cli/__tests__/codex-feature-probe.test.js +66 -1
  116. package/dist/cli/__tests__/codex-feature-probe.test.js.map +1 -1
  117. package/dist/cli/__tests__/codex-plugin-layout.test.js +70 -2
  118. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  119. package/dist/cli/__tests__/detached-leader-teardown.test.d.ts +2 -0
  120. package/dist/cli/__tests__/detached-leader-teardown.test.d.ts.map +1 -0
  121. package/dist/cli/__tests__/detached-leader-teardown.test.js +473 -0
  122. package/dist/cli/__tests__/detached-leader-teardown.test.js.map +1 -0
  123. package/dist/cli/__tests__/doctor-process-identity-readiness.test.d.ts +2 -0
  124. package/dist/cli/__tests__/doctor-process-identity-readiness.test.d.ts.map +1 -0
  125. package/dist/cli/__tests__/doctor-process-identity-readiness.test.js +103 -0
  126. package/dist/cli/__tests__/doctor-process-identity-readiness.test.js.map +1 -0
  127. package/dist/cli/__tests__/doctor-state-root-binding.test.d.ts +2 -0
  128. package/dist/cli/__tests__/doctor-state-root-binding.test.d.ts.map +1 -0
  129. package/dist/cli/__tests__/doctor-state-root-binding.test.js +139 -0
  130. package/dist/cli/__tests__/doctor-state-root-binding.test.js.map +1 -0
  131. package/dist/cli/__tests__/doctor-warning-copy.test.js +35 -3
  132. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  133. package/dist/cli/__tests__/exec.test.js +75 -0
  134. package/dist/cli/__tests__/exec.test.js.map +1 -1
  135. package/dist/cli/__tests__/index.test.js +621 -460
  136. package/dist/cli/__tests__/index.test.js.map +1 -1
  137. package/dist/cli/__tests__/launch-fallback.test.js +1020 -143
  138. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  139. package/dist/cli/__tests__/mcp-parity.test.js +4 -3
  140. package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
  141. package/dist/cli/__tests__/native-assets.test.js +398 -16
  142. package/dist/cli/__tests__/native-assets.test.js.map +1 -1
  143. package/dist/cli/__tests__/native-hook-claim-journal.test.js +53 -5
  144. package/dist/cli/__tests__/native-hook-claim-journal.test.js.map +1 -1
  145. package/dist/cli/__tests__/ralph.test.js +2 -2
  146. package/dist/cli/__tests__/ralph.test.js.map +1 -1
  147. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js +57 -57
  148. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -1
  149. package/dist/cli/__tests__/ralplan-child-binding-3212.test.d.ts +2 -0
  150. package/dist/cli/__tests__/ralplan-child-binding-3212.test.d.ts.map +1 -0
  151. package/dist/cli/__tests__/ralplan-child-binding-3212.test.js +56 -0
  152. package/dist/cli/__tests__/ralplan-child-binding-3212.test.js.map +1 -0
  153. package/dist/cli/__tests__/ralplan.test.js +84 -12
  154. package/dist/cli/__tests__/ralplan.test.js.map +1 -1
  155. package/dist/cli/__tests__/session-scoped-runtime.test.js +1118 -43
  156. package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
  157. package/dist/cli/__tests__/setup-agents-overwrite.test.js +8 -55
  158. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  159. package/dist/cli/__tests__/setup-install-mode.test.js +183 -4
  160. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  161. package/dist/cli/__tests__/sparkshell-cli.test.js +93 -41
  162. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  163. package/dist/cli/__tests__/team.test.js +45 -1
  164. package/dist/cli/__tests__/team.test.js.map +1 -1
  165. package/dist/cli/__tests__/ultragoal.test.js +117 -2
  166. package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
  167. package/dist/cli/__tests__/uninstall.test.js +50 -5
  168. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  169. package/dist/cli/__tests__/update.test.js +669 -177
  170. package/dist/cli/__tests__/update.test.js.map +1 -1
  171. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +32 -2
  172. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  173. package/dist/cli/api.d.ts.map +1 -1
  174. package/dist/cli/api.js +9 -10
  175. package/dist/cli/api.js.map +1 -1
  176. package/dist/cli/codex-feature-probe.d.ts +16 -0
  177. package/dist/cli/codex-feature-probe.d.ts.map +1 -1
  178. package/dist/cli/codex-feature-probe.js +44 -0
  179. package/dist/cli/codex-feature-probe.js.map +1 -1
  180. package/dist/cli/codex-home.d.ts.map +1 -1
  181. package/dist/cli/codex-home.js +7 -4
  182. package/dist/cli/codex-home.js.map +1 -1
  183. package/dist/cli/doctor.d.ts +17 -0
  184. package/dist/cli/doctor.d.ts.map +1 -1
  185. package/dist/cli/doctor.js +277 -3
  186. package/dist/cli/doctor.js.map +1 -1
  187. package/dist/cli/index.d.ts +231 -12
  188. package/dist/cli/index.d.ts.map +1 -1
  189. package/dist/cli/index.js +2298 -735
  190. package/dist/cli/index.js.map +1 -1
  191. package/dist/cli/native-assets.d.ts +25 -0
  192. package/dist/cli/native-assets.d.ts.map +1 -1
  193. package/dist/cli/native-assets.js +448 -51
  194. package/dist/cli/native-assets.js.map +1 -1
  195. package/dist/cli/native-hook-claim-journal.d.ts +8 -4
  196. package/dist/cli/native-hook-claim-journal.d.ts.map +1 -1
  197. package/dist/cli/native-hook-claim-journal.js +30 -8
  198. package/dist/cli/native-hook-claim-journal.js.map +1 -1
  199. package/dist/cli/package-manager-ownership.d.ts +63 -0
  200. package/dist/cli/package-manager-ownership.d.ts.map +1 -0
  201. package/dist/cli/package-manager-ownership.js +323 -0
  202. package/dist/cli/package-manager-ownership.js.map +1 -0
  203. package/dist/cli/ralph.js +2 -2
  204. package/dist/cli/ralph.js.map +1 -1
  205. package/dist/cli/ralplan.d.ts +4 -2
  206. package/dist/cli/ralplan.d.ts.map +1 -1
  207. package/dist/cli/ralplan.js +76 -34
  208. package/dist/cli/ralplan.js.map +1 -1
  209. package/dist/cli/setup-preferences.d.ts +15 -0
  210. package/dist/cli/setup-preferences.d.ts.map +1 -1
  211. package/dist/cli/setup-preferences.js +27 -1
  212. package/dist/cli/setup-preferences.js.map +1 -1
  213. package/dist/cli/setup.d.ts +3 -0
  214. package/dist/cli/setup.d.ts.map +1 -1
  215. package/dist/cli/setup.js +55 -32
  216. package/dist/cli/setup.js.map +1 -1
  217. package/dist/cli/sparkshell.d.ts.map +1 -1
  218. package/dist/cli/sparkshell.js +52 -9
  219. package/dist/cli/sparkshell.js.map +1 -1
  220. package/dist/cli/ultragoal.d.ts.map +1 -1
  221. package/dist/cli/ultragoal.js +2 -2
  222. package/dist/cli/ultragoal.js.map +1 -1
  223. package/dist/cli/uninstall.d.ts.map +1 -1
  224. package/dist/cli/uninstall.js +27 -28
  225. package/dist/cli/uninstall.js.map +1 -1
  226. package/dist/cli/update-worker.d.ts +2 -0
  227. package/dist/cli/update-worker.d.ts.map +1 -0
  228. package/dist/cli/update-worker.js +142 -0
  229. package/dist/cli/update-worker.js.map +1 -0
  230. package/dist/cli/update.d.ts +14 -22
  231. package/dist/cli/update.d.ts.map +1 -1
  232. package/dist/cli/update.js +192 -241
  233. package/dist/cli/update.js.map +1 -1
  234. package/dist/config/__tests__/generator-idempotent.test.js +173 -29
  235. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  236. package/dist/config/__tests__/generator-notify.test.js +3 -1
  237. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  238. package/dist/config/generator.d.ts +25 -3
  239. package/dist/config/generator.d.ts.map +1 -1
  240. package/dist/config/generator.js +113 -35
  241. package/dist/config/generator.js.map +1 -1
  242. package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js +25 -0
  243. package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js.map +1 -1
  244. package/dist/goal-workflows/codex-goal-snapshot.d.ts +1 -1
  245. package/dist/goal-workflows/codex-goal-snapshot.d.ts.map +1 -1
  246. package/dist/goal-workflows/codex-goal-snapshot.js +2 -0
  247. package/dist/goal-workflows/codex-goal-snapshot.js.map +1 -1
  248. package/dist/hooks/__tests__/agents-overlay.test.js +3 -1
  249. package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
  250. package/dist/hooks/__tests__/autopilot-skill-contract.test.js +27 -4
  251. package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
  252. package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.d.ts +2 -0
  253. package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.d.ts.map +1 -0
  254. package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.js +172 -0
  255. package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.js.map +1 -0
  256. package/dist/hooks/__tests__/keyword-detector.test.js +172 -33
  257. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  258. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +173 -11
  259. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  260. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +1 -0
  261. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  262. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +65 -17
  263. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  264. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +27 -0
  265. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
  266. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js +10 -118
  267. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js.map +1 -1
  268. package/dist/hooks/__tests__/notify-hook-regression-205.test.js +7 -0
  269. package/dist/hooks/__tests__/notify-hook-regression-205.test.js.map +1 -1
  270. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +2 -2
  271. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  272. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +4 -1
  273. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  274. package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js +2 -1
  275. package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js.map +1 -1
  276. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +1 -0
  277. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  278. package/dist/hooks/__tests__/prompt-guidance-contract.test.js +9 -0
  279. package/dist/hooks/__tests__/prompt-guidance-contract.test.js.map +1 -1
  280. package/dist/hooks/__tests__/session.test.js +2976 -557
  281. package/dist/hooks/__tests__/session.test.js.map +1 -1
  282. package/dist/hooks/agents-overlay.js +1 -1
  283. package/dist/hooks/agents-overlay.js.map +1 -1
  284. package/dist/hooks/extensibility/__tests__/dispatcher.test.js +108 -2
  285. package/dist/hooks/extensibility/__tests__/dispatcher.test.js.map +1 -1
  286. package/dist/hooks/extensibility/__tests__/sdk.test.js +151 -1
  287. package/dist/hooks/extensibility/__tests__/sdk.test.js.map +1 -1
  288. package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -1
  289. package/dist/hooks/extensibility/dispatcher.js +20 -3
  290. package/dist/hooks/extensibility/dispatcher.js.map +1 -1
  291. package/dist/hooks/extensibility/plugin-runner.js +1 -0
  292. package/dist/hooks/extensibility/plugin-runner.js.map +1 -1
  293. package/dist/hooks/extensibility/runtime.d.ts.map +1 -1
  294. package/dist/hooks/extensibility/runtime.js.map +1 -1
  295. package/dist/hooks/extensibility/sdk/paths.d.ts +1 -1
  296. package/dist/hooks/extensibility/sdk/paths.d.ts.map +1 -1
  297. package/dist/hooks/extensibility/sdk/paths.js +2 -2
  298. package/dist/hooks/extensibility/sdk/paths.js.map +1 -1
  299. package/dist/hooks/extensibility/sdk/runtime-state.d.ts +1 -1
  300. package/dist/hooks/extensibility/sdk/runtime-state.d.ts.map +1 -1
  301. package/dist/hooks/extensibility/sdk/runtime-state.js +55 -15
  302. package/dist/hooks/extensibility/sdk/runtime-state.js.map +1 -1
  303. package/dist/hooks/extensibility/sdk.d.ts +1 -0
  304. package/dist/hooks/extensibility/sdk.d.ts.map +1 -1
  305. package/dist/hooks/extensibility/sdk.js +1 -1
  306. package/dist/hooks/extensibility/sdk.js.map +1 -1
  307. package/dist/hooks/extensibility/types.d.ts +7 -0
  308. package/dist/hooks/extensibility/types.d.ts.map +1 -1
  309. package/dist/hooks/keyword-detector.d.ts +10 -3
  310. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  311. package/dist/hooks/keyword-detector.js +116 -20
  312. package/dist/hooks/keyword-detector.js.map +1 -1
  313. package/dist/hooks/session.d.ts +180 -14
  314. package/dist/hooks/session.d.ts.map +1 -1
  315. package/dist/hooks/session.js +1402 -281
  316. package/dist/hooks/session.js.map +1 -1
  317. package/dist/hud/__tests__/hud-tmux-injection.test.js +6 -6
  318. package/dist/hud/__tests__/hud-tmux-injection.test.js.map +1 -1
  319. package/dist/hud/__tests__/index.test.js +26 -16
  320. package/dist/hud/__tests__/index.test.js.map +1 -1
  321. package/dist/hud/__tests__/reconcile.test.js +83 -78
  322. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  323. package/dist/hud/__tests__/state.test.js +36 -31
  324. package/dist/hud/__tests__/state.test.js.map +1 -1
  325. package/dist/hud/__tests__/tmux.test.js +155 -198
  326. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  327. package/dist/hud/tmux.d.ts +42 -4
  328. package/dist/hud/tmux.d.ts.map +1 -1
  329. package/dist/hud/tmux.js +1017 -139
  330. package/dist/hud/tmux.js.map +1 -1
  331. package/dist/leader/__tests__/contract.test.js +71 -34
  332. package/dist/leader/__tests__/contract.test.js.map +1 -1
  333. package/dist/leader/contract.d.ts +1 -6
  334. package/dist/leader/contract.d.ts.map +1 -1
  335. package/dist/leader/contract.js +25 -24
  336. package/dist/leader/contract.js.map +1 -1
  337. package/dist/mcp/__tests__/hermes-bridge.test.js +17 -0
  338. package/dist/mcp/__tests__/hermes-bridge.test.js.map +1 -1
  339. package/dist/mcp/__tests__/server-lifecycle.test.js +4 -4
  340. package/dist/mcp/__tests__/state-paths.test.js +735 -4
  341. package/dist/mcp/__tests__/state-paths.test.js.map +1 -1
  342. package/dist/mcp/__tests__/state-server.test.js +1 -1
  343. package/dist/mcp/__tests__/state-server.test.js.map +1 -1
  344. package/dist/mcp/__tests__/trace-server.test.js +2 -2
  345. package/dist/mcp/__tests__/trace-server.test.js.map +1 -1
  346. package/dist/mcp/hermes-bridge.d.ts.map +1 -1
  347. package/dist/mcp/hermes-bridge.js +2 -2
  348. package/dist/mcp/hermes-bridge.js.map +1 -1
  349. package/dist/mcp/state-paths.d.ts +78 -4
  350. package/dist/mcp/state-paths.d.ts.map +1 -1
  351. package/dist/mcp/state-paths.js +543 -40
  352. package/dist/mcp/state-paths.js.map +1 -1
  353. package/dist/mcp/state-server.d.ts +4 -4
  354. package/dist/mcp/state-server.d.ts.map +1 -1
  355. package/dist/modes/__tests__/base-autopilot-gates.test.js +36 -7
  356. package/dist/modes/__tests__/base-autopilot-gates.test.js.map +1 -1
  357. package/dist/modes/__tests__/base-multi-state-compat.test.js +1 -1
  358. package/dist/modes/__tests__/base-multi-state-compat.test.js.map +1 -1
  359. package/dist/modes/__tests__/base-session-scope.test.js +17 -8
  360. package/dist/modes/__tests__/base-session-scope.test.js.map +1 -1
  361. package/dist/modes/base.d.ts +5 -5
  362. package/dist/modes/base.d.ts.map +1 -1
  363. package/dist/modes/base.js +76 -21
  364. package/dist/modes/base.js.map +1 -1
  365. package/dist/native-assets/archive.d.ts +17 -0
  366. package/dist/native-assets/archive.d.ts.map +1 -0
  367. package/dist/native-assets/archive.js +238 -0
  368. package/dist/native-assets/archive.js.map +1 -0
  369. package/dist/native-assets/policy.d.ts +42 -0
  370. package/dist/native-assets/policy.d.ts.map +1 -0
  371. package/dist/native-assets/policy.js +155 -0
  372. package/dist/native-assets/policy.js.map +1 -0
  373. package/dist/pipeline/__tests__/orchestrator.test.js +162 -40
  374. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  375. package/dist/pipeline/__tests__/stages.test.js +66 -149
  376. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  377. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  378. package/dist/pipeline/orchestrator.js +124 -18
  379. package/dist/pipeline/orchestrator.js.map +1 -1
  380. package/dist/pipeline/stages/ralplan.d.ts.map +1 -1
  381. package/dist/pipeline/stages/ralplan.js +36 -8
  382. package/dist/pipeline/stages/ralplan.js.map +1 -1
  383. package/dist/ralplan/__tests__/consensus-gate.test.js +70 -1437
  384. package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -1
  385. package/dist/ralplan/__tests__/documented-leader-preflight.test.js +328 -39
  386. package/dist/ralplan/__tests__/documented-leader-preflight.test.js.map +1 -1
  387. package/dist/ralplan/__tests__/runtime.test.js +82 -102
  388. package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
  389. package/dist/ralplan/consensus-gate.d.ts +9 -1
  390. package/dist/ralplan/consensus-gate.d.ts.map +1 -1
  391. package/dist/ralplan/consensus-gate.js +40 -304
  392. package/dist/ralplan/consensus-gate.js.map +1 -1
  393. package/dist/ralplan/documented-leader-preflight.d.ts +15 -8
  394. package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -1
  395. package/dist/ralplan/documented-leader-preflight.js +356 -54
  396. package/dist/ralplan/documented-leader-preflight.js.map +1 -1
  397. package/dist/ralplan/runtime.d.ts +1 -1
  398. package/dist/ralplan/runtime.d.ts.map +1 -1
  399. package/dist/ralplan/runtime.js +68 -173
  400. package/dist/ralplan/runtime.js.map +1 -1
  401. package/dist/runtime/run-state.d.ts +5 -1
  402. package/dist/runtime/run-state.d.ts.map +1 -1
  403. package/dist/runtime/run-state.js +15 -8
  404. package/dist/runtime/run-state.js.map +1 -1
  405. package/dist/scripts/__tests__/codex-native-hook.test.js +14071 -8859
  406. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  407. package/dist/scripts/__tests__/issue-3293-callsite-parity.test.d.ts +2 -0
  408. package/dist/scripts/__tests__/issue-3293-callsite-parity.test.d.ts.map +1 -0
  409. package/dist/scripts/__tests__/issue-3293-callsite-parity.test.js +225 -0
  410. package/dist/scripts/__tests__/issue-3293-callsite-parity.test.js.map +1 -0
  411. package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.d.ts +2 -0
  412. package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.d.ts.map +1 -0
  413. package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.js +188 -0
  414. package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.js.map +1 -0
  415. package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.d.ts +2 -0
  416. package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.d.ts.map +1 -0
  417. package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.js +259 -0
  418. package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.js.map +1 -0
  419. package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.d.ts +2 -0
  420. package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.d.ts.map +1 -0
  421. package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.js +491 -0
  422. package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.js.map +1 -0
  423. package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.d.ts +2 -0
  424. package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.d.ts.map +1 -0
  425. package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.js +708 -0
  426. package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.js.map +1 -0
  427. package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.d.ts +2 -0
  428. package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.d.ts.map +1 -0
  429. package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.js +208 -0
  430. package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.js.map +1 -0
  431. package/dist/scripts/__tests__/notify-state-io.test.js +26 -0
  432. package/dist/scripts/__tests__/notify-state-io.test.js.map +1 -1
  433. package/dist/scripts/__tests__/notify-tmux-injection.test.js +23 -1
  434. package/dist/scripts/__tests__/notify-tmux-injection.test.js.map +1 -1
  435. package/dist/scripts/__tests__/postinstall.test.js +19 -1
  436. package/dist/scripts/__tests__/postinstall.test.js.map +1 -1
  437. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js +25 -8
  438. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js.map +1 -1
  439. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js +60 -25
  440. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -1
  441. package/dist/scripts/__tests__/smoke-packed-install.test.js +155 -16
  442. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  443. package/dist/scripts/__tests__/verify-native-release-assets.test.d.ts +2 -0
  444. package/dist/scripts/__tests__/verify-native-release-assets.test.d.ts.map +1 -0
  445. package/dist/scripts/__tests__/verify-native-release-assets.test.js +235 -0
  446. package/dist/scripts/__tests__/verify-native-release-assets.test.js.map +1 -0
  447. package/dist/scripts/codex-native-hook.d.ts +50 -1
  448. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  449. package/dist/scripts/codex-native-hook.js +3559 -451
  450. package/dist/scripts/codex-native-hook.js.map +1 -1
  451. package/dist/scripts/codex-native-pre-post.d.ts +9 -0
  452. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  453. package/dist/scripts/codex-native-pre-post.js +35 -12
  454. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  455. package/dist/scripts/generate-native-release-manifest.js +80 -51
  456. package/dist/scripts/generate-native-release-manifest.js.map +1 -1
  457. package/dist/scripts/notify-fallback-watcher.js +28 -39
  458. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  459. package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
  460. package/dist/scripts/notify-hook/state-io.js +5 -0
  461. package/dist/scripts/notify-hook/state-io.js.map +1 -1
  462. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  463. package/dist/scripts/notify-hook/team-leader-nudge.js +32 -3
  464. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  465. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  466. package/dist/scripts/notify-hook/team-worker-stop.js +23 -3
  467. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  468. package/dist/scripts/notify-hook/team-worker.d.ts.map +1 -1
  469. package/dist/scripts/notify-hook/team-worker.js +45 -5
  470. package/dist/scripts/notify-hook/team-worker.js.map +1 -1
  471. package/dist/scripts/notify-hook/tmux-injection.d.ts +1 -0
  472. package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
  473. package/dist/scripts/notify-hook/tmux-injection.js +4 -1
  474. package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
  475. package/dist/scripts/notify-hook.js +78 -82
  476. package/dist/scripts/notify-hook.js.map +1 -1
  477. package/dist/scripts/postinstall-advisory.d.ts +14 -0
  478. package/dist/scripts/postinstall-advisory.d.ts.map +1 -0
  479. package/dist/scripts/postinstall-advisory.js +49 -0
  480. package/dist/scripts/postinstall-advisory.js.map +1 -0
  481. package/dist/scripts/postinstall.d.ts +1 -1
  482. package/dist/scripts/postinstall.d.ts.map +1 -1
  483. package/dist/scripts/postinstall.js +1 -1
  484. package/dist/scripts/postinstall.js.map +1 -1
  485. package/dist/scripts/smoke-packed-install.d.ts +32 -1
  486. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  487. package/dist/scripts/smoke-packed-install.js +413 -60
  488. package/dist/scripts/smoke-packed-install.js.map +1 -1
  489. package/dist/scripts/sync-plugin-mirror.d.ts.map +1 -1
  490. package/dist/scripts/sync-plugin-mirror.js +16 -1
  491. package/dist/scripts/sync-plugin-mirror.js.map +1 -1
  492. package/dist/scripts/verify-native-release-assets.js +37 -23
  493. package/dist/scripts/verify-native-release-assets.js.map +1 -1
  494. package/dist/state/__tests__/mode-state-context.test.js +12 -5
  495. package/dist/state/__tests__/mode-state-context.test.js.map +1 -1
  496. package/dist/state/__tests__/operations.test.js +460 -850
  497. package/dist/state/__tests__/operations.test.js.map +1 -1
  498. package/dist/state/__tests__/skill-active.test.js +400 -2
  499. package/dist/state/__tests__/skill-active.test.js.map +1 -1
  500. package/dist/state/mode-state-context.d.ts +3 -0
  501. package/dist/state/mode-state-context.d.ts.map +1 -1
  502. package/dist/state/mode-state-context.js +27 -13
  503. package/dist/state/mode-state-context.js.map +1 -1
  504. package/dist/state/operations.d.ts +3 -0
  505. package/dist/state/operations.d.ts.map +1 -1
  506. package/dist/state/operations.js +144 -40
  507. package/dist/state/operations.js.map +1 -1
  508. package/dist/state/skill-active.d.ts +18 -1
  509. package/dist/state/skill-active.d.ts.map +1 -1
  510. package/dist/state/skill-active.js +420 -23
  511. package/dist/state/skill-active.js.map +1 -1
  512. package/dist/state/workflow-transition-reconcile.d.ts +3 -0
  513. package/dist/state/workflow-transition-reconcile.d.ts.map +1 -1
  514. package/dist/state/workflow-transition-reconcile.js +10 -4
  515. package/dist/state/workflow-transition-reconcile.js.map +1 -1
  516. package/dist/state/workflow-transition.d.ts.map +1 -1
  517. package/dist/state/workflow-transition.js +3 -1
  518. package/dist/state/workflow-transition.js.map +1 -1
  519. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +27 -0
  520. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -1
  521. package/dist/subagents/__tests__/tracker.test.js +837 -1048
  522. package/dist/subagents/__tests__/tracker.test.js.map +1 -1
  523. package/dist/subagents/tracker.d.ts +64 -60
  524. package/dist/subagents/tracker.d.ts.map +1 -1
  525. package/dist/subagents/tracker.js +809 -358
  526. package/dist/subagents/tracker.js.map +1 -1
  527. package/dist/team/__tests__/delivery-e2e-smoke.test.js +6 -7
  528. package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
  529. package/dist/team/__tests__/mcp-comm.test.js +104 -3
  530. package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
  531. package/dist/team/__tests__/notice-ledger.test.d.ts +2 -0
  532. package/dist/team/__tests__/notice-ledger.test.d.ts.map +1 -0
  533. package/dist/team/__tests__/notice-ledger.test.js +131 -0
  534. package/dist/team/__tests__/notice-ledger.test.js.map +1 -0
  535. package/dist/team/__tests__/pane-teardown-residue-e2e.test.d.ts +2 -0
  536. package/dist/team/__tests__/pane-teardown-residue-e2e.test.d.ts.map +1 -0
  537. package/dist/team/__tests__/pane-teardown-residue-e2e.test.js +410 -0
  538. package/dist/team/__tests__/pane-teardown-residue-e2e.test.js.map +1 -0
  539. package/dist/team/__tests__/phase-controller.test.js +20 -0
  540. package/dist/team/__tests__/phase-controller.test.js.map +1 -1
  541. package/dist/team/__tests__/runtime.test.js +725 -59
  542. package/dist/team/__tests__/runtime.test.js.map +1 -1
  543. package/dist/team/__tests__/scaling.test.js +222 -237
  544. package/dist/team/__tests__/scaling.test.js.map +1 -1
  545. package/dist/team/__tests__/state-root.test.js +117 -2
  546. package/dist/team/__tests__/state-root.test.js.map +1 -1
  547. package/dist/team/__tests__/tmux-session.test.js +887 -92
  548. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  549. package/dist/team/__tests__/tmux-source-authority-realtmux.test.d.ts +2 -0
  550. package/dist/team/__tests__/tmux-source-authority-realtmux.test.d.ts.map +1 -0
  551. package/dist/team/__tests__/tmux-source-authority-realtmux.test.js +149 -0
  552. package/dist/team/__tests__/tmux-source-authority-realtmux.test.js.map +1 -0
  553. package/dist/team/__tests__/tmux-test-fixture.d.ts +69 -0
  554. package/dist/team/__tests__/tmux-test-fixture.d.ts.map +1 -1
  555. package/dist/team/__tests__/tmux-test-fixture.js +242 -15
  556. package/dist/team/__tests__/tmux-test-fixture.js.map +1 -1
  557. package/dist/team/__tests__/tmux-test-fixture.test.js +177 -1
  558. package/dist/team/__tests__/tmux-test-fixture.test.js.map +1 -1
  559. package/dist/team/__tests__/worker-runtime-identity.test.js +45 -72
  560. package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -1
  561. package/dist/team/notice-ledger.d.ts +54 -0
  562. package/dist/team/notice-ledger.d.ts.map +1 -0
  563. package/dist/team/notice-ledger.js +259 -0
  564. package/dist/team/notice-ledger.js.map +1 -0
  565. package/dist/team/phase-controller.d.ts.map +1 -1
  566. package/dist/team/phase-controller.js +2 -0
  567. package/dist/team/phase-controller.js.map +1 -1
  568. package/dist/team/runtime.d.ts +3 -1
  569. package/dist/team/runtime.d.ts.map +1 -1
  570. package/dist/team/runtime.js +314 -142
  571. package/dist/team/runtime.js.map +1 -1
  572. package/dist/team/scaling.d.ts.map +1 -1
  573. package/dist/team/scaling.js +161 -91
  574. package/dist/team/scaling.js.map +1 -1
  575. package/dist/team/state/monitor.d.ts +10 -0
  576. package/dist/team/state/monitor.d.ts.map +1 -1
  577. package/dist/team/state/monitor.js +13 -0
  578. package/dist/team/state/monitor.js.map +1 -1
  579. package/dist/team/state/types.d.ts +15 -0
  580. package/dist/team/state/types.d.ts.map +1 -1
  581. package/dist/team/state/types.js.map +1 -1
  582. package/dist/team/state-root.d.ts +12 -0
  583. package/dist/team/state-root.d.ts.map +1 -1
  584. package/dist/team/state-root.js +177 -0
  585. package/dist/team/state-root.js.map +1 -1
  586. package/dist/team/state.d.ts +18 -0
  587. package/dist/team/state.d.ts.map +1 -1
  588. package/dist/team/state.js +36 -0
  589. package/dist/team/state.js.map +1 -1
  590. package/dist/team/tmux-session.d.ts +40 -4
  591. package/dist/team/tmux-session.d.ts.map +1 -1
  592. package/dist/team/tmux-session.js +513 -166
  593. package/dist/team/tmux-session.js.map +1 -1
  594. package/dist/ultragoal/__tests__/artifacts.test.js +950 -2
  595. package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
  596. package/dist/ultragoal/__tests__/docs-contract.test.js +1 -0
  597. package/dist/ultragoal/__tests__/docs-contract.test.js.map +1 -1
  598. package/dist/ultragoal/artifacts.d.ts +35 -1
  599. package/dist/ultragoal/artifacts.d.ts.map +1 -1
  600. package/dist/ultragoal/artifacts.js +232 -46
  601. package/dist/ultragoal/artifacts.js.map +1 -1
  602. package/dist/utils/__tests__/file-durability.test.js +58 -1
  603. package/dist/utils/__tests__/file-durability.test.js.map +1 -1
  604. package/dist/utils/__tests__/paths.test.js +21 -1
  605. package/dist/utils/__tests__/paths.test.js.map +1 -1
  606. package/dist/utils/file-durability.d.ts +10 -0
  607. package/dist/utils/file-durability.d.ts.map +1 -1
  608. package/dist/utils/file-durability.js +34 -4
  609. package/dist/utils/file-durability.js.map +1 -1
  610. package/dist/utils/paths.d.ts.map +1 -1
  611. package/dist/utils/paths.js +14 -6
  612. package/dist/utils/paths.js.map +1 -1
  613. package/dist/verification/__tests__/ci-rust-gates.test.js +49 -4
  614. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  615. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +60 -0
  616. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  617. package/dist/verification/__tests__/native-release-manifest.test.js +157 -4
  618. package/dist/verification/__tests__/native-release-manifest.test.js.map +1 -1
  619. package/package.json +13 -5
  620. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  621. package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +81 -17
  622. package/plugins/oh-my-codex/skills/autopilot/SKILL.md +13 -5
  623. package/plugins/oh-my-codex/skills/cancel/SKILL.md +30 -85
  624. package/plugins/oh-my-codex/skills/ralph/SKILL.md +5 -5
  625. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +15 -17
  626. package/plugins/oh-my-codex/skills/ultragoal/SKILL.md +3 -2
  627. package/skills/autopilot/SKILL.md +13 -5
  628. package/skills/cancel/SKILL.md +30 -85
  629. package/skills/ralph/SKILL.md +5 -5
  630. package/skills/ralplan/SKILL.md +15 -17
  631. package/skills/ultragoal/SKILL.md +3 -2
  632. package/src/scripts/__tests__/codex-native-hook.test.ts +6091 -540
  633. package/src/scripts/__tests__/issue-3293-callsite-parity.test.ts +187 -0
  634. package/src/scripts/__tests__/issue-3293-cancel-transaction.test.ts +189 -0
  635. package/src/scripts/__tests__/issue-3293-hook-owned-cancel.test.ts +187 -0
  636. package/src/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.ts +591 -0
  637. package/src/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.ts +701 -0
  638. package/src/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.ts +211 -0
  639. package/src/scripts/__tests__/notify-state-io.test.ts +29 -0
  640. package/src/scripts/__tests__/notify-tmux-injection.test.ts +26 -1
  641. package/src/scripts/__tests__/postinstall.test.ts +23 -1
  642. package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +27 -8
  643. package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +61 -27
  644. package/src/scripts/__tests__/smoke-packed-install.test.ts +209 -18
  645. package/src/scripts/__tests__/verify-native-release-assets.test.ts +240 -0
  646. package/src/scripts/codex-native-hook.ts +3638 -475
  647. package/src/scripts/codex-native-pre-post.ts +43 -12
  648. package/src/scripts/generate-native-release-manifest.ts +83 -79
  649. package/src/scripts/notify-fallback-watcher.ts +38 -38
  650. package/src/scripts/notify-hook/state-io.ts +11 -0
  651. package/src/scripts/notify-hook/team-leader-nudge.ts +32 -4
  652. package/src/scripts/notify-hook/team-worker-stop.ts +23 -4
  653. package/src/scripts/notify-hook/team-worker.ts +43 -5
  654. package/src/scripts/notify-hook/tmux-injection.ts +5 -1
  655. package/src/scripts/notify-hook.ts +83 -82
  656. package/src/scripts/postinstall-advisory.ts +67 -0
  657. package/src/scripts/postinstall.ts +1 -1
  658. package/src/scripts/smoke-packed-install.ts +446 -59
  659. package/src/scripts/sync-plugin-mirror.ts +17 -1
  660. package/src/scripts/verify-native-release-assets.ts +39 -31
  661. package/templates/AGENTS.md +1 -0
@@ -3,10 +3,10 @@ import assert from 'node:assert/strict';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { once } from 'node:events';
5
5
  import { existsSync } from 'node:fs';
6
- import { mkdir, mkdtemp, readFile, readdir, rename, rm, rmdir, symlink, writeFile } from 'node:fs/promises';
6
+ import { link, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
- import { __createDefaultPidProbeForTests, appendPromptSessionProvenanceRejection, __resetSessionPointerTransactionDependenciesForTests, __setSessionPointerTransactionDependenciesForTests, inspectSessionPointerLock, isSessionPointerLaunchAbort, isSessionStale, readSessionPointer, readSessionState, readUsableSessionState, reconcileNativeSessionStart, recoverSessionPointerLock, resetSessionMetrics, resolveSessionPointerContext, writeSessionEnd, writeSessionStart, } from '../session.js';
9
+ import { __createDefaultPidProbeForTests, __releasePointerLockForTests, appendPromptSessionProvenanceRejection, closeLaunchSessionBindingOnce, establishLaunchSessionBinding, finalizeBoundOnce, __resetSessionPointerTransactionDependenciesForTests, __setSessionPointerTransactionDependenciesForTests, inspectSessionPointerLock, isSessionPointerLaunchAbort, isSessionStale, readSessionPointer, readSessionState, readUsableSessionState, readNativeSessionOwner, readNativeSessionOwnerEvidence, reconcileNativeSessionStart, recoverSessionPointerLock, resetSessionMetrics, resolveSessionPointerContext, writeSessionEnd, writeNativeSessionOwner, updateDetachedSessionMetadata, writeSessionStart, } from '../session.js';
10
10
  function todayIsoDate() {
11
11
  return new Date().toISOString().slice(0, 10);
12
12
  }
@@ -25,8 +25,17 @@ const SUCCESSOR_TOKEN = 'successor_token_123456789';
25
25
  function codedError(code) {
26
26
  return Object.assign(new Error(code), { code });
27
27
  }
28
+ function isOwnerConflict(error) {
29
+ return isSessionPointerLaunchAbort(error)
30
+ && error.code === 'session_pointer_owner_conflict';
31
+ }
28
32
  async function withPointerDependencies(overrides, run) {
29
- __setSessionPointerTransactionDependenciesForTests(overrides);
33
+ const runtimePlatform = overrides.runtimePlatform ?? process.platform;
34
+ __setSessionPointerTransactionDependenciesForTests({
35
+ atomicRenameNoReplace: defaultTestAtomicRenameNoReplace,
36
+ ...overrides,
37
+ runtimePlatform,
38
+ });
30
39
  try {
31
40
  await run();
32
41
  }
@@ -50,8 +59,11 @@ async function withOwnerEnvironment(sessionId, run) {
50
59
  process.env.OMX_SESSION_ID = previous;
51
60
  }
52
61
  }
53
- function matchingProcessIdentity() {
54
- return { status: 'matching', startTicks: 1 };
62
+ function matchingObservation(platform = process.platform) {
63
+ return { kind: 'identity', identity: { platform, birth: '1' } };
64
+ }
65
+ function matchingWin32Observation() {
66
+ return { kind: 'identity', identity: { platform: 'win32', birth: '1' } };
55
67
  }
56
68
  async function writeLockOwner(cwd, owner) {
57
69
  const context = resolveSessionPointerContext(cwd);
@@ -60,16 +72,64 @@ async function writeLockOwner(cwd, owner) {
60
72
  return context.lockPath;
61
73
  }
62
74
  function validLockOwner(overrides = {}) {
75
+ const platform = (overrides.platform ?? 'linux');
76
+ const version = overrides.version === 2 || overrides.process_identity !== undefined ? 2 : 1;
77
+ const identity = overrides.process_identity ?? (version === 2 ? { platform, birth: '1' } : undefined);
63
78
  return {
64
- version: 1,
79
+ version,
65
80
  token: TEST_TOKEN,
66
81
  pid: process.pid,
67
- platform: 'linux',
68
- pid_start_ticks: 1,
82
+ platform,
83
+ ...(version === 2 && identity ? { process_identity: identity } : {}),
84
+ ...(version === 1 ? { pid_start_ticks: 1 } : {}),
69
85
  created_at: '2026-07-14T00:00:00.000Z',
70
86
  ...overrides,
71
87
  };
72
88
  }
89
+ /** Safe test seam for capability probes: no-replace rename without overwriting destinations. */
90
+ async function defaultTestAtomicRenameNoReplace(from, to) {
91
+ if (existsSync(to))
92
+ return 'not-moved';
93
+ await rename(from, to);
94
+ return 'moved';
95
+ }
96
+ /** Wrap a lock-directory-focused seam so capability probes keep working. */
97
+ function withProbeFallback(contextLockPath, move) {
98
+ return async (from, to) => {
99
+ if (from === contextLockPath
100
+ || to === contextLockPath
101
+ || from.startsWith(`${contextLockPath}.`)
102
+ || to.startsWith(`${contextLockPath}.`)) {
103
+ return await move(from, to);
104
+ }
105
+ return await defaultTestAtomicRenameNoReplace(from, to);
106
+ };
107
+ }
108
+ async function assertDurableDirectoryRecovery(cwd, options = {}) {
109
+ const context = resolveSessionPointerContext(cwd);
110
+ await writeLockOwner(cwd, validLockOwner());
111
+ await withPointerDependencies({
112
+ token: () => SUCCESSOR_TOKEN,
113
+ probePid: () => 'dead',
114
+ atomicRenameNoReplace: options.atomicRenameNoReplace ?? defaultTestAtomicRenameNoReplace,
115
+ }, async () => {
116
+ const recovered = await recoverSessionPointerLock(cwd);
117
+ assert.equal(recovered.recovered, true, recovered.reason);
118
+ assert.equal(recovered.action, 'quarantined');
119
+ const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
120
+ const completed = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.completed`;
121
+ assert.equal(existsSync(context.lockPath), false);
122
+ assert.equal(existsSync(parked), true);
123
+ assert.equal(existsSync(completed), true);
124
+ assert.equal(existsSync(recovered.quarantinePath), true);
125
+ const quarantine = await lstat(recovered.quarantinePath);
126
+ const parkedOwner = await lstat(join(parked, 'owner.json'));
127
+ assert.deepEqual({ dev: quarantine.dev, ino: quarantine.ino }, { dev: parkedOwner.dev, ino: parkedOwner.ino });
128
+ const repeated = await recoverSessionPointerLock(cwd);
129
+ assert.equal(repeated.recovered, false);
130
+ assert.equal(repeated.status, 'absent');
131
+ });
132
+ }
73
133
  async function linuxProcessStartTicks(pid) {
74
134
  const stat = await readFile(`/proc/${pid}/stat`, 'utf-8');
75
135
  const closingParen = stat.lastIndexOf(')');
@@ -132,8 +192,13 @@ describe('session lifecycle manager', () => {
132
192
  it('writes session start/end lifecycle artifacts and archives session history', async () => {
133
193
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lifecycle-'));
134
194
  const sessionId = 'sess-lifecycle-1';
195
+ let binding;
135
196
  try {
136
- await writeSessionStart(cwd, sessionId);
197
+ const established = await establishLaunchSessionBinding(cwd, sessionId);
198
+ assert.equal(established.kind, 'committed-released');
199
+ if (established.kind !== 'committed-released')
200
+ return;
201
+ binding = established.binding;
137
202
  const state = await readSessionState(cwd);
138
203
  assert.ok(state);
139
204
  assert.equal(state.session_id, sessionId);
@@ -142,7 +207,7 @@ describe('session lifecycle manager', () => {
142
207
  assert.equal(isSessionStale(state), false);
143
208
  const sessionPath = join(cwd, '.omx', 'state', 'session.json');
144
209
  assert.equal(existsSync(sessionPath), true);
145
- await writeSessionEnd(cwd, sessionId);
210
+ await finalizeBoundOnce(binding, 'test');
146
211
  assert.equal(existsSync(sessionPath), false);
147
212
  const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
148
213
  assert.equal(existsSync(historyPath), true);
@@ -163,6 +228,8 @@ describe('session lifecycle manager', () => {
163
228
  assert.match(dailyLog, /"event":"session_end"/);
164
229
  }
165
230
  finally {
231
+ if (binding)
232
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
166
233
  await rm(cwd, { recursive: true, force: true });
167
234
  }
168
235
  });
@@ -174,8 +241,16 @@ describe('session lifecycle manager', () => {
174
241
  warnings.push(value);
175
242
  return true;
176
243
  });
244
+ let binding;
177
245
  try {
246
+ const established = await establishLaunchSessionBinding(cwd, 'sess-end-durability');
247
+ assert.equal(established.kind, 'committed-released');
248
+ if (established.kind !== 'committed-released')
249
+ return;
250
+ binding = established.binding;
178
251
  await writeSessionEnd(cwd, 'sess-end-durability', {
252
+ binding,
253
+ context: binding.context,
179
254
  platform: 'win32',
180
255
  regularFileSync: async () => { throw codedError('EPERM'); },
181
256
  });
@@ -184,6 +259,8 @@ describe('session lifecycle manager', () => {
184
259
  ]);
185
260
  }
186
261
  finally {
262
+ if (binding)
263
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
187
264
  process.stderr.write = originalWrite;
188
265
  await rm(cwd, { recursive: true, force: true });
189
266
  }
@@ -197,13 +274,22 @@ describe('session lifecycle manager', () => {
197
274
  return true;
198
275
  });
199
276
  const regularFileSync = async () => { throw codedError('EPERM'); };
277
+ let binding;
200
278
  try {
279
+ const established = await establishLaunchSessionBinding(cwd, 'sess-end-release-failure');
280
+ assert.equal(established.kind, 'committed-released');
281
+ if (established.kind !== 'committed-released')
282
+ return;
283
+ binding = established.binding;
284
+ const establishedBinding = binding;
201
285
  await withPointerDependencies({
202
286
  fs: {
203
287
  rmdir: async () => { throw new Error('release failure'); },
204
288
  },
205
289
  }, async () => {
206
290
  await assert.rejects(writeSessionEnd(cwd, 'sess-end-release-failure', {
291
+ binding: establishedBinding,
292
+ context: establishedBinding.context,
207
293
  platform: 'win32',
208
294
  regularFileSync,
209
295
  }));
@@ -211,6 +297,8 @@ describe('session lifecycle manager', () => {
211
297
  assert.deepEqual(warnings, []);
212
298
  }
213
299
  finally {
300
+ if (binding)
301
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
214
302
  process.stderr.write = originalWrite;
215
303
  await rm(cwd, { recursive: true, force: true });
216
304
  }
@@ -219,6 +307,7 @@ describe('session lifecycle manager', () => {
219
307
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-owner-'));
220
308
  try {
221
309
  await writeSessionStart(cwd, 'sess-current');
310
+ const beforePointer = await readFile(resolveSessionPointerContext(cwd).sessionPath, 'utf-8');
222
311
  const stateDir = join(cwd, '.omx', 'state');
223
312
  const sessionPath = join(stateDir, 'session.json');
224
313
  const currentHudPath = join(stateDir, 'sessions', 'sess-current', 'hud-state.json');
@@ -227,21 +316,13 @@ describe('session lifecycle manager', () => {
227
316
  await mkdir(join(stateDir, 'sessions', 'sess-ending'), { recursive: true });
228
317
  await writeFile(currentHudPath, JSON.stringify({ turn_count: 2 }), 'utf-8');
229
318
  await writeFile(endingHudPath, JSON.stringify({ turn_count: 1 }), 'utf-8');
230
- await writeSessionEnd(cwd, 'sess-ending');
319
+ await assert.rejects(() => writeSessionEnd(cwd, 'sess-ending'), isSessionPointerLaunchAbort);
231
320
  const state = await readSessionState(cwd);
232
321
  assert.equal(state?.session_id, 'sess-current');
233
- assert.equal(existsSync(sessionPath), true);
322
+ assert.equal(await readFile(sessionPath, 'utf-8'), beforePointer);
234
323
  assert.equal(existsSync(currentHudPath), true);
235
- assert.equal(existsSync(endingHudPath), false);
236
- const historyLines = (await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8'))
237
- .trim()
238
- .split('\n')
239
- .filter(Boolean);
240
- assert.equal(historyLines.length, 1);
241
- const historyEntry = JSON.parse(historyLines[0]);
242
- assert.equal(historyEntry.session_id, 'sess-ending');
243
- assert.equal(historyEntry.started_at, 'unknown');
244
- assert.equal(historyEntry.preserved_active_session_id, 'sess-current');
324
+ assert.equal(existsSync(endingHudPath), true);
325
+ assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
245
326
  }
246
327
  finally {
247
328
  await rm(cwd, { recursive: true, force: true });
@@ -251,8 +332,13 @@ describe('session lifecycle manager', () => {
251
332
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-hud-cleanup-'));
252
333
  const canonicalSessionId = 'omx-launch-hud';
253
334
  const nativeSessionId = 'codex-native-hud';
335
+ let binding;
254
336
  try {
255
- await writeSessionStart(cwd, canonicalSessionId, { nativeSessionId });
337
+ const established = await establishLaunchSessionBinding(cwd, canonicalSessionId, { nativeSessionId });
338
+ assert.equal(established.kind, 'committed-released');
339
+ if (established.kind !== 'committed-released')
340
+ return;
341
+ binding = established.binding;
256
342
  const stateDir = join(cwd, '.omx', 'state');
257
343
  const rootHudPath = join(stateDir, 'hud-state.json');
258
344
  const canonicalHudPath = join(stateDir, 'sessions', canonicalSessionId, 'hud-state.json');
@@ -262,19 +348,21 @@ describe('session lifecycle manager', () => {
262
348
  await writeFile(rootHudPath, JSON.stringify({ last_turn_at: 'root', turn_count: 1 }), 'utf-8');
263
349
  await writeFile(canonicalHudPath, JSON.stringify({ last_turn_at: 'canonical', turn_count: 2 }), 'utf-8');
264
350
  await writeFile(nativeHudPath, JSON.stringify({ last_turn_at: 'native', turn_count: 9 }), 'utf-8');
265
- await writeSessionEnd(cwd, canonicalSessionId);
351
+ await finalizeBoundOnce(binding, 'test');
266
352
  assert.equal(existsSync(rootHudPath), false);
267
353
  assert.equal(existsSync(canonicalHudPath), false);
268
354
  assert.equal(existsSync(nativeHudPath), false);
269
355
  }
270
356
  finally {
357
+ if (binding)
358
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
271
359
  await rm(cwd, { recursive: true, force: true });
272
360
  }
273
361
  });
274
362
  it('preserves canonical session id while reconciling native SessionStart metadata', async () => {
275
363
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-reconcile-'));
276
364
  try {
277
- await writeSessionStart(cwd, 'omx-launch-1');
365
+ const established = await writeSessionStart(cwd, 'omx-launch-1');
278
366
  const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-1', {
279
367
  pid: 54321,
280
368
  platform: 'win32',
@@ -283,10 +371,12 @@ describe('session lifecycle manager', () => {
283
371
  assert.equal(reconciled.native_session_id, 'codex-native-1');
284
372
  assert.equal(reconciled.pid, 54321);
285
373
  assert.equal(reconciled.platform, 'win32');
374
+ assert.equal(reconciled.launch_lineage_token, established.launch_lineage_token);
286
375
  const persisted = await readSessionState(cwd);
287
376
  assert.equal(persisted?.session_id, 'omx-launch-1');
288
377
  assert.equal(persisted?.native_session_id, 'codex-native-1');
289
378
  assert.equal(persisted?.pid, 54321);
379
+ assert.equal(persisted?.launch_lineage_token, established.launch_lineage_token);
290
380
  const dailyLogPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
291
381
  const dailyLog = await readFile(dailyLogPath, 'utf-8');
292
382
  assert.match(dailyLog, /"event":"session_start_reconciled"/);
@@ -296,74 +386,76 @@ describe('session lifecycle manager', () => {
296
386
  await rm(cwd, { recursive: true, force: true });
297
387
  }
298
388
  });
299
- it('starts a fresh native session while retaining the owner OMX launch session when native SessionStart changes', async () => {
389
+ it('rejects replacing a live native session pointer with another native SessionStart', async () => {
300
390
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-fresh-'));
301
391
  try {
302
392
  await writeSessionStart(cwd, 'omx-old-session', {
303
393
  nativeSessionId: 'codex-native-old',
304
394
  });
305
- const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-new', {
395
+ await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-new', {
306
396
  pid: 54321,
307
397
  platform: 'win32',
308
- });
309
- assert.equal(reconciled.session_id, 'codex-native-new');
310
- assert.equal(reconciled.native_session_id, 'codex-native-new');
311
- assert.equal(reconciled.previous_native_session_id, 'codex-native-old');
312
- assert.equal(reconciled.owner_omx_session_id, 'omx-old-session');
313
- assert.match(reconciled.native_session_switched_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
314
- assert.equal(reconciled.pid, 54321);
398
+ }), isOwnerConflict);
315
399
  const persisted = await readSessionState(cwd);
316
- assert.equal(persisted?.session_id, 'codex-native-new');
317
- assert.equal(persisted?.native_session_id, 'codex-native-new');
318
- assert.equal(persisted?.previous_native_session_id, 'codex-native-old');
319
- assert.equal(persisted?.owner_omx_session_id, 'omx-old-session');
320
- const dailyLogPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
321
- const dailyLog = await readFile(dailyLogPath, 'utf-8');
322
- assert.match(dailyLog, /"event":"native_session_replaced"/);
323
- assert.match(dailyLog, /"event":"session_start"/);
324
- assert.match(dailyLog, /"previous_native_session_id":"codex-native-old"/);
325
- assert.match(dailyLog, /"native_session_id":"codex-native-new"/);
400
+ assert.equal(persisted?.session_id, 'omx-old-session');
401
+ assert.equal(persisted?.native_session_id, 'codex-native-old');
326
402
  }
327
403
  finally {
328
404
  await rm(cwd, { recursive: true, force: true });
329
405
  }
330
406
  });
331
- it('preserves existing native and tmux bindings on same-session start updates', async () => {
407
+ it('routes existing detached metadata updates through the exact binding', async () => {
332
408
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-preserve-'));
409
+ let binding;
333
410
  try {
334
- await writeSessionStart(cwd, 'omx-launch-1', {
411
+ const established = await establishLaunchSessionBinding(cwd, 'omx-launch-1', {
335
412
  nativeSessionId: 'codex-native-1',
336
413
  tmuxSessionName: 'omx-detached-demo',
337
414
  });
338
- const withPane = await writeSessionStart(cwd, 'omx-launch-1', {
415
+ assert.equal(established.kind, 'committed-released');
416
+ if (established.kind !== 'committed-released')
417
+ return;
418
+ binding = established.binding;
419
+ const withPaneResult = await updateDetachedSessionMetadata(binding, {
339
420
  tmuxSessionName: 'omx-detached-demo',
340
421
  tmuxPaneId: '%42',
341
422
  });
342
- assert.equal(withPane.native_session_id, 'codex-native-1');
343
- assert.equal(withPane.tmux_session_name, 'omx-detached-demo');
344
- assert.equal(withPane.tmux_pane_id, '%42');
345
- const withoutPane = await writeSessionStart(cwd, 'omx-launch-1', {
423
+ assert.equal(withPaneResult.kind, 'committed-released');
424
+ const withPane = await readSessionState(cwd);
425
+ assert.equal(withPane?.native_session_id, 'codex-native-1');
426
+ assert.equal(withPane?.tmux_session_name, 'omx-detached-demo');
427
+ assert.equal(withPane?.tmux_pane_id, '%42');
428
+ const withoutPaneResult = await updateDetachedSessionMetadata(binding, {
346
429
  tmuxSessionName: 'omx-detached-demo',
347
430
  });
348
- assert.equal(withoutPane.native_session_id, 'codex-native-1');
349
- assert.equal(withoutPane.tmux_session_name, 'omx-detached-demo');
350
- assert.equal(withoutPane.tmux_pane_id, '%42');
431
+ assert.equal(withoutPaneResult.kind, 'committed-released');
432
+ const withoutPane = await readSessionState(cwd);
433
+ assert.equal(withoutPane?.native_session_id, 'codex-native-1');
434
+ assert.equal(withoutPane?.tmux_session_name, 'omx-detached-demo');
435
+ assert.equal(withoutPane?.tmux_pane_id, '%42');
351
436
  }
352
437
  finally {
438
+ if (binding)
439
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
353
440
  await rm(cwd, { recursive: true, force: true });
354
441
  }
355
442
  });
356
- it('lets an owner OMX launch session end the fresh native session it spawned', async () => {
443
+ it('lets an owner OMX launch session end after rejecting an unrelated native replacement', async () => {
357
444
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-owner-end-'));
445
+ let binding;
358
446
  try {
359
- await writeSessionStart(cwd, 'omx-owner-session', {
447
+ const established = await establishLaunchSessionBinding(cwd, 'omx-owner-session', {
360
448
  nativeSessionId: 'codex-native-old',
361
449
  });
362
- await reconcileNativeSessionStart(cwd, 'codex-native-new', {
450
+ assert.equal(established.kind, 'committed-released');
451
+ if (established.kind !== 'committed-released')
452
+ return;
453
+ binding = established.binding;
454
+ await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-new', {
363
455
  pid: process.pid,
364
456
  platform: 'win32',
365
- });
366
- await writeSessionEnd(cwd, 'omx-owner-session');
457
+ }), isOwnerConflict);
458
+ await finalizeBoundOnce(binding, 'test');
367
459
  assert.equal(await readSessionState(cwd), null);
368
460
  const historyLines = (await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8'))
369
461
  .trim()
@@ -372,64 +464,73 @@ describe('session lifecycle manager', () => {
372
464
  assert.equal(historyLines.length, 1);
373
465
  const historyEntry = JSON.parse(historyLines[0]);
374
466
  assert.equal(historyEntry.session_id, 'omx-owner-session');
375
- assert.equal(historyEntry.native_session_id, 'codex-native-new');
376
- assert.equal(historyEntry.active_session_id, 'codex-native-new');
467
+ assert.equal(historyEntry.native_session_id, 'codex-native-old');
468
+ assert.equal(historyEntry.active_session_id, undefined);
377
469
  }
378
470
  finally {
471
+ if (binding)
472
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
379
473
  await rm(cwd, { recursive: true, force: true });
380
474
  }
381
475
  });
382
- it('preserves owner OMX metadata when reconciling the same fresh native session', async () => {
476
+ it('preserves canonical session metadata when reconciling the same native session', async () => {
383
477
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-owner-reconcile-'));
478
+ let binding;
384
479
  try {
385
- await writeSessionStart(cwd, 'omx-owner-session', {
386
- nativeSessionId: 'codex-native-old',
387
- });
388
- await reconcileNativeSessionStart(cwd, 'codex-native-new', {
389
- pid: process.pid,
390
- platform: 'win32',
391
- });
392
- const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-new', {
393
- pid: process.pid,
394
- platform: 'win32',
480
+ await withPointerDependencies({
481
+ runtimePlatform: 'win32',
482
+ observeProcess: () => matchingWin32Observation(),
483
+ }, async () => {
484
+ const established = await establishLaunchSessionBinding(cwd, 'omx-owner-session', {
485
+ nativeSessionId: 'codex-native-old',
486
+ platform: 'win32',
487
+ });
488
+ assert.equal(established.kind, 'committed-released');
489
+ if (established.kind !== 'committed-released')
490
+ return;
491
+ binding = established.binding;
492
+ const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-old', {
493
+ pid: process.pid,
494
+ platform: 'win32',
495
+ });
496
+ assert.equal(reconciled.session_id, 'omx-owner-session');
497
+ assert.equal(reconciled.native_session_id, 'codex-native-old');
498
+ assert.equal(reconciled.previous_native_session_id, undefined);
499
+ assert.equal(reconciled.owner_omx_session_id, undefined);
500
+ assert.equal(reconciled.pid, process.pid);
501
+ await finalizeBoundOnce(binding, 'test');
502
+ assert.equal(await readSessionState(cwd), null);
395
503
  });
396
- assert.equal(reconciled.session_id, 'codex-native-new');
397
- assert.equal(reconciled.native_session_id, 'codex-native-new');
398
- assert.equal(reconciled.previous_native_session_id, 'codex-native-old');
399
- assert.equal(reconciled.owner_omx_session_id, 'omx-owner-session');
400
- assert.equal(reconciled.pid, process.pid);
401
- await writeSessionEnd(cwd, 'omx-owner-session');
402
- assert.equal(await readSessionState(cwd), null);
403
504
  }
404
505
  finally {
506
+ if (binding)
507
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
405
508
  await rm(cwd, { recursive: true, force: true });
406
509
  }
407
510
  });
408
- it('carries the owner OMX launch session across chained native SessionStart replacements', async () => {
511
+ it('rejects chained native SessionStart replacements and preserves the original pointer', async () => {
409
512
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-owner-chain-'));
513
+ let binding;
410
514
  try {
411
- await writeSessionStart(cwd, 'omx-owner-session', {
515
+ const established = await establishLaunchSessionBinding(cwd, 'omx-owner-session', {
412
516
  nativeSessionId: 'codex-native-a',
413
517
  });
414
- await reconcileNativeSessionStart(cwd, 'codex-native-b', {
518
+ assert.equal(established.kind, 'committed-released');
519
+ if (established.kind !== 'committed-released')
520
+ return;
521
+ binding = established.binding;
522
+ await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-b', {
415
523
  pid: process.pid,
416
524
  platform: 'win32',
417
- });
418
- const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-c', {
525
+ }), isOwnerConflict);
526
+ await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-c', {
419
527
  pid: process.pid,
420
528
  platform: 'win32',
421
- });
422
- assert.equal(reconciled.session_id, 'codex-native-c');
423
- assert.equal(reconciled.native_session_id, 'codex-native-c');
424
- assert.equal(reconciled.previous_native_session_id, 'codex-native-b');
425
- assert.equal(reconciled.owner_omx_session_id, 'omx-owner-session');
426
- const dailyLogPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
427
- const dailyLog = await readFile(dailyLogPath, 'utf-8');
428
- assert.match(dailyLog, /"session_id":"omx-owner-session"/);
429
- assert.match(dailyLog, /"active_session_id":"codex-native-b"/);
430
- assert.match(dailyLog, /"previous_native_session_id":"codex-native-b"/);
431
- assert.match(dailyLog, /"replaced_by_native_session_id":"codex-native-c"/);
432
- await writeSessionEnd(cwd, 'omx-owner-session');
529
+ }), isOwnerConflict);
530
+ const persisted = await readSessionState(cwd);
531
+ assert.equal(persisted?.session_id, 'omx-owner-session');
532
+ assert.equal(persisted?.native_session_id, 'codex-native-a');
533
+ await finalizeBoundOnce(binding, 'test');
433
534
  assert.equal(await readSessionState(cwd), null);
434
535
  const historyLines = (await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8'))
435
536
  .trim()
@@ -437,30 +538,28 @@ describe('session lifecycle manager', () => {
437
538
  .filter(Boolean);
438
539
  const historyEntry = JSON.parse(historyLines.at(-1) ?? '{}');
439
540
  assert.equal(historyEntry.session_id, 'omx-owner-session');
440
- assert.equal(historyEntry.native_session_id, 'codex-native-c');
441
- assert.equal(historyEntry.active_session_id, 'codex-native-c');
541
+ assert.equal(historyEntry.native_session_id, 'codex-native-a');
542
+ assert.equal(historyEntry.active_session_id, undefined);
442
543
  }
443
544
  finally {
545
+ if (binding)
546
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
444
547
  await rm(cwd, { recursive: true, force: true });
445
548
  }
446
549
  });
447
- it('starts a fresh canonical session when a non-OMX native session is replaced', async () => {
550
+ it('rejects replacing a live wrapper-owned native session', async () => {
448
551
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-non-omx-fresh-'));
449
552
  try {
450
553
  await writeSessionStart(cwd, 'codex-native-old', {
451
554
  nativeSessionId: 'codex-native-old',
452
555
  });
453
- const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-new', {
556
+ await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-new', {
454
557
  pid: 54321,
455
558
  platform: 'win32',
456
- });
457
- assert.equal(reconciled.session_id, 'codex-native-new');
458
- assert.equal(reconciled.native_session_id, 'codex-native-new');
459
- assert.equal(reconciled.previous_native_session_id, undefined);
460
- assert.equal(reconciled.pid, 54321);
559
+ }), isOwnerConflict);
461
560
  const persisted = await readSessionState(cwd);
462
- assert.equal(persisted?.session_id, 'codex-native-new');
463
- assert.equal(persisted?.native_session_id, 'codex-native-new');
561
+ assert.equal(persisted?.session_id, 'codex-native-old');
562
+ assert.equal(persisted?.native_session_id, 'codex-native-old');
464
563
  assert.equal(persisted?.previous_native_session_id, undefined);
465
564
  }
466
565
  finally {
@@ -606,14 +705,29 @@ describe('isSessionStale', () => {
606
705
  });
607
706
  assert.equal(stale, true);
608
707
  });
609
- it('falls back to PID liveness on non-Linux platforms', () => {
610
- const state = makeState();
611
- const stale = isSessionStale(state, {
612
- platform: 'darwin',
613
- isPidAlive: () => true,
614
- readLinuxIdentity: () => null,
615
- });
616
- assert.equal(stale, false);
708
+ it('classifies identity-less live non-Linux pointers as identity-indeterminate', async () => {
709
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-non-linux-identityless-'));
710
+ try {
711
+ const context = resolveSessionPointerContext(cwd);
712
+ await mkdir(context.baseStateDir, { recursive: true });
713
+ await writeFile(context.sessionPath, JSON.stringify({
714
+ session_id: 'sess-darwin-identityless',
715
+ started_at: '2026-07-14T00:00:00.000Z',
716
+ cwd,
717
+ pid: process.pid,
718
+ platform: 'darwin',
719
+ }), 'utf-8');
720
+ await withPointerDependencies({
721
+ runtimePlatform: 'darwin',
722
+ probePid: () => 'alive',
723
+ observeProcess: () => ({ kind: 'unsupported' }),
724
+ }, async () => {
725
+ assert.equal((await readSessionPointer(context)).status, 'identity-indeterminate');
726
+ });
727
+ }
728
+ finally {
729
+ await rm(cwd, { recursive: true, force: true });
730
+ }
617
731
  });
618
732
  });
619
733
  describe('session pointer transaction', () => {
@@ -623,13 +737,39 @@ describe('session pointer transaction', () => {
623
737
  && error.operation === 'pointer-context-resolve'
624
738
  && !('pointerPath' in error));
625
739
  });
740
+ it('keeps the default synthetic identity aligned with the host transaction platform', async () => {
741
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-platform-fixture-'));
742
+ try {
743
+ const context = resolveSessionPointerContext(cwd);
744
+ await mkdir(context.baseStateDir, { recursive: true });
745
+ await writeFile(context.sessionPath, JSON.stringify({
746
+ session_id: 'sess-platform-fixture',
747
+ started_at: '2026-07-14T00:00:00.000Z',
748
+ cwd,
749
+ pid: process.pid,
750
+ platform: process.platform,
751
+ identity_schema_version: 2,
752
+ process_identity: { platform: process.platform, birth: '1' },
753
+ }), 'utf-8');
754
+ await withPointerDependencies({
755
+ probePid: () => 'alive',
756
+ observeProcess: () => matchingObservation(),
757
+ }, async () => {
758
+ assert.equal((await readSessionPointer(context)).status, 'usable');
759
+ });
760
+ }
761
+ finally {
762
+ __resetSessionPointerTransactionDependenciesForTests();
763
+ await rm(cwd, { recursive: true, force: true });
764
+ }
765
+ });
626
766
  it('classifies only the exact selected pointer as absent, usable, stale, indeterminate, malformed, or foreign', async () => {
627
767
  const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-status-'));
628
768
  try {
629
769
  const context = resolveSessionPointerContext(cwd);
630
770
  await withPointerDependencies({
631
771
  probePid: () => 'alive',
632
- readProcessIdentity: () => matchingProcessIdentity(),
772
+ observeProcess: () => matchingObservation(),
633
773
  }, async () => {
634
774
  assert.equal((await readSessionPointer(context)).status, 'absent');
635
775
  await mkdir(context.baseStateDir, { recursive: true });
@@ -644,12 +784,12 @@ describe('session pointer transaction', () => {
644
784
  assert.equal((await readSessionPointer(context)).status, 'usable');
645
785
  __setSessionPointerTransactionDependenciesForTests({
646
786
  probePid: () => 'dead',
647
- readProcessIdentity: () => matchingProcessIdentity(),
787
+ observeProcess: () => matchingObservation(),
648
788
  });
649
789
  assert.equal((await readSessionPointer(context)).status, 'stale-dead');
650
790
  __setSessionPointerTransactionDependenciesForTests({
651
791
  probePid: () => 'indeterminate',
652
- readProcessIdentity: () => matchingProcessIdentity(),
792
+ observeProcess: () => matchingObservation(),
653
793
  });
654
794
  assert.equal((await readSessionPointer(context)).status, 'identity-indeterminate');
655
795
  await writeFile(context.sessionPath, '{ not-json', 'utf-8');
@@ -669,6 +809,277 @@ describe('session pointer transaction', () => {
669
809
  await rm(cwd, { recursive: true, force: true });
670
810
  }
671
811
  });
812
+ it('covers the process identity decision table through pointer and lock inspection', async () => {
813
+ const hashA = 'aaa'.repeat(21) + 'a';
814
+ const hashB = 'bbb'.repeat(21) + 'b';
815
+ const identityOwner = (identity) => validLockOwner({
816
+ pid: 4242,
817
+ version: 2,
818
+ platform: identity.platform,
819
+ process_identity: identity,
820
+ });
821
+ const legacyOwner = (platform = 'linux', startTicks = 1) => validLockOwner({
822
+ pid: 4242,
823
+ platform,
824
+ pid_start_ticks: startTicks,
825
+ });
826
+ const identitylessOwner = (platform) => validLockOwner({
827
+ pid: 4242,
828
+ platform,
829
+ pid_start_ticks: undefined,
830
+ });
831
+ const identity = (platform, birth, cmdline_hash) => ({
832
+ kind: 'identity',
833
+ identity: { platform, birth, ...(cmdline_hash ? { cmdline_hash } : {}) },
834
+ });
835
+ const classifierCases = [
836
+ {
837
+ name: 'usable match',
838
+ recordedState: {
839
+ platform: 'linux', pid_start_ticks: 1, identity_schema_version: 2,
840
+ process_identity: { platform: 'linux', birth: '1' },
841
+ },
842
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1')],
843
+ lockOwner: identityOwner({ platform: 'linux', birth: '1' }),
844
+ expectedPointerStatus: 'usable', expectedLockStatus: 'live',
845
+ },
846
+ {
847
+ name: 'darwin match',
848
+ recordedState: { identity_schema_version: 2, process_identity: { platform: 'darwin', birth: '1234.567' } },
849
+ runtimePlatform: 'darwin', probePid: 'alive', observations: [identity('darwin', '1234.567')],
850
+ lockOwner: identityOwner({ platform: 'darwin', birth: '1234.567' }),
851
+ expectedPointerStatus: 'usable', expectedLockStatus: 'live',
852
+ },
853
+ {
854
+ name: 'windows exact FILETIME',
855
+ recordedState: { identity_schema_version: 2, process_identity: { platform: 'win32', birth: '132580896000000000' } },
856
+ runtimePlatform: 'win32', probePid: 'alive', observations: [identity('win32', '132580896000000000')],
857
+ lockOwner: identityOwner({ platform: 'win32', birth: '132580896000000000' }),
858
+ expectedPointerStatus: 'usable', expectedLockStatus: 'live',
859
+ },
860
+ {
861
+ name: 'dead PID',
862
+ recordedState: { platform: 'linux', pid_start_ticks: 1 },
863
+ runtimePlatform: 'linux', probePid: 'dead', observations: [],
864
+ lockOwner: legacyOwner(),
865
+ expectedPointerStatus: 'stale-dead', expectedLockStatus: 'dead',
866
+ },
867
+ {
868
+ name: 'birth mismatch',
869
+ recordedState: { platform: 'linux', pid_start_ticks: 1 },
870
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '2'), identity('linux', '2')],
871
+ lockOwner: legacyOwner(),
872
+ expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
873
+ },
874
+ {
875
+ name: 'TOCTOU retry agree',
876
+ recordedState: { platform: 'linux', pid_start_ticks: 1 },
877
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '2'), identity('linux', '1')],
878
+ lockOwner: legacyOwner(),
879
+ expectedPointerStatus: 'usable', expectedLockStatus: 'live',
880
+ },
881
+ {
882
+ name: 'TOCTOU retry disagree',
883
+ recordedState: { platform: 'linux', pid_start_ticks: 1 },
884
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '2'), identity('linux', '3')],
885
+ lockOwner: legacyOwner(),
886
+ expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
887
+ },
888
+ {
889
+ name: 'EPERM probePid',
890
+ recordedState: { platform: 'linux', pid_start_ticks: 1 },
891
+ runtimePlatform: 'linux', probePid: 'indeterminate', observations: [],
892
+ lockOwner: legacyOwner(),
893
+ expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
894
+ },
895
+ {
896
+ name: 'provider denied',
897
+ recordedState: { platform: 'linux', pid_start_ticks: 1 },
898
+ runtimePlatform: 'linux', probePid: 'alive', observations: [{ kind: 'denied' }],
899
+ lockOwner: legacyOwner(),
900
+ expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
901
+ },
902
+ {
903
+ name: 'provider error',
904
+ recordedState: { platform: 'linux', pid_start_ticks: 1 },
905
+ runtimePlatform: 'linux', probePid: 'alive', observations: [{ kind: 'error' }],
906
+ lockOwner: legacyOwner(),
907
+ expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
908
+ },
909
+ {
910
+ name: 'provider unsupported',
911
+ recordedState: { identity_schema_version: 2, process_identity: { platform: 'freebsd', birth: '1' } },
912
+ runtimePlatform: 'freebsd', probePid: 'alive', observations: [{ kind: 'unsupported' }],
913
+ lockOwner: identityOwner({ platform: 'freebsd', birth: '1' }),
914
+ expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
915
+ },
916
+ {
917
+ name: 'foreign platform',
918
+ recordedState: { identity_schema_version: 2, process_identity: { platform: 'darwin', birth: '1' } },
919
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1')],
920
+ lockOwner: identityOwner({ platform: 'darwin', birth: '1' }),
921
+ expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
922
+ },
923
+ {
924
+ name: 'cmdline hash match',
925
+ recordedState: {
926
+ identity_schema_version: 2,
927
+ process_identity: { platform: 'linux', birth: '1', cmdline_hash: hashA },
928
+ },
929
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1', hashA)],
930
+ lockOwner: identityOwner({ platform: 'linux', birth: '1', cmdline_hash: hashA }),
931
+ expectedPointerStatus: 'usable', expectedLockStatus: 'live',
932
+ },
933
+ {
934
+ name: 'cmdline hash mismatch',
935
+ recordedState: {
936
+ identity_schema_version: 2,
937
+ process_identity: { platform: 'linux', birth: '1', cmdline_hash: hashA },
938
+ },
939
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1', hashB)],
940
+ lockOwner: identityOwner({ platform: 'linux', birth: '1', cmdline_hash: hashA }),
941
+ expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
942
+ },
943
+ {
944
+ name: 'legacy v1 upgrade Linux',
945
+ recordedState: { platform: 'linux', pid_start_ticks: 111 },
946
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '111')],
947
+ lockOwner: legacyOwner('linux', 111),
948
+ expectedPointerStatus: 'usable', expectedLockStatus: 'live',
949
+ },
950
+ {
951
+ name: 'legacy v1 mismatch Linux',
952
+ recordedState: { platform: 'linux', pid_start_ticks: 111 },
953
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '222'), identity('linux', '222')],
954
+ lockOwner: legacyOwner('linux', 111),
955
+ expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
956
+ },
957
+ {
958
+ name: 'legacy identity-less live non-Linux',
959
+ recordedState: { platform: 'darwin' },
960
+ runtimePlatform: 'darwin', probePid: 'alive', observations: [{ kind: 'unsupported' }],
961
+ lockOwner: identitylessOwner('darwin'),
962
+ expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
963
+ },
964
+ {
965
+ name: 'future schema v3',
966
+ recordedState: { identity_schema_version: 3, process_identity: { platform: 'linux', birth: '1' } },
967
+ runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1')],
968
+ lockOwner: identitylessOwner('darwin'),
969
+ expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
970
+ },
971
+ {
972
+ name: 'malformed JSON',
973
+ rawPointer: '{not-json',
974
+ runtimePlatform: 'linux', probePid: 'alive', observations: [], lockOwner: 'malformed',
975
+ expectedPointerStatus: 'malformed', expectedLockStatus: 'malformed',
976
+ },
977
+ {
978
+ name: 'same-second Darwin collision',
979
+ recordedState: { identity_schema_version: 2, process_identity: { platform: 'darwin', birth: '1000.500' } },
980
+ runtimePlatform: 'darwin', probePid: 'alive', observations: [identity('darwin', '1000.999'), identity('darwin', '1000.999')],
981
+ lockOwner: identityOwner({ platform: 'darwin', birth: '1000.500' }),
982
+ expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
983
+ },
984
+ {
985
+ name: 'absent pointer',
986
+ runtimePlatform: 'linux', probePid: 'alive', observations: [],
987
+ expectedPointerStatus: 'absent', expectedLockStatus: 'absent',
988
+ },
989
+ ];
990
+ for (const testCase of classifierCases) {
991
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-classifier-'));
992
+ try {
993
+ const context = resolveSessionPointerContext(cwd);
994
+ await mkdir(context.baseStateDir, { recursive: true });
995
+ if (testCase.rawPointer !== undefined) {
996
+ await writeFile(context.sessionPath, testCase.rawPointer, 'utf-8');
997
+ }
998
+ else if (testCase.recordedState) {
999
+ await writeFile(context.sessionPath, JSON.stringify({
1000
+ session_id: 'sess-classifier',
1001
+ started_at: '2026-07-14T00:00:00.000Z',
1002
+ cwd,
1003
+ pid: 4242,
1004
+ ...testCase.recordedState,
1005
+ }), 'utf-8');
1006
+ }
1007
+ if (testCase.lockOwner !== undefined) {
1008
+ await mkdir(context.lockPath, { recursive: true });
1009
+ await writeFile(join(context.lockPath, 'owner.json'), testCase.lockOwner === 'malformed' ? '{not-json' : JSON.stringify(testCase.lockOwner), 'utf-8');
1010
+ }
1011
+ let observerCalls = 0;
1012
+ const observer = () => {
1013
+ const index = observerCalls++;
1014
+ return testCase.observations[Math.min(index, Math.max(testCase.observations.length - 1, 0))] ?? { kind: 'error' };
1015
+ };
1016
+ await withPointerDependencies({
1017
+ runtimePlatform: testCase.runtimePlatform,
1018
+ probePid: () => testCase.probePid,
1019
+ observeProcess: observer,
1020
+ }, async () => {
1021
+ assert.equal((await readSessionPointer(context)).status, testCase.expectedPointerStatus, testCase.name);
1022
+ });
1023
+ let lockObserverCalls = 0;
1024
+ const lockObserver = () => {
1025
+ const index = lockObserverCalls++;
1026
+ return testCase.observations[Math.min(index, Math.max(testCase.observations.length - 1, 0))] ?? { kind: 'error' };
1027
+ };
1028
+ await withPointerDependencies({
1029
+ runtimePlatform: testCase.runtimePlatform,
1030
+ probePid: () => testCase.probePid,
1031
+ observeProcess: lockObserver,
1032
+ }, async () => {
1033
+ assert.equal((await inspectSessionPointerLock(cwd)).status, testCase.expectedLockStatus, testCase.name);
1034
+ });
1035
+ }
1036
+ finally {
1037
+ await rm(cwd, { recursive: true, force: true });
1038
+ }
1039
+ }
1040
+ });
1041
+ it('rejects every schema version other than v2 as identity-indeterminate', async () => {
1042
+ const schemaVersions = [0, 1, -1, null, '2', 'bad', 3];
1043
+ for (const schemaVersion of schemaVersions) {
1044
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-identity-schema-rejection-'));
1045
+ try {
1046
+ const context = resolveSessionPointerContext(cwd);
1047
+ await mkdir(context.baseStateDir, { recursive: true });
1048
+ await writeFile(context.sessionPath, JSON.stringify({
1049
+ session_id: 'sess-schema-rejection',
1050
+ started_at: '2026-07-14T00:00:00.000Z',
1051
+ cwd,
1052
+ pid: 4242,
1053
+ platform: 'linux',
1054
+ pid_start_ticks: 1,
1055
+ identity_schema_version: schemaVersion,
1056
+ }), 'utf-8');
1057
+ await mkdir(context.lockPath, { recursive: true });
1058
+ await writeFile(join(context.lockPath, 'owner.json'), JSON.stringify(validLockOwner({
1059
+ pid: 4242,
1060
+ platform: 'darwin',
1061
+ pid_start_ticks: undefined,
1062
+ })), 'utf-8');
1063
+ await withPointerDependencies({
1064
+ runtimePlatform: 'linux',
1065
+ probePid: () => 'alive',
1066
+ observeProcess: () => matchingObservation('linux'),
1067
+ }, async () => {
1068
+ assert.equal((await readSessionPointer(context)).status, 'identity-indeterminate', String(schemaVersion));
1069
+ });
1070
+ await withPointerDependencies({
1071
+ runtimePlatform: 'linux',
1072
+ probePid: () => 'alive',
1073
+ observeProcess: () => matchingObservation('linux'),
1074
+ }, async () => {
1075
+ assert.equal((await inspectSessionPointerLock(cwd)).status, 'identity-indeterminate', String(schemaVersion));
1076
+ });
1077
+ }
1078
+ finally {
1079
+ await rm(cwd, { recursive: true, force: true });
1080
+ }
1081
+ }
1082
+ });
672
1083
  it('maps only ESRCH to a definitely dead default PID probe', () => {
673
1084
  const alive = __createDefaultPidProbeForTests(() => { });
674
1085
  const esrch = __createDefaultPidProbeForTests(() => { throw codedError('ESRCH'); });
@@ -686,11 +1097,11 @@ describe('session pointer transaction', () => {
686
1097
  it('never reaps dead, reused, indeterminate, missing, or malformed lock evidence', async () => {
687
1098
  const cases = [
688
1099
  { name: 'dead', owner: validLockOwner(), probePid: 'dead', expected: 'dead' },
689
- { name: 'reused', owner: validLockOwner(), probePid: 'alive', identity: { status: 'reused', startTicks: 2 }, expected: 'reused' },
1100
+ { name: 'reused', owner: validLockOwner(), probePid: 'alive', observation: { kind: 'identity', identity: { platform: 'linux', birth: '2' } }, expected: 'reused' },
690
1101
  { name: 'indeterminate-probe', owner: validLockOwner(), probePid: 'indeterminate', expected: 'identity-indeterminate' },
691
- { name: 'indeterminate-identity', owner: validLockOwner(), probePid: 'alive', identity: { status: 'indeterminate' }, expected: 'identity-indeterminate' },
692
- { name: 'unsubstantiated-reuse', owner: validLockOwner(), probePid: 'alive', identity: { status: 'reused', startTicks: 1 }, expected: 'identity-indeterminate' },
693
- { name: 'missing-required-hash', owner: validLockOwner({ pid_cmdline_hash: 'a'.repeat(64) }), probePid: 'alive', identity: matchingProcessIdentity(), expected: 'identity-indeterminate' },
1102
+ { name: 'indeterminate-identity', owner: validLockOwner(), probePid: 'alive', observation: { kind: 'error' }, expected: 'identity-indeterminate' },
1103
+ { name: 'foreign-identity', owner: validLockOwner(), probePid: 'alive', observation: { kind: 'identity', identity: { platform: 'darwin', birth: '1' } }, expected: 'identity-indeterminate' },
1104
+ { name: 'missing-required-hash', owner: validLockOwner({ pid_cmdline_hash: 'a'.repeat(64) }), probePid: 'alive', observation: matchingObservation(), expected: 'identity-indeterminate' },
694
1105
  { name: 'missing', probePid: 'alive', expected: 'missing' },
695
1106
  { name: 'malformed', owner: { version: 1 }, probePid: 'alive', expected: 'malformed' },
696
1107
  ];
@@ -706,7 +1117,7 @@ describe('session pointer transaction', () => {
706
1117
  await withPointerDependencies({
707
1118
  token: () => TEST_TOKEN,
708
1119
  probePid: () => testCase.probePid,
709
- readProcessIdentity: () => testCase.identity ?? matchingProcessIdentity(),
1120
+ observeProcess: () => testCase.observation ?? matchingObservation(),
710
1121
  }, async () => {
711
1122
  await assert.rejects(writeSessionStart(cwd, 'sess-lock', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
712
1123
  && error.code === 'session_pointer_lock_recovery_required'
@@ -745,595 +1156,2603 @@ describe('session pointer transaction', () => {
745
1156
  await rm(cwd, { recursive: true, force: true });
746
1157
  }
747
1158
  });
748
- it('does not recover a dead canonical owner because owner.json is not an exact acquisition claim', async () => {
749
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-dead-canonical-'));
1159
+ it('atomically quarantines dead canonical owner evidence and stays idempotent', async () => {
1160
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-preserved-'));
1161
+ try {
1162
+ await assertDurableDirectoryRecovery(cwd);
1163
+ }
1164
+ finally {
1165
+ await rm(cwd, { recursive: true, force: true });
1166
+ }
1167
+ });
1168
+ it('launches only after explicit recovery of a dead canonical owner lock', async () => {
1169
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recover-launch-'));
750
1170
  try {
751
1171
  const context = resolveSessionPointerContext(cwd);
752
1172
  await writeLockOwner(cwd, validLockOwner());
753
1173
  await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead' }, async () => {
1174
+ await assert.rejects(writeSessionStart(cwd, 'sess-blocked', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
1175
+ && error.code === 'session_pointer_lock_recovery_required'
1176
+ && error.lockOwnerStatus === 'dead');
1177
+ assert.equal(existsSync(context.lockPath), true);
1178
+ const recovered = await recoverSessionPointerLock(cwd);
1179
+ assert.equal(recovered.recovered, true);
1180
+ assert.equal(recovered.action, 'quarantined');
1181
+ await writeSessionStart(cwd, 'sess-after-recovery', { platform: 'win32' });
1182
+ assert.equal((await readSessionState(cwd))?.session_id, 'sess-after-recovery');
1183
+ assert.equal(existsSync(context.lockPath), false);
1184
+ });
1185
+ }
1186
+ finally {
1187
+ await rm(cwd, { recursive: true, force: true });
1188
+ }
1189
+ });
1190
+ it('does not claim a successor lock when the inspected dead canonical owner is displaced before the exact rename', async () => {
1191
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-canonical-race-'));
1192
+ try {
1193
+ const context = resolveSessionPointerContext(cwd);
1194
+ const ownerPath = join(context.lockPath, 'owner.json');
1195
+ const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
1196
+ const displacedPath = `${context.lockPath}.displaced`;
1197
+ const staleOwner = JSON.stringify(validLockOwner());
1198
+ const successorOwner = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }));
1199
+ await writeLockOwner(cwd, validLockOwner());
1200
+ let displaced = false;
1201
+ await withPointerDependencies({
1202
+ token: () => SUCCESSOR_TOKEN,
1203
+ probePid: () => 'dead',
1204
+ atomicRenameNoReplace: async (from, to) => {
1205
+ if (from === context.lockPath && to === `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`) {
1206
+ displaced = true;
1207
+ await rename(from, displacedPath);
1208
+ await mkdir(context.lockPath);
1209
+ await writeFile(successorTemp, successorOwner, 'utf-8');
1210
+ return 'not-moved';
1211
+ }
1212
+ if (existsSync(to))
1213
+ return 'not-moved';
1214
+ await rename(from, to);
1215
+ return 'moved';
1216
+ },
1217
+ }, async () => {
754
1218
  const recovered = await recoverSessionPointerLock(cwd);
755
- assert.equal(recovered.status, 'dead');
756
- assert.equal(recovered.evidenceSource, 'owner.json');
757
- assert.equal(recovered.safeToRecover, false);
758
1219
  assert.equal(recovered.recovered, false);
759
1220
  assert.equal(recovered.action, 'none');
760
- assert.equal(existsSync(context.lockPath), true);
1221
+ assert.match(recovered.reason, /Atomic recovery move|recovery checkpoint/i);
761
1222
  });
1223
+ assert.equal(displaced, true);
1224
+ assert.equal(await readFile(join(displacedPath, 'owner.json'), 'utf-8'), staleOwner);
1225
+ assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
1226
+ assert.equal(await readFile(successorTemp, 'utf-8'), successorOwner);
1227
+ assert.equal(existsSync(ownerPath), false);
762
1228
  }
763
1229
  finally {
764
1230
  await rm(cwd, { recursive: true, force: true });
765
1231
  }
766
1232
  });
767
- it('distinguishes a paused live pre-rename owner from the same owner after SIGKILL', async (t) => {
768
- if (process.platform !== 'linux') {
769
- t.skip('Linux process start identity is required for deterministic PID reuse protection.');
770
- return;
771
- }
772
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-sigkill-'));
773
- const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });
1233
+ it('rolls back a claim that lands on a displaced successor canonical owner', async () => {
1234
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-canonical-claim-race-'));
774
1235
  try {
775
- assert.ok(child.pid);
776
- await once(child, 'spawn');
777
1236
  const context = resolveSessionPointerContext(cwd);
778
- await mkdir(context.lockPath, { recursive: true });
779
- await writeFile(join(context.lockPath, `owner.${TEST_TOKEN}.tmp`), JSON.stringify(validLockOwner({
780
- pid: child.pid,
781
- pid_start_ticks: await linuxProcessStartTicks(child.pid),
782
- })), 'utf-8');
783
- const paused = await recoverSessionPointerLock(cwd);
784
- assert.equal(paused.status, 'live');
785
- assert.equal(paused.recovered, false);
786
- assert.equal(paused.action, 'none');
787
- assert.equal(existsSync(context.lockPath), true);
788
- child.kill('SIGKILL');
789
- const [exitCode, signal] = await once(child, 'exit');
790
- assert.equal(exitCode, null);
791
- assert.equal(signal, 'SIGKILL');
792
- const orphaned = await recoverSessionPointerLock(cwd);
793
- assert.equal(orphaned.status, 'dead');
794
- assert.equal(orphaned.recovered, true);
795
- assert.equal(orphaned.action, 'quarantined');
796
- assert.equal(existsSync(context.lockPath), false);
1237
+ const ownerPath = join(context.lockPath, 'owner.json');
1238
+ const parkedPath = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
1239
+ const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
1240
+ const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1241
+ const staleOwner = JSON.stringify(validLockOwner());
1242
+ const successorPid = process.pid + 100_000;
1243
+ const successorOwner = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN, pid: successorPid }));
1244
+ await writeLockOwner(cwd, validLockOwner());
1245
+ let moved = false;
1246
+ let rollbackAttempted = false;
1247
+ await withPointerDependencies({
1248
+ token: () => SUCCESSOR_TOKEN,
1249
+ probePid: () => 'dead',
1250
+ atomicRenameNoReplace: async (from, to) => {
1251
+ if (from === context.lockPath && to === parkedPath) {
1252
+ await rename(from, to);
1253
+ await mkdir(context.lockPath);
1254
+ await writeFile(ownerPath, successorOwner, 'utf-8');
1255
+ moved = true;
1256
+ return 'moved';
1257
+ }
1258
+ if (from === parkedPath && to === context.lockPath) {
1259
+ rollbackAttempted = true;
1260
+ return 'not-moved';
1261
+ }
1262
+ if (existsSync(to))
1263
+ return 'not-moved';
1264
+ await rename(from, to);
1265
+ return 'moved';
1266
+ },
1267
+ }, async () => {
1268
+ const recovered = await recoverSessionPointerLock(cwd);
1269
+ assert.equal(recovered.recovered, false);
1270
+ assert.equal(recovered.action, 'none');
1271
+ assert.match(recovered.reason, /captured object|token-bound|source pathname/i);
1272
+ });
1273
+ assert.equal(moved, true);
1274
+ assert.equal(rollbackAttempted, true);
1275
+ assert.equal(await readFile(ownerPath, 'utf-8'), successorOwner);
1276
+ assert.equal(await readFile(join(parkedPath, 'owner.json'), 'utf-8'), staleOwner);
1277
+ assert.equal(await readFile(quarantinePath, 'utf-8'), staleOwner);
1278
+ assert.equal(existsSync(checkpointPath), true);
797
1279
  }
798
1280
  finally {
799
- if (child.exitCode === null && child.signalCode === null)
800
- child.kill('SIGKILL');
801
1281
  await rm(cwd, { recursive: true, force: true });
802
1282
  }
803
1283
  });
804
- it('fails closed for live pre-rename, PID reuse, malformed, and ambiguous lock evidence', async () => {
805
- const cases = [
806
- { name: 'live-temp', files: [[`owner.${TEST_TOKEN}.tmp`, JSON.stringify(validLockOwner())]], probePid: 'alive', identity: matchingProcessIdentity() },
807
- { name: 'reused', files: [['owner.json', JSON.stringify(validLockOwner())]], probePid: 'alive', identity: { status: 'matching', startTicks: 2 } },
808
- { name: 'malformed', files: [['owner.json', '{']], probePid: 'dead' },
809
- { name: 'ambiguous', files: [['owner.json', JSON.stringify(validLockOwner())], [`owner.${SUCCESSOR_TOKEN}.tmp`, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }))]], probePid: 'dead' },
810
- ];
811
- for (const testCase of cases) {
812
- const cwd = await mkdtemp(join(tmpdir(), `omx-session-lock-recovery-${testCase.name}-`));
813
- try {
814
- const context = resolveSessionPointerContext(cwd);
815
- await mkdir(context.lockPath, { recursive: true });
816
- await Promise.all(testCase.files.map(async ([name, contents]) => await writeFile(join(context.lockPath, name), contents, 'utf-8')));
817
- await withPointerDependencies({
818
- token: () => SUCCESSOR_TOKEN,
819
- probePid: () => testCase.probePid,
820
- readProcessIdentity: () => testCase.identity ?? matchingProcessIdentity(),
821
- }, async () => {
822
- const before = await readdir(context.lockPath);
823
- const recovered = await recoverSessionPointerLock(cwd);
824
- assert.equal(recovered.recovered, false);
825
- assert.equal(recovered.action, 'none');
826
- assert.equal(recovered.safeToRecover, false);
827
- assert.deepEqual(await readdir(context.lockPath), before);
828
- });
829
- }
830
- finally {
831
- await rm(cwd, { recursive: true, force: true });
832
- }
833
- }
834
- });
835
- it('claims stale evidence before quarantine so a successor cannot be mistaken for it', async () => {
836
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-race-'));
1284
+ it('live canonical successor survives a refused recovery claim and stays recognizable, releasable, and acquirable', async () => {
1285
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-live-successor-'));
837
1286
  try {
838
1287
  const context = resolveSessionPointerContext(cwd);
839
- await mkdir(context.lockPath, { recursive: true });
840
- await writeFile(join(context.lockPath, `owner.${TEST_TOKEN}.tmp`), JSON.stringify(validLockOwner()), 'utf-8');
841
- const renames = [];
842
- let successorBlocked = false;
1288
+ const ownerPath = join(context.lockPath, 'owner.json');
1289
+ const parkedPath = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
1290
+ const successorPid = process.pid + 100_000;
1291
+ const successorOwner = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN, pid: successorPid }));
1292
+ await writeLockOwner(cwd, validLockOwner());
1293
+ let rollbackAttempted = false;
843
1294
  await withPointerDependencies({
844
1295
  token: () => SUCCESSOR_TOKEN,
845
- probePid: () => 'dead',
846
- fs: {
847
- rename: async (from, to) => {
848
- renames.push([from, to]);
1296
+ probePid: (pid) => pid === successorPid ? 'alive' : 'dead',
1297
+ observeProcess: () => matchingObservation(),
1298
+ atomicRenameNoReplace: async (from, to) => {
1299
+ if (from === context.lockPath && to === parkedPath) {
849
1300
  await rename(from, to);
850
- if (from.endsWith(`owner.${TEST_TOKEN}.tmp`)) {
851
- await assert.rejects(mkdir(context.lockPath), { code: 'EEXIST' });
852
- successorBlocked = true;
853
- }
854
- },
1301
+ await mkdir(context.lockPath);
1302
+ await writeFile(ownerPath, successorOwner, 'utf-8');
1303
+ return 'moved';
1304
+ }
1305
+ if (from === parkedPath && to === context.lockPath) {
1306
+ rollbackAttempted = true;
1307
+ return 'not-moved';
1308
+ }
1309
+ if (existsSync(to))
1310
+ return 'not-moved';
1311
+ await rename(from, to);
1312
+ return 'moved';
855
1313
  },
856
1314
  }, async () => {
857
- const recovered = await recoverSessionPointerLock(cwd);
858
- assert.equal(recovered.recovered, true);
1315
+ const refused = await recoverSessionPointerLock(cwd);
1316
+ assert.equal(refused.recovered, false);
1317
+ assert.equal(refused.action, 'none');
1318
+ const inspected = await inspectSessionPointerLock(cwd);
1319
+ assert.equal(inspected.status, 'live');
1320
+ assert.equal(inspected.safeToRecover, false);
1321
+ assert.equal(await readFile(ownerPath, 'utf-8'), successorOwner);
1322
+ assert.deepEqual(await __releasePointerLockForTests(cwd, SUCCESSOR_TOKEN), []);
1323
+ assert.equal(existsSync(context.lockPath), false);
1324
+ const started = await writeSessionStart(cwd, 'sess-after-survival', { platform: 'win32' });
1325
+ assert.equal(started.session_id, 'sess-after-survival');
1326
+ assert.equal(existsSync(context.lockPath), false);
859
1327
  });
860
- assert.equal(successorBlocked, true);
861
- assert.match(renames[0][0], /owner\.transaction_token_123456\.tmp$/);
862
- assert.match(renames[0][1], new RegExp(`owner\\.${TEST_TOKEN}\\.${SUCCESSOR_TOKEN}\\.recovery$`));
863
- assert.match(renames[1][0], new RegExp(`owner\\.${TEST_TOKEN}\\.${SUCCESSOR_TOKEN}\\.recovery$`));
864
- assert.equal(renames[1][1].includes('.quarantine.'), true);
1328
+ assert.equal(rollbackAttempted, true);
1329
+ assert.equal(existsSync(parkedPath), true);
865
1330
  }
866
1331
  finally {
867
1332
  await rm(cwd, { recursive: true, force: true });
868
1333
  }
869
1334
  });
870
- it('does not claim a successor lock when the inspected orphan is displaced before the exact temp rename', async () => {
871
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-successor-race-'));
1335
+ it('fails closed without mutating a foreign target when the lock directory is replaced by a symlink before the claim rename', async () => {
1336
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-lock-symlink-'));
1337
+ const foreign = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-foreign-'));
872
1338
  try {
873
1339
  const context = resolveSessionPointerContext(cwd);
874
- const staleTemp = join(context.lockPath, `owner.${TEST_TOKEN}.tmp`);
875
- const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
876
- const displacedPath = `${context.lockPath}.displaced`;
877
- await mkdir(context.lockPath, { recursive: true });
878
- await writeFile(staleTemp, JSON.stringify(validLockOwner()), 'utf-8');
879
- let displaced = false;
1340
+ const ownerPath = join(context.lockPath, 'owner.json');
1341
+ const foreignOwner = join(foreign, 'owner.json');
1342
+ const marker = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }));
1343
+ let replaced = false;
1344
+ await writeLockOwner(cwd, validLockOwner());
1345
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => {
1346
+ if (!replaced && from === ownerPath) {
1347
+ replaced = true;
1348
+ await rm(context.lockPath, { recursive: true });
1349
+ await writeFile(foreignOwner, marker);
1350
+ await symlink(foreign, context.lockPath);
1351
+ }
1352
+ await link(from, to);
1353
+ } } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
1354
+ assert.equal(await readFile(foreignOwner, 'utf-8'), marker);
1355
+ assert.equal((await readdir(foreign)).some((entry) => entry.includes('.quarantine.')), false);
1356
+ }
1357
+ finally {
1358
+ await rm(cwd, { recursive: true, force: true });
1359
+ await rm(foreign, { recursive: true, force: true });
1360
+ }
1361
+ });
1362
+ it('does not follow an evidence symlink swapped in before the claim rename', async () => {
1363
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-evidence-symlink-'));
1364
+ try {
1365
+ const context = resolveSessionPointerContext(cwd);
1366
+ const ownerPath = join(context.lockPath, 'owner.json');
1367
+ const foreign = join(cwd, 'foreign-owner');
1368
+ const marker = 'foreign evidence marker';
1369
+ let replaced = false;
1370
+ await writeLockOwner(cwd, validLockOwner());
1371
+ await writeFile(foreign, marker);
1372
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => {
1373
+ if (!replaced && from === ownerPath) {
1374
+ replaced = true;
1375
+ await rm(ownerPath);
1376
+ await symlink(foreign, ownerPath);
1377
+ }
1378
+ await link(from, to);
1379
+ } } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
1380
+ assert.equal(await readFile(foreign, 'utf-8'), marker);
1381
+ assert.equal(await readlink(ownerPath), foreign);
1382
+ }
1383
+ finally {
1384
+ await rm(cwd, { recursive: true, force: true });
1385
+ }
1386
+ });
1387
+ it('preserves a non-file evidence replacement as exact recovery residue without clobbering', async () => {
1388
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-evidence-directory-'));
1389
+ try {
1390
+ const context = resolveSessionPointerContext(cwd);
1391
+ const ownerPath = join(context.lockPath, 'owner.json');
1392
+ const marker = join(ownerPath, 'marker');
1393
+ let replaced = false;
1394
+ await writeLockOwner(cwd, validLockOwner());
1395
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => {
1396
+ if (!replaced && from === ownerPath) {
1397
+ replaced = true;
1398
+ await rm(ownerPath);
1399
+ await mkdir(ownerPath);
1400
+ await writeFile(marker, 'directory marker');
1401
+ }
1402
+ await link(from, to);
1403
+ } } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
1404
+ assert.equal(await readFile(marker, 'utf-8'), 'directory marker');
1405
+ }
1406
+ finally {
1407
+ await rm(cwd, { recursive: true, force: true });
1408
+ }
1409
+ });
1410
+ it('retries a transient token-bound park collision without allocating a second parked name', async () => {
1411
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-park-retry-'));
1412
+ try {
1413
+ const context = resolveSessionPointerContext(cwd);
1414
+ const parkedPath = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
1415
+ const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
1416
+ const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1417
+ const foreignMarker = 'foreign transient park destination';
1418
+ const parkAttempts = [];
1419
+ let tokenCalls = 0;
1420
+ let collision = true;
1421
+ await writeLockOwner(cwd, validLockOwner());
880
1422
  await withPointerDependencies({
881
- token: () => SUCCESSOR_TOKEN,
1423
+ token: () => {
1424
+ tokenCalls += 1;
1425
+ return SUCCESSOR_TOKEN;
1426
+ },
882
1427
  probePid: () => 'dead',
883
- fs: {
884
- rename: async (from, to) => {
885
- if (!displaced && from === staleTemp) {
886
- displaced = true;
887
- await rename(context.lockPath, displacedPath);
888
- await mkdir(context.lockPath);
889
- await writeFile(successorTemp, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })), 'utf-8');
1428
+ atomicRenameNoReplace: async (from, to) => {
1429
+ if (from === context.lockPath && to === parkedPath) {
1430
+ parkAttempts.push(to);
1431
+ if (collision) {
1432
+ collision = false;
1433
+ await mkdir(to);
1434
+ await writeFile(join(to, 'foreign'), foreignMarker, 'utf-8');
1435
+ return 'not-moved';
890
1436
  }
891
- await rename(from, to);
892
- },
1437
+ }
1438
+ if (existsSync(to))
1439
+ return 'not-moved';
1440
+ await rename(from, to);
1441
+ return 'moved';
893
1442
  },
894
1443
  }, async () => {
1444
+ const refused = await recoverSessionPointerLock(cwd);
1445
+ assert.equal(refused.recovered, false);
1446
+ assert.equal(refused.action, 'none');
1447
+ assert.equal(existsSync(checkpointPath), true);
1448
+ assert.equal(existsSync(context.lockPath), true);
1449
+ assert.equal(await readFile(join(parkedPath, 'foreign'), 'utf-8'), foreignMarker);
1450
+ await rm(parkedPath, { recursive: true, force: true });
895
1451
  const recovered = await recoverSessionPointerLock(cwd);
896
- assert.equal(recovered.recovered, false);
897
- assert.equal(recovered.action, 'none');
898
- assert.match(recovered.reason, /changed before recovery claim/i);
1452
+ assert.equal(recovered.recovered, true, recovered.reason);
1453
+ assert.equal(recovered.action, 'quarantined');
899
1454
  });
900
- assert.equal(displaced, true);
901
- assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
902
- assert.equal(await readFile(successorTemp, 'utf-8'), JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })));
1455
+ assert.equal(tokenCalls, 1);
1456
+ assert.deepEqual(parkAttempts, [parkedPath, parkedPath]);
1457
+ assert.equal(existsSync(context.lockPath), false);
1458
+ assert.equal(existsSync(parkedPath), true);
1459
+ assert.equal(existsSync(quarantinePath), true);
1460
+ assert.equal(existsSync(checkpointPath), false);
1461
+ assert.equal(existsSync(checkpointPath.replace(/\.json$/, '.completed')), true);
903
1462
  }
904
1463
  finally {
905
1464
  await rm(cwd, { recursive: true, force: true });
906
1465
  }
907
1466
  });
908
- it('does not quarantine a successor live lock displaced into the canonical path after claim revalidation', async () => {
909
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-post-claim-race-'));
1467
+ it('rolls back a claim when checkpoint creation fails', async () => {
1468
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-write-fail-'));
910
1469
  try {
911
1470
  const context = resolveSessionPointerContext(cwd);
912
- const staleTemp = join(context.lockPath, `owner.${TEST_TOKEN}.tmp`);
913
- const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
914
- const displacedPath = `${context.lockPath}.claimed-displaced`;
915
- await mkdir(context.lockPath, { recursive: true });
916
- await writeFile(staleTemp, JSON.stringify(validLockOwner()), 'utf-8');
917
- let displaced = false;
1471
+ await writeLockOwner(cwd, validLockOwner());
1472
+ let fail = true;
1473
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { writeFile: async (path, data, options) => { if (fail && path.includes('.recovery.')) {
1474
+ fail = false;
1475
+ throw codedError('EBUSY');
1476
+ } await writeFile(path, data, options); } } }, async () => {
1477
+ assert.equal((await recoverSessionPointerLock(cwd)).recovered, false);
1478
+ assert.deepEqual(await readdir(context.lockPath), ['owner.json']);
1479
+ assert.equal((await recoverSessionPointerLock(cwd)).recovered, true);
1480
+ });
1481
+ }
1482
+ finally {
1483
+ await rm(cwd, { recursive: true, force: true });
1484
+ }
1485
+ });
1486
+ it('rolls back a double-EBUSY evidence park failure so a normal retry succeeds', async () => {
1487
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-'));
1488
+ try {
1489
+ const context = resolveSessionPointerContext(cwd);
1490
+ const ownerPath = join(context.lockPath, 'owner.json');
1491
+ const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
1492
+ const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1493
+ const staleOwner = JSON.stringify(validLockOwner());
1494
+ let failures = 2;
1495
+ let tokenCalls = 0;
1496
+ let linkAttempts = 0;
1497
+ await writeLockOwner(cwd, validLockOwner());
918
1498
  await withPointerDependencies({
919
- token: () => SUCCESSOR_TOKEN,
1499
+ token: () => {
1500
+ tokenCalls += 1;
1501
+ return SUCCESSOR_TOKEN;
1502
+ },
920
1503
  probePid: () => 'dead',
921
1504
  fs: {
922
- rmdir: async (path) => {
923
- if (!displaced && path === context.lockPath) {
924
- displaced = true;
925
- await rename(context.lockPath, displacedPath);
926
- await mkdir(context.lockPath);
927
- await writeFile(successorTemp, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })), 'utf-8');
1505
+ link: async (from, to) => {
1506
+ if (to === quarantinePath && failures > 0) {
1507
+ failures -= 1;
1508
+ linkAttempts += 1;
1509
+ throw codedError('EBUSY');
928
1510
  }
929
- await rmdir(path);
1511
+ await link(from, to);
930
1512
  },
931
1513
  },
932
1514
  }, async () => {
1515
+ const first = await recoverSessionPointerLock(cwd);
1516
+ assert.equal(first.recovered, false);
1517
+ assert.equal(first.action, 'none');
1518
+ assert.equal(existsSync(checkpointPath), true);
1519
+ assert.equal(await readFile(ownerPath, 'utf-8'), staleOwner);
1520
+ assert.equal(existsSync(quarantinePath), false);
1521
+ const second = await recoverSessionPointerLock(cwd);
1522
+ assert.equal(second.recovered, false);
1523
+ assert.equal(second.action, 'none');
1524
+ assert.equal(existsSync(checkpointPath), true);
1525
+ assert.equal(await readFile(ownerPath, 'utf-8'), staleOwner);
1526
+ assert.equal(existsSync(quarantinePath), false);
933
1527
  const recovered = await recoverSessionPointerLock(cwd);
934
- assert.equal(recovered.recovered, false);
935
- assert.equal(recovered.action, 'none');
936
- assert.match(recovered.reason, /canonical lock directory was not empty/i);
937
- assert.ok(recovered.quarantinePath);
1528
+ assert.equal(recovered.recovered, true, recovered.reason);
1529
+ assert.equal(recovered.action, 'quarantined');
938
1530
  });
939
- assert.equal(displaced, true);
940
- assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
941
- assert.equal(await readFile(successorTemp, 'utf-8'), JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })));
1531
+ assert.equal(tokenCalls, 1);
1532
+ assert.equal(linkAttempts, 2);
1533
+ assert.equal(existsSync(context.lockPath), false);
1534
+ assert.equal(await readFile(quarantinePath, 'utf-8'), staleOwner);
1535
+ assert.equal(existsSync(checkpointPath), false);
1536
+ assert.equal(existsSync(checkpointPath.replace(/\.json$/, '.completed')), true);
942
1537
  }
943
1538
  finally {
944
1539
  await rm(cwd, { recursive: true, force: true });
945
1540
  }
946
1541
  });
947
- it('retries a positively matching live owner with the bounded 25/50/100 schedule before timing out', async () => {
948
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-timeout-'));
1542
+ it('resumes an evidence-pending checkpoint without minting another recovery token', async () => {
1543
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-resume-'));
949
1544
  try {
1545
+ const context = resolveSessionPointerContext(cwd);
1546
+ const ownerPath = join(context.lockPath, 'owner.json');
1547
+ const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
1548
+ const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
1549
+ const owner = JSON.stringify(validLockOwner());
950
1550
  await writeLockOwner(cwd, validLockOwner());
951
- const delays = [];
952
- let now = 0;
953
- await withPointerDependencies({
954
- nowMs: () => now,
955
- sleep: async (ms) => { delays.push(ms); now += ms; },
956
- token: () => TEST_TOKEN,
957
- probePid: () => 'alive',
958
- readProcessIdentity: () => matchingProcessIdentity(),
959
- }, async () => {
960
- await assert.rejects(writeSessionStart(cwd, 'sess-timeout', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
961
- && error.code === 'session_pointer_lock_timeout'
962
- && error.lockOwnerStatus === 'live');
1551
+ await link(ownerPath, claimPath);
1552
+ await rename(ownerPath, parkPath);
1553
+ const parked = await lstat(parkPath);
1554
+ const lock = await lstat(context.lockPath);
1555
+ await writeFile(`${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
1556
+ await withPointerDependencies({ token: () => { throw new Error('resume must reuse checkpoint token'); }, probePid: () => 'dead' }, async () => {
1557
+ const resumed = await recoverSessionPointerLock(cwd);
1558
+ assert.equal(resumed.recovered, true);
963
1559
  });
964
- assert.deepEqual(delays.slice(0, 3), [25, 50, 100]);
965
- assert.equal(delays.at(-1), 25);
966
- assert.equal(existsSync(resolveSessionPointerContext(cwd).lockPath), true);
1560
+ assert.equal(await readFile(`${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`, 'utf-8'), owner);
1561
+ assert.equal(existsSync(`${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`), false);
967
1562
  }
968
1563
  finally {
969
1564
  await rm(cwd, { recursive: true, force: true });
970
1565
  }
971
1566
  });
972
- it('rolls back only its unpublished owner artifacts and reports rollback ambiguity', async () => {
973
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-publish-'));
1567
+ it('resumes the actual distinct-token evidence park path after an interruption', async () => {
1568
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-distinct-park-token-'));
974
1569
  try {
975
1570
  const context = resolveSessionPointerContext(cwd);
976
- await withPointerDependencies({
977
- token: () => TEST_TOKEN,
978
- fs: {
979
- writeFile: async (path, data, options) => {
980
- if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
981
- throw new Error('owner write failure');
982
- await writeFile(path, data, options);
983
- },
984
- },
985
- }, async () => {
986
- await assert.rejects(writeSessionStart(cwd, 'sess-owner-publish', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
987
- && error.code === 'session_pointer_io_failure'
988
- && error.operation === 'lock-owner-publish');
989
- });
1571
+ const ownerPath = join(context.lockPath, 'owner.json');
1572
+ const claimToken = 'claim_token_123456789';
1573
+ const parkToken = 'park_token_123456789';
1574
+ const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${claimToken}.recovery`);
1575
+ const parkPath = `${context.lockPath}.parked-${parkToken}`;
1576
+ await writeLockOwner(cwd, validLockOwner());
1577
+ await link(ownerPath, claimPath);
1578
+ await rename(ownerPath, parkPath);
1579
+ const parked = await lstat(parkPath);
1580
+ const lock = await lstat(context.lockPath);
1581
+ await writeFile(`${context.lockPath}.recovery.${TEST_TOKEN}.${claimToken}.json`, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${claimToken}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
1582
+ await withPointerDependencies({ token: () => { throw new Error('resume must not mint a replacement token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
1583
+ assert.equal(await readFile(`${context.lockPath}.quarantine.${TEST_TOKEN}.${claimToken}`, 'utf-8'), JSON.stringify(validLockOwner()));
1584
+ }
1585
+ finally {
1586
+ await rm(cwd, { recursive: true, force: true });
1587
+ }
1588
+ });
1589
+ it('resumes a checkpoint written before evidence parking', async () => {
1590
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-source-checkpoint-'));
1591
+ try {
1592
+ const context = resolveSessionPointerContext(cwd);
1593
+ const ownerPath = join(context.lockPath, 'owner.json');
1594
+ const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
1595
+ const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
1596
+ const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1597
+ await writeLockOwner(cwd, validLockOwner());
1598
+ await link(ownerPath, claimPath);
1599
+ const owner = await lstat(ownerPath);
1600
+ const lock = await lstat(context.lockPath);
1601
+ await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: owner.dev, ino: owner.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
1602
+ await withPointerDependencies({ token: () => { throw new Error('resume must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
990
1603
  assert.equal(existsSync(context.lockPath), false);
991
- await withPointerDependencies({
992
- token: () => TEST_TOKEN,
993
- fs: {
994
- writeFile: async (path, data, options) => {
995
- if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
996
- throw new Error('owner write failure');
997
- await writeFile(path, data, options);
998
- },
999
- rmdir: async (path) => {
1000
- if (path === context.lockPath)
1001
- throw new Error('keep lock evidence');
1002
- await rm(path, { recursive: false });
1003
- },
1004
- },
1005
- }, async () => {
1006
- await assert.rejects(writeSessionStart(cwd, 'sess-owner-residue', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
1007
- && error.code === 'session_pointer_lock_recovery_required'
1008
- && error.primaryOperation === 'lock-owner-publish'
1009
- && error.secondaryFailures?.[0]?.phase === 'remove-unpublished-lock');
1010
- });
1011
- assert.equal(existsSync(context.lockPath), true);
1604
+ assert.equal(existsSync(checkpointPath), false);
1012
1605
  }
1013
1606
  finally {
1014
1607
  await rm(cwd, { recursive: true, force: true });
1015
1608
  }
1016
1609
  });
1017
- it('publishes the owner and pointer on Windows when regular-file fsync reports EPERM', async () => {
1018
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-windows-eperm-'));
1019
- let syncCalls = 0;
1020
- const regularFileSync = async (platform) => {
1021
- assert.equal(platform, 'win32');
1022
- syncCalls += 1;
1023
- throw codedError('EPERM');
1024
- };
1610
+ it('resumes after quarantine hard-link creation but before claim and park cleanup', async () => {
1611
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-dual-evidence-checkpoint-'));
1025
1612
  try {
1026
- const state = await writeSessionStart(cwd, 'sess-windows-eperm', {
1027
- platform: 'win32',
1028
- regularFileSync,
1029
- });
1030
1613
  const context = resolveSessionPointerContext(cwd);
1031
- assert.equal(state.session_id, 'sess-windows-eperm');
1032
- assert.equal(syncCalls, 2, 'owner publication and pointer publication must both attempt fsync');
1033
- assert.equal(existsSync(join(context.lockPath, 'owner.json')), false, 'lock owner is removed after commit');
1034
- assert.equal(JSON.parse(await readFile(context.sessionPath, 'utf8')).session_id, 'sess-windows-eperm');
1614
+ const ownerPath = join(context.lockPath, 'owner.json');
1615
+ const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
1616
+ const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
1617
+ const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
1618
+ const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1619
+ await writeLockOwner(cwd, validLockOwner());
1620
+ await link(ownerPath, claimPath);
1621
+ await rename(ownerPath, parkPath);
1622
+ await link(parkPath, quarantinePath);
1623
+ const parked = await lstat(parkPath);
1624
+ const lock = await lstat(context.lockPath);
1625
+ await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
1626
+ await withPointerDependencies({ token: () => { throw new Error('resume must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
1627
+ assert.equal(await readFile(quarantinePath, 'utf-8'), JSON.stringify(validLockOwner()));
1628
+ assert.equal(existsSync(checkpointPath), false);
1035
1629
  }
1036
1630
  finally {
1037
1631
  await rm(cwd, { recursive: true, force: true });
1038
1632
  }
1039
1633
  });
1040
- it('emits one post-release warning for a degraded session start and stays silent for synced or failed transactions', async () => {
1041
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-durability-warning-'));
1042
- const originalWrite = process.stderr.write;
1043
- const warnings = [];
1044
- process.stderr.write = ((value) => {
1045
- warnings.push({ value, lockExists: existsSync(resolveSessionPointerContext(cwd).lockPath) });
1046
- return true;
1047
- });
1048
- const regularFileSync = async () => { throw codedError('EPERM'); };
1634
+ it('reports absent recovery on a fresh workspace without a state directory', async () => {
1635
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-fresh-'));
1049
1636
  try {
1050
- await writeSessionStart(cwd, 'sess-degraded-start', { platform: 'win32', regularFileSync });
1051
- assert.deepEqual(warnings, [{
1052
- value: '[omx] warning: Windows EPERM regular-file fsync unsupported in session pointer start/reconcile; operation succeeded with degraded durability.\n',
1053
- lockExists: false,
1054
- }]);
1055
- warnings.length = 0;
1056
- await writeSessionStart(cwd, 'sess-degraded-start', { platform: 'win32' });
1057
- assert.deepEqual(warnings, []);
1058
- await withPointerDependencies({
1059
- fs: {
1060
- rename: async (from, to) => {
1061
- if (to === resolveSessionPointerContext(cwd).sessionPath)
1062
- throw new Error('rename failure');
1063
- await rename(from, to);
1064
- },
1065
- },
1066
- }, async () => {
1067
- await assert.rejects(writeSessionStart(cwd, 'sess-degraded-start', { platform: 'win32', regularFileSync }));
1637
+ const recovered = await recoverSessionPointerLock(cwd);
1638
+ assert.equal(recovered.recovered, false);
1639
+ assert.equal(recovered.status, 'absent');
1640
+ assert.match(recovered.reason, /No session pointer lock exists/);
1641
+ }
1642
+ finally {
1643
+ await rm(cwd, { recursive: true, force: true });
1644
+ }
1645
+ });
1646
+ it('fails closed when recovery checkpoint discovery cannot be enumerated', async () => {
1647
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-readdir-error-'));
1648
+ try {
1649
+ const context = resolveSessionPointerContext(cwd);
1650
+ await writeLockOwner(cwd, validLockOwner());
1651
+ await withPointerDependencies({ probePid: () => 'dead', fs: { readdir: async () => { throw codedError('EACCES'); } } }, async () => {
1652
+ const recovered = await recoverSessionPointerLock(cwd);
1653
+ assert.equal(recovered.recovered, false);
1654
+ assert.equal(recovered.status, 'io-error');
1655
+ assert.match(recovered.reason, /enumerate.*checkpoints/i);
1068
1656
  });
1069
- assert.deepEqual(warnings, []);
1657
+ assert.deepEqual(await readdir(context.lockPath), ['owner.json']);
1070
1658
  }
1071
1659
  finally {
1072
- process.stderr.write = originalWrite;
1073
1660
  await rm(cwd, { recursive: true, force: true });
1074
1661
  }
1075
1662
  });
1076
- it('types state-directory, pointer-read, owner-sync, owner-rename, and invalid-token failures before commit', async () => {
1077
- const failures = [
1078
- {
1079
- name: 'state-dir',
1080
- operation: 'state-dir-create',
1081
- dependencies: (context) => ({
1082
- fs: {
1083
- mkdir: async (path, options) => {
1084
- if (path === context.baseStateDir)
1085
- throw new Error('state directory failure');
1086
- await mkdir(path, options);
1087
- },
1088
- },
1089
- }),
1090
- },
1091
- {
1092
- name: 'pointer-read',
1093
- operation: 'pointer-read',
1094
- dependencies: (context) => ({
1095
- token: () => TEST_TOKEN,
1096
- fs: {
1097
- readFile: async (path, encoding) => {
1098
- if (path === context.sessionPath)
1099
- throw new Error('pointer read failure');
1100
- return await readFile(path, encoding);
1101
- },
1663
+ it('fails closed for malformed checkpoint JSON without minting a recovery token', async () => {
1664
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-malformed-checkpoint-'));
1665
+ try {
1666
+ const context = resolveSessionPointerContext(cwd);
1667
+ await writeLockOwner(cwd, validLockOwner());
1668
+ const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1669
+ await writeFile(checkpointPath, '{not json', 'utf-8');
1670
+ await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => {
1671
+ const recovered = await recoverSessionPointerLock(cwd);
1672
+ assert.equal(recovered.recovered, false);
1673
+ assert.equal(recovered.safeToRecover, false);
1674
+ });
1675
+ assert.equal(await readFile(checkpointPath, 'utf-8'), '{not json');
1676
+ assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf-8'), JSON.stringify(validLockOwner()));
1677
+ }
1678
+ finally {
1679
+ await rm(cwd, { recursive: true, force: true });
1680
+ }
1681
+ });
1682
+ for (const [label, checkpoint] of [
1683
+ ['wrong version', { version: 2, phase: 'evidence-pending', sourcePath: 'x', parkPath: 'x', identity: { dev: 0, ino: 0 } }],
1684
+ ['wrong phase', { version: 1, phase: 'claim-pending', sourcePath: 'x', parkPath: 'x', identity: { dev: 0, ino: 0 } }],
1685
+ ['out-of-root path', { version: 1, phase: 'evidence-pending', sourcePath: '/foreign/owner.json', parkPath: '/foreign/parked', identity: { dev: 0, ino: 0 } }],
1686
+ ])
1687
+ it(`fails closed for ${label} checkpoint`, async () => {
1688
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-invalid-checkpoint-'));
1689
+ try {
1690
+ const context = resolveSessionPointerContext(cwd);
1691
+ await writeLockOwner(cwd, validLockOwner());
1692
+ const path = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1693
+ await writeFile(path, JSON.stringify(checkpoint), 'utf-8');
1694
+ await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
1695
+ assert.equal(await readFile(path, 'utf-8'), JSON.stringify(checkpoint));
1696
+ assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf-8'), JSON.stringify(validLockOwner()));
1697
+ }
1698
+ finally {
1699
+ await rm(cwd, { recursive: true, force: true });
1700
+ }
1701
+ });
1702
+ it('refuses multiple checkpoints without mutating either', async () => {
1703
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-multiple-checkpoints-'));
1704
+ try {
1705
+ const context = resolveSessionPointerContext(cwd);
1706
+ await writeLockOwner(cwd, validLockOwner());
1707
+ for (const token of [SUCCESSOR_TOKEN, FOREIGN_TOKEN])
1708
+ await writeFile(`${context.lockPath}.recovery.${TEST_TOKEN}.${token}.json`, '{}', 'utf-8');
1709
+ await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => assert.match((await recoverSessionPointerLock(cwd)).reason, /Multiple recovery checkpoints/));
1710
+ assert.equal(existsSync(join(context.lockPath, 'owner.json')), true);
1711
+ }
1712
+ finally {
1713
+ await rm(cwd, { recursive: true, force: true });
1714
+ }
1715
+ });
1716
+ it('preserves a checkpoint when its claim is missing', async () => {
1717
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-missing-claim-'));
1718
+ try {
1719
+ const context = resolveSessionPointerContext(cwd);
1720
+ await mkdir(context.lockPath, { recursive: true });
1721
+ const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
1722
+ await writeFile(parkPath, 'stale', 'utf-8');
1723
+ const stat = await lstat(parkPath);
1724
+ const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1725
+ const lock = await lstat(context.lockPath);
1726
+ await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: join(context.lockPath, 'owner.json'), parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: stat.dev, ino: stat.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
1727
+ await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
1728
+ assert.equal(await readFile(checkpointPath, 'utf-8').then(Boolean), true);
1729
+ assert.equal(await readFile(parkPath, 'utf-8'), 'stale');
1730
+ }
1731
+ finally {
1732
+ await rm(cwd, { recursive: true, force: true });
1733
+ }
1734
+ });
1735
+ it('refuses an advanced checkpoint when a live successor replaces the bound lock directory', async () => {
1736
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-successor-swap-'));
1737
+ try {
1738
+ const context = resolveSessionPointerContext(cwd);
1739
+ const ownerPath = join(context.lockPath, 'owner.json');
1740
+ const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
1741
+ const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
1742
+ const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1743
+ const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
1744
+ await writeLockOwner(cwd, validLockOwner());
1745
+ await link(ownerPath, claimPath);
1746
+ await rename(ownerPath, parkPath);
1747
+ const parked = await lstat(parkPath);
1748
+ const lock = await lstat(context.lockPath);
1749
+ await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
1750
+ await rename(parkPath, quarantinePath);
1751
+ await rm(claimPath);
1752
+ await rename(context.lockPath, `${context.lockPath}.displaced`);
1753
+ const successorPid = process.pid + 100_000;
1754
+ const successor = JSON.stringify(validLockOwner({ token: FOREIGN_TOKEN, pid: successorPid }));
1755
+ await mkdir(context.lockPath);
1756
+ await writeFile(ownerPath, successor, 'utf-8');
1757
+ await withPointerDependencies({ token: () => { throw new Error('resume must not mint'); }, probePid: (pid) => pid === successorPid ? 'alive' : 'dead', observeProcess: () => matchingObservation() }, async () => {
1758
+ const refused = await recoverSessionPointerLock(cwd);
1759
+ assert.equal(refused.recovered, false);
1760
+ assert.match(refused.reason, /recovery state/i);
1761
+ assert.deepEqual(await readdir(context.lockPath), ['owner.json']);
1762
+ assert.equal(await readFile(ownerPath, 'utf-8'), successor);
1763
+ assert.deepEqual(await __releasePointerLockForTests(cwd, FOREIGN_TOKEN), []);
1764
+ assert.equal(existsSync(context.lockPath), false);
1765
+ });
1766
+ assert.equal(await readFile(quarantinePath, 'utf-8'), JSON.stringify(validLockOwner()));
1767
+ assert.equal(existsSync(checkpointPath), true);
1768
+ }
1769
+ finally {
1770
+ await rm(cwd, { recursive: true, force: true });
1771
+ }
1772
+ });
1773
+ it('does not remove a live successor swapped in before final checkpoint lock removal', async () => {
1774
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-final-successor-'));
1775
+ try {
1776
+ const context = resolveSessionPointerContext(cwd);
1777
+ const ownerPath = join(context.lockPath, 'owner.json');
1778
+ const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1779
+ const lockPark = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
1780
+ await writeLockOwner(cwd, validLockOwner());
1781
+ const owner = await lstat(ownerPath);
1782
+ const lock = await lstat(context.lockPath);
1783
+ await writeFile(checkpointPath, JSON.stringify({ version: 3, sourcePath: ownerPath, identity: { dev: owner.dev, ino: owner.ino }, evidenceIdentity: { dev: owner.dev, ino: owner.ino }, evidenceBytes: JSON.stringify(validLockOwner()), lockIdentity: { dev: lock.dev, ino: lock.ino }, lockParkPath: lockPark, phase: 'evidence-pending' }));
1784
+ const successor = JSON.stringify(validLockOwner({ token: FOREIGN_TOKEN, pid: process.pid + 100_000 }));
1785
+ let moved = false;
1786
+ await withPointerDependencies({ token: () => { throw new Error('resume must not mint'); }, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => {
1787
+ if (from === context.lockPath && to === lockPark) {
1788
+ await rename(from, to);
1789
+ await mkdir(context.lockPath);
1790
+ await writeFile(ownerPath, successor);
1791
+ moved = true;
1792
+ return 'moved';
1793
+ }
1794
+ return 'not-moved';
1795
+ } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
1796
+ assert.equal(moved, true);
1797
+ assert.equal(await readFile(ownerPath, 'utf8'), successor);
1798
+ assert.deepEqual(await __releasePointerLockForTests(cwd, FOREIGN_TOKEN), []);
1799
+ assert.equal(existsSync(context.lockPath), false);
1800
+ assert.equal(existsSync(lockPark), true);
1801
+ }
1802
+ finally {
1803
+ await rm(cwd, { recursive: true, force: true });
1804
+ }
1805
+ });
1806
+ it('refuses a quarantine path that appears after its preflight check without overwriting it', async () => {
1807
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-quarantine-race-'));
1808
+ try {
1809
+ const context = resolveSessionPointerContext(cwd);
1810
+ const ownerPath = join(context.lockPath, 'owner.json');
1811
+ const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
1812
+ const foreign = 'foreign quarantine marker';
1813
+ await writeLockOwner(cwd, validLockOwner());
1814
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => { if (to === quarantine)
1815
+ await writeFile(to, foreign); await link(from, to); } } }, async () => {
1816
+ const result = await recoverSessionPointerLock(cwd);
1817
+ assert.equal(result.recovered, false);
1818
+ assert.match(result.reason, /EEXIST|quarantine/i);
1819
+ });
1820
+ assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
1821
+ assert.equal(await readFile(quarantine, 'utf8'), foreign);
1822
+ }
1823
+ finally {
1824
+ await rm(cwd, { recursive: true, force: true });
1825
+ }
1826
+ });
1827
+ it('fails closed without mutation when a recovery claim destination appears before the no-clobber claim link', async () => {
1828
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-claim-exist-'));
1829
+ try {
1830
+ const context = resolveSessionPointerContext(cwd);
1831
+ const ownerPath = join(context.lockPath, 'owner.json');
1832
+ const claim = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
1833
+ let moved = false;
1834
+ await writeLockOwner(cwd, validLockOwner());
1835
+ await withPointerDependencies({
1836
+ token: () => SUCCESSOR_TOKEN,
1837
+ probePid: () => 'dead',
1838
+ fs: {
1839
+ writeFile: async (path, data, options) => {
1840
+ await writeFile(path, data, options);
1841
+ if (path.includes('.recovery.') && path.endsWith('.json'))
1842
+ await writeFile(claim, 'foreign claim');
1102
1843
  },
1844
+ },
1845
+ atomicRenameNoReplace: withProbeFallback(context.lockPath, async () => {
1846
+ moved = true;
1847
+ return 'moved';
1103
1848
  }),
1104
- },
1105
- {
1106
- name: 'owner-sync',
1107
- operation: 'lock-owner-publish',
1108
- dependencies: (context) => ({
1109
- token: () => TEST_TOKEN,
1110
- fs: {
1111
- openAndSync: async (path) => {
1112
- if (path === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
1113
- throw new Error('owner sync failure');
1114
- return 'synced';
1115
- },
1849
+ }, async () => {
1850
+ const recovered = await recoverSessionPointerLock(cwd);
1851
+ assert.equal(recovered.recovered, false, recovered.reason);
1852
+ assert.match(recovered.reason, /foreign|changed|claim/i);
1853
+ });
1854
+ assert.equal(moved, false);
1855
+ assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
1856
+ assert.equal(await readFile(claim, 'utf8'), 'foreign claim');
1857
+ }
1858
+ finally {
1859
+ await rm(cwd, { recursive: true, force: true });
1860
+ }
1861
+ });
1862
+ it('preserves a swapped parked quarantine claim rather than unlinking it', async () => {
1863
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-parked-claim-swap-'));
1864
+ try {
1865
+ const context = resolveSessionPointerContext(cwd);
1866
+ const ownerPath = join(context.lockPath, 'owner.json');
1867
+ const lockPark = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
1868
+ const claim = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
1869
+ const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
1870
+ const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1871
+ await writeLockOwner(cwd, validLockOwner());
1872
+ const owner = await lstat(ownerPath);
1873
+ const lock = await lstat(context.lockPath);
1874
+ await link(ownerPath, claim);
1875
+ await link(ownerPath, quarantine);
1876
+ await rm(claim);
1877
+ await writeFile(claim, 'foreign parked claim');
1878
+ await rename(context.lockPath, lockPark);
1879
+ await writeFile(checkpoint, JSON.stringify({ version: 2, sourcePath: ownerPath, identity: { dev: owner.dev, ino: owner.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, lockParkPath: lockPark, phase: 'evidence-quarantined' }));
1880
+ await withPointerDependencies({ token: () => { throw new Error('resume must not mint'); }, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => { if (from === checkpoint) {
1881
+ await rename(from, to);
1882
+ return 'moved';
1883
+ } throw new Error('directory move must not occur'); } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
1884
+ assert.equal(await readFile(join(lockPark, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`), 'utf8'), 'foreign parked claim');
1885
+ assert.equal(await readFile(quarantine, 'utf8'), JSON.stringify(validLockOwner()));
1886
+ assert.equal(existsSync(lockPark), true);
1887
+ }
1888
+ finally {
1889
+ await rm(cwd, { recursive: true, force: true });
1890
+ }
1891
+ });
1892
+ it('fails closed without mutation when evidence lstat fails non-ENOENT before the claim', async () => {
1893
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-evidence-eacces-'));
1894
+ try {
1895
+ const context = resolveSessionPointerContext(cwd);
1896
+ const ownerPath = join(context.lockPath, 'owner.json');
1897
+ const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
1898
+ let armed = false;
1899
+ let moved = false;
1900
+ await writeLockOwner(cwd, validLockOwner());
1901
+ await withPointerDependencies({
1902
+ token: () => SUCCESSOR_TOKEN,
1903
+ probePid: () => 'dead',
1904
+ fs: {
1905
+ link: async (from, to) => {
1906
+ await link(from, to);
1907
+ if (to === quarantine)
1908
+ armed = true;
1909
+ },
1910
+ lstat: async (path) => {
1911
+ if (armed && path === ownerPath)
1912
+ throw codedError('EACCES');
1913
+ return await lstat(path);
1116
1914
  },
1915
+ },
1916
+ atomicRenameNoReplace: withProbeFallback(context.lockPath, async () => {
1917
+ moved = true;
1918
+ return 'moved';
1117
1919
  }),
1118
- },
1119
- {
1120
- name: 'owner-rename',
1121
- operation: 'lock-owner-publish',
1122
- dependencies: (context) => ({
1123
- token: () => TEST_TOKEN,
1920
+ }, async () => {
1921
+ const recovered = await recoverSessionPointerLock(cwd);
1922
+ assert.equal(recovered.recovered, false, recovered.reason);
1923
+ });
1924
+ assert.equal(moved, false);
1925
+ assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
1926
+ }
1927
+ finally {
1928
+ await rm(cwd, { recursive: true, force: true });
1929
+ }
1930
+ });
1931
+ it('restores evidence and keeps the exact quarantine inspect diagnostic when quarantine lstat fails non-ENOENT', async () => {
1932
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-quarantine-eacces-'));
1933
+ try {
1934
+ const context = resolveSessionPointerContext(cwd);
1935
+ const ownerPath = join(context.lockPath, 'owner.json');
1936
+ const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
1937
+ const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1938
+ let armed = false;
1939
+ await writeLockOwner(cwd, validLockOwner());
1940
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => { await link(from, to); if (to === quarantine)
1941
+ armed = true; }, lstat: async (path) => { if (armed && path === quarantine)
1942
+ throw codedError('EACCES'); return await lstat(path); } } }, async () => { const result = await recoverSessionPointerLock(cwd); assert.equal(result.recovered, false); assert.match(result.reason, /EACCES/); });
1943
+ assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
1944
+ assert.equal(await readFile(quarantine, 'utf8'), JSON.stringify(validLockOwner()));
1945
+ assert.equal(existsSync(checkpoint), true);
1946
+ }
1947
+ finally {
1948
+ await rm(cwd, { recursive: true, force: true });
1949
+ }
1950
+ });
1951
+ it('preserves a swapped parked lock directory rather than removing it', async () => {
1952
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-directory-swap-'));
1953
+ try {
1954
+ const context = resolveSessionPointerContext(cwd);
1955
+ const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
1956
+ const original = `${parked}.original`;
1957
+ await writeLockOwner(cwd, validLockOwner());
1958
+ await withPointerDependencies({
1959
+ token: () => SUCCESSOR_TOKEN,
1960
+ probePid: () => 'dead',
1961
+ atomicRenameNoReplace: withProbeFallback(context.lockPath, async (from, to) => {
1962
+ if (from === context.lockPath && to === parked) {
1963
+ await rename(from, original);
1964
+ await mkdir(from);
1965
+ await writeFile(join(from, 'foreign'), 'foreign');
1966
+ await rename(from, to);
1967
+ return 'moved';
1968
+ }
1969
+ return await defaultTestAtomicRenameNoReplace(from, to);
1970
+ }),
1971
+ }, async () => {
1972
+ assert.equal((await recoverSessionPointerLock(cwd)).recovered, false);
1973
+ });
1974
+ assert.equal(await readFile(join(parked, 'foreign'), 'utf8'), 'foreign');
1975
+ assert.equal(existsSync(join(original, 'owner.json')), true);
1976
+ }
1977
+ finally {
1978
+ await rm(cwd, { recursive: true, force: true });
1979
+ }
1980
+ });
1981
+ for (const [label, checkpoint] of [
1982
+ ['wrong version', { version: 99, phase: 'evidence-pending', sourcePath: 'x', identity: { dev: 0, ino: 0 } }],
1983
+ ['wrong phase', { version: 1, phase: 'claim-pending', sourcePath: 'x', identity: { dev: 0, ino: 0 } }],
1984
+ ['out-of-root path', { version: 1, phase: 'evidence-pending', sourcePath: '/foreign/owner.json', identity: { dev: 0, ino: 0 } }],
1985
+ ])
1986
+ it(`fails closed for ${label} checkpoint`, async () => {
1987
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-invalid-checkpoint-'));
1988
+ try {
1989
+ const context = resolveSessionPointerContext(cwd);
1990
+ const path = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
1991
+ await writeLockOwner(cwd, validLockOwner());
1992
+ await writeFile(path, JSON.stringify(checkpoint));
1993
+ await withPointerDependencies({ token: () => { throw new Error('must not mint'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
1994
+ assert.equal(await readFile(path, 'utf8'), JSON.stringify(checkpoint));
1995
+ assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf8'), JSON.stringify(validLockOwner()));
1996
+ }
1997
+ finally {
1998
+ await rm(cwd, { recursive: true, force: true });
1999
+ }
2000
+ });
2001
+ for (const fault of ['EBUSY', 'ENOTEMPTY'])
2002
+ it(`completes a checkpoint resume retry after one-shot atomic directory move failure ${fault}`, async () => {
2003
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-atomic-retry-'));
2004
+ try {
2005
+ const context = resolveSessionPointerContext(cwd);
2006
+ const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
2007
+ let fail = true;
2008
+ await writeLockOwner(cwd, validLockOwner());
2009
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => { if (from === context.lockPath && fail) {
2010
+ fail = false;
2011
+ throw codedError(fault);
2012
+ } if (existsSync(to))
2013
+ return 'not-moved'; await rename(from, to); return 'moved'; } }, async () => { assert.equal((await recoverSessionPointerLock(cwd)).recovered, false); assert.equal(existsSync(checkpoint), true); assert.equal(existsSync(context.lockPath), true); assert.equal((await recoverSessionPointerLock(cwd)).recovered, true); });
2014
+ }
2015
+ finally {
2016
+ await rm(cwd, { recursive: true, force: true });
2017
+ }
2018
+ });
2019
+ it('keeps a foreign atomic destination and the source lock when no directory move occurs', async () => {
2020
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-atomic-destination-'));
2021
+ try {
2022
+ const context = resolveSessionPointerContext(cwd);
2023
+ await writeLockOwner(cwd, validLockOwner());
2024
+ const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
2025
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async (_from, to) => {
2026
+ await mkdir(to);
2027
+ await writeFile(join(to, 'foreign'), 'foreign');
2028
+ return 'not-moved';
2029
+ } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
2030
+ assert.equal(await readFile(join(parked, 'foreign'), 'utf8'), 'foreign');
2031
+ assert.equal(existsSync(context.lockPath), true);
2032
+ }
2033
+ finally {
2034
+ await rm(cwd, { recursive: true, force: true });
2035
+ }
2036
+ });
2037
+ it('preserves a foreign directory swapped into the atomic move and rollback residue', async () => {
2038
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-atomic-source-swap-'));
2039
+ try {
2040
+ const context = resolveSessionPointerContext(cwd);
2041
+ const original = `${context.lockPath}.original`;
2042
+ const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
2043
+ await writeLockOwner(cwd, validLockOwner());
2044
+ await withPointerDependencies({
2045
+ token: () => SUCCESSOR_TOKEN,
2046
+ probePid: () => 'dead',
2047
+ atomicRenameNoReplace: withProbeFallback(context.lockPath, async (from, to) => {
2048
+ if (from === context.lockPath && to === parked) {
2049
+ await rename(from, original);
2050
+ await mkdir(from);
2051
+ await writeFile(join(from, 'foreign'), 'foreign');
2052
+ await rename(from, to);
2053
+ return 'moved';
2054
+ }
2055
+ if (from === parked && to === context.lockPath)
2056
+ return 'not-moved';
2057
+ return await defaultTestAtomicRenameNoReplace(from, to);
2058
+ }),
2059
+ }, async () => {
2060
+ assert.equal((await recoverSessionPointerLock(cwd)).recovered, false);
2061
+ });
2062
+ assert.equal(existsSync(join(original, 'owner.json')), true);
2063
+ assert.equal(await readFile(join(parked, 'foreign'), 'utf8'), 'foreign');
2064
+ }
2065
+ finally {
2066
+ await rm(cwd, { recursive: true, force: true });
2067
+ }
2068
+ });
2069
+ it('does not overwrite a rollback destination that appears before the no-clobber rollback link', async () => {
2070
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-rollback-destination-race-'));
2071
+ try {
2072
+ const context = resolveSessionPointerContext(cwd);
2073
+ const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
2074
+ const foreign = 'foreign rollback destination';
2075
+ let rollback = false;
2076
+ await writeLockOwner(cwd, validLockOwner());
2077
+ await withPointerDependencies({
2078
+ token: () => SUCCESSOR_TOKEN,
2079
+ probePid: () => 'dead',
2080
+ atomicRenameNoReplace: async (from, to) => {
2081
+ if (from === context.lockPath) {
2082
+ await rename(from, to);
2083
+ await mkdir(context.lockPath);
2084
+ await writeFile(join(context.lockPath, 'foreign'), foreign);
2085
+ return 'moved';
2086
+ }
2087
+ if (from === parked && to === context.lockPath) {
2088
+ rollback = true;
2089
+ return 'not-moved';
2090
+ }
2091
+ if (existsSync(to))
2092
+ return 'not-moved';
2093
+ await rename(from, to);
2094
+ return 'moved';
2095
+ },
2096
+ }, async () => {
2097
+ const recovered = await recoverSessionPointerLock(cwd);
2098
+ assert.equal(recovered.recovered, false);
2099
+ assert.equal(recovered.action, 'none');
2100
+ assert.match(recovered.reason, /captured object|token-bound|source pathname/i);
2101
+ });
2102
+ assert.equal(rollback, true);
2103
+ assert.equal(await readFile(join(context.lockPath, 'foreign'), 'utf-8'), foreign);
2104
+ assert.equal(existsSync(join(parked, 'owner.json')), true);
2105
+ }
2106
+ finally {
2107
+ await rm(cwd, { recursive: true, force: true });
2108
+ }
2109
+ });
2110
+ it('preserves a checkpoint replacement and foreign completed receipt collision', async () => {
2111
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-completion-collision-'));
2112
+ try {
2113
+ const context = resolveSessionPointerContext(cwd);
2114
+ await writeLockOwner(cwd, validLockOwner());
2115
+ const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
2116
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => {
2117
+ if (from === checkpoint) {
2118
+ await writeFile(to, 'foreign receipt');
2119
+ return 'not-moved';
2120
+ }
2121
+ await rename(from, to);
2122
+ return 'moved';
2123
+ } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
2124
+ assert.equal(existsSync(checkpoint), true);
2125
+ assert.equal(await readFile(checkpoint.replace(/\.json$/, '.completed'), 'utf8'), 'foreign receipt');
2126
+ }
2127
+ finally {
2128
+ await rm(cwd, { recursive: true, force: true });
2129
+ }
2130
+ });
2131
+ it('fails closed before moving a lock directory with a foreign extra entry', async () => {
2132
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-foreign-entry-'));
2133
+ try {
2134
+ const context = resolveSessionPointerContext(cwd);
2135
+ await writeLockOwner(cwd, validLockOwner());
2136
+ await writeFile(join(context.lockPath, 'foreign'), 'foreign');
2137
+ let moved = false;
2138
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async () => { moved = true; return 'moved'; } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
2139
+ assert.equal(moved, false);
2140
+ assert.equal(await readFile(join(context.lockPath, 'foreign'), 'utf8'), 'foreign');
2141
+ }
2142
+ finally {
2143
+ await rm(cwd, { recursive: true, force: true });
2144
+ }
2145
+ });
2146
+ it('resumes legacy base-v1 recovery and v2 source and parked partial recovery', async () => {
2147
+ for (const state of ['v1-source', 'v2-source', 'v2-parked']) {
2148
+ const cwd = await mkdtemp(join(tmpdir(), `omx-session-recovery-legacy-${state}-`));
2149
+ try {
2150
+ const context = resolveSessionPointerContext(cwd);
2151
+ const ownerPath = join(context.lockPath, 'owner.json');
2152
+ const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
2153
+ const lockPark = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
2154
+ await writeLockOwner(cwd, validLockOwner());
2155
+ const owner = await lstat(ownerPath);
2156
+ const lock = await lstat(context.lockPath);
2157
+ const base = { version: state === 'v1-source' ? 1 : 2, sourcePath: ownerPath, identity: { dev: owner.dev, ino: owner.ino }, phase: 'evidence-pending' };
2158
+ if (state === 'v2-parked') {
2159
+ await link(ownerPath, join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`));
2160
+ await link(ownerPath, `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`);
2161
+ await rename(context.lockPath, lockPark);
2162
+ }
2163
+ await writeFile(checkpoint, JSON.stringify(state === 'v1-source' ? base : { ...base, lockIdentity: { dev: lock.dev, ino: lock.ino }, lockParkPath: lockPark, phase: state === 'v2-parked' ? 'evidence-quarantined' : 'evidence-pending' }));
2164
+ await withPointerDependencies({ token: () => { throw new Error('legacy resume must not mint'); }, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => { if (existsSync(to))
2165
+ return 'not-moved'; await rename(from, to); return 'moved'; } }, async () => {
2166
+ const recovered = await recoverSessionPointerLock(cwd);
2167
+ assert.equal(recovered.recovered, true, recovered.reason);
2168
+ });
2169
+ assert.equal(existsSync(checkpoint.replace(/\.json$/, '.completed')), true);
2170
+ assert.equal(existsSync(lockPark), true);
2171
+ }
2172
+ finally {
2173
+ await rm(cwd, { recursive: true, force: true });
2174
+ }
2175
+ }
2176
+ });
2177
+ it('preserves recovery evidence when atomic directory quarantine is unsupported', async () => {
2178
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-unsupported-'));
2179
+ try {
2180
+ const context = resolveSessionPointerContext(cwd);
2181
+ await writeLockOwner(cwd, validLockOwner());
2182
+ await withPointerDependencies({
2183
+ token: () => SUCCESSOR_TOKEN,
2184
+ probePid: () => 'dead',
2185
+ atomicRenameNoReplace: async () => 'unsupported',
2186
+ }, async () => {
2187
+ const recovered = await recoverSessionPointerLock(cwd);
2188
+ assert.equal(recovered.recovered, false);
2189
+ assert.match(recovered.reason, /unsupported/i);
2190
+ });
2191
+ // Capability is probed before checkpoint/quarantine mutations.
2192
+ assert.equal(existsSync(context.lockPath), true);
2193
+ assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf8'), JSON.stringify(validLockOwner()));
2194
+ assert.equal(existsSync(`${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`), false);
2195
+ assert.equal(existsSync(`${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`), false);
2196
+ }
2197
+ finally {
2198
+ await rm(cwd, { recursive: true, force: true });
2199
+ }
2200
+ });
2201
+ it('distinguishes a paused live pre-rename owner from the same owner after SIGKILL', async (t) => {
2202
+ if (process.platform !== 'linux') {
2203
+ t.skip('Linux process start identity is required for deterministic PID reuse protection.');
2204
+ return;
2205
+ }
2206
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-sigkill-'));
2207
+ const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });
2208
+ try {
2209
+ assert.ok(child.pid);
2210
+ await once(child, 'spawn');
2211
+ const context = resolveSessionPointerContext(cwd);
2212
+ await mkdir(context.lockPath, { recursive: true });
2213
+ await writeFile(join(context.lockPath, `owner.${TEST_TOKEN}.tmp`), JSON.stringify(validLockOwner({
2214
+ pid: child.pid,
2215
+ pid_start_ticks: await linuxProcessStartTicks(child.pid),
2216
+ })), 'utf-8');
2217
+ const paused = await recoverSessionPointerLock(cwd);
2218
+ assert.equal(paused.status, 'live');
2219
+ assert.equal(paused.recovered, false);
2220
+ assert.equal(paused.action, 'none');
2221
+ assert.equal(existsSync(context.lockPath), true);
2222
+ child.kill('SIGKILL');
2223
+ const [exitCode, signal] = await once(child, 'exit');
2224
+ assert.equal(exitCode, null);
2225
+ assert.equal(signal, 'SIGKILL');
2226
+ let orphaned;
2227
+ await withPointerDependencies({
2228
+ atomicRenameNoReplace: async (from, to) => {
2229
+ await rename(from, to);
2230
+ return 'moved';
2231
+ },
2232
+ }, async () => { orphaned = await recoverSessionPointerLock(cwd); });
2233
+ assert.equal(orphaned.status, 'dead');
2234
+ assert.equal(orphaned.recovered, true);
2235
+ assert.equal(orphaned.action, 'quarantined');
2236
+ assert.equal(existsSync(context.lockPath), false);
2237
+ }
2238
+ finally {
2239
+ if (child.exitCode === null && child.signalCode === null)
2240
+ child.kill('SIGKILL');
2241
+ await rm(cwd, { recursive: true, force: true });
2242
+ }
2243
+ });
2244
+ it('fails closed for live pre-rename, PID reuse, malformed, and ambiguous lock evidence', async () => {
2245
+ const cases = [
2246
+ { name: 'live-temp', files: [[`owner.${TEST_TOKEN}.tmp`, JSON.stringify(validLockOwner())]], probePid: 'alive', observation: matchingObservation() },
2247
+ { name: 'reused', files: [['owner.json', JSON.stringify(validLockOwner())]], probePid: 'alive', observation: { kind: 'identity', identity: { platform: 'linux', birth: '2' } } },
2248
+ { name: 'malformed', files: [['owner.json', '{']], probePid: 'dead' },
2249
+ { name: 'ambiguous', files: [['owner.json', JSON.stringify(validLockOwner())], [`owner.${SUCCESSOR_TOKEN}.tmp`, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }))]], probePid: 'dead' },
2250
+ ];
2251
+ for (const testCase of cases) {
2252
+ const cwd = await mkdtemp(join(tmpdir(), `omx-session-lock-recovery-${testCase.name}-`));
2253
+ try {
2254
+ const context = resolveSessionPointerContext(cwd);
2255
+ await mkdir(context.lockPath, { recursive: true });
2256
+ await Promise.all(testCase.files.map(async ([name, contents]) => await writeFile(join(context.lockPath, name), contents, 'utf-8')));
2257
+ await withPointerDependencies({
2258
+ token: () => SUCCESSOR_TOKEN,
2259
+ probePid: () => testCase.probePid,
2260
+ observeProcess: () => testCase.observation ?? matchingObservation(),
2261
+ }, async () => {
2262
+ const before = await readdir(context.lockPath);
2263
+ const recovered = await recoverSessionPointerLock(cwd);
2264
+ assert.equal(recovered.recovered, false);
2265
+ assert.equal(recovered.action, 'none');
2266
+ assert.equal(recovered.safeToRecover, false);
2267
+ assert.deepEqual(await readdir(context.lockPath), before);
2268
+ });
2269
+ }
2270
+ finally {
2271
+ await rm(cwd, { recursive: true, force: true });
2272
+ }
2273
+ }
2274
+ });
2275
+ it('claims stale evidence before quarantine so a successor cannot be mistaken for it', async () => {
2276
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-race-'));
2277
+ try {
2278
+ const context = resolveSessionPointerContext(cwd);
2279
+ await mkdir(context.lockPath, { recursive: true });
2280
+ await writeFile(join(context.lockPath, `owner.${TEST_TOKEN}.tmp`), JSON.stringify(validLockOwner()), 'utf-8');
2281
+ const links = [];
2282
+ let successorBlocked = false;
2283
+ await withPointerDependencies({
2284
+ token: () => SUCCESSOR_TOKEN,
2285
+ probePid: () => 'dead',
2286
+ fs: {
2287
+ link: async (from, to) => {
2288
+ links.push([from, to]);
2289
+ await link(from, to);
2290
+ },
2291
+ },
2292
+ atomicRenameNoReplace: async (from, to) => {
2293
+ if (from === context.lockPath) {
2294
+ await assert.rejects(mkdir(context.lockPath), { code: 'EEXIST' });
2295
+ successorBlocked = true;
2296
+ }
2297
+ if (existsSync(to))
2298
+ return 'not-moved';
2299
+ await rename(from, to);
2300
+ return 'moved';
2301
+ },
2302
+ }, async () => {
2303
+ const recovered = await recoverSessionPointerLock(cwd);
2304
+ assert.equal(recovered.recovered, true);
2305
+ });
2306
+ assert.equal(successorBlocked, true);
2307
+ assert.equal(links.length, 1);
2308
+ assert.match(links[0][0], /owner\.transaction_token_123456\.tmp$/);
2309
+ assert.equal(links[0][1].includes('.quarantine.'), true);
2310
+ assert.equal(existsSync(`${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`), true);
2311
+ }
2312
+ finally {
2313
+ await rm(cwd, { recursive: true, force: true });
2314
+ }
2315
+ });
2316
+ it('does not claim a successor lock when the inspected orphan is displaced before the exact temp rename', async () => {
2317
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-successor-race-'));
2318
+ try {
2319
+ const context = resolveSessionPointerContext(cwd);
2320
+ const staleTemp = join(context.lockPath, `owner.${TEST_TOKEN}.tmp`);
2321
+ const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
2322
+ const displacedPath = `${context.lockPath}.displaced`;
2323
+ await mkdir(context.lockPath, { recursive: true });
2324
+ await writeFile(staleTemp, JSON.stringify(validLockOwner()), 'utf-8');
2325
+ let displaced = false;
2326
+ await withPointerDependencies({
2327
+ token: () => SUCCESSOR_TOKEN,
2328
+ probePid: () => 'dead',
2329
+ fs: {
2330
+ link: async (from, to) => {
2331
+ if (!displaced && from === staleTemp) {
2332
+ displaced = true;
2333
+ await rename(context.lockPath, displacedPath);
2334
+ await mkdir(context.lockPath);
2335
+ await writeFile(successorTemp, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })), 'utf-8');
2336
+ }
2337
+ await link(from, to);
2338
+ },
2339
+ },
2340
+ }, async () => {
2341
+ const recovered = await recoverSessionPointerLock(cwd);
2342
+ assert.equal(recovered.recovered, false);
2343
+ assert.equal(recovered.action, 'none');
2344
+ assert.match(recovered.reason, /Recovery checkpoint.*ENOENT/i);
2345
+ });
2346
+ assert.equal(displaced, true);
2347
+ assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
2348
+ assert.equal(await readFile(successorTemp, 'utf-8'), JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })));
2349
+ }
2350
+ finally {
2351
+ await rm(cwd, { recursive: true, force: true });
2352
+ }
2353
+ });
2354
+ it('does not report recovery after a successor live lock appears during parked-lock removal', async () => {
2355
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-post-claim-race-'));
2356
+ try {
2357
+ const context = resolveSessionPointerContext(cwd);
2358
+ const staleTemp = join(context.lockPath, `owner.${TEST_TOKEN}.tmp`);
2359
+ const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
2360
+ await mkdir(context.lockPath, { recursive: true });
2361
+ await writeFile(staleTemp, JSON.stringify(validLockOwner()), 'utf-8');
2362
+ let displaced = false;
2363
+ await withPointerDependencies({
2364
+ token: () => SUCCESSOR_TOKEN,
2365
+ probePid: () => 'dead',
2366
+ atomicRenameNoReplace: async (from, to) => {
2367
+ if (existsSync(to))
2368
+ return 'not-moved';
2369
+ await rename(from, to);
2370
+ if (!displaced && from === context.lockPath) {
2371
+ displaced = true;
2372
+ await mkdir(context.lockPath);
2373
+ await writeFile(successorTemp, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })), 'utf-8');
2374
+ }
2375
+ return 'moved';
2376
+ },
2377
+ }, async () => {
2378
+ const recovered = await recoverSessionPointerLock(cwd);
2379
+ assert.equal(recovered.recovered, false);
2380
+ assert.equal(recovered.action, 'none');
2381
+ assert.match(recovered.reason, /did not leave the captured object/i);
2382
+ });
2383
+ assert.equal(displaced, true);
2384
+ assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
2385
+ assert.equal(await readFile(successorTemp, 'utf-8'), JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })));
2386
+ }
2387
+ finally {
2388
+ await rm(cwd, { recursive: true, force: true });
2389
+ }
2390
+ });
2391
+ it('retries a positively matching live owner with the bounded 25/50/100 schedule before timing out', async () => {
2392
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-timeout-'));
2393
+ try {
2394
+ await writeLockOwner(cwd, validLockOwner());
2395
+ const delays = [];
2396
+ let now = 0;
2397
+ await withPointerDependencies({
2398
+ nowMs: () => now,
2399
+ sleep: async (ms) => { delays.push(ms); now += ms; },
2400
+ token: () => TEST_TOKEN,
2401
+ probePid: () => 'alive',
2402
+ observeProcess: () => matchingObservation(),
2403
+ }, async () => {
2404
+ await assert.rejects(writeSessionStart(cwd, 'sess-timeout', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
2405
+ && error.code === 'session_pointer_lock_timeout'
2406
+ && error.lockOwnerStatus === 'live');
2407
+ });
2408
+ assert.deepEqual(delays.slice(0, 3), [25, 50, 100]);
2409
+ assert.equal(delays.at(-1), 25);
2410
+ assert.equal(existsSync(resolveSessionPointerContext(cwd).lockPath), true);
2411
+ }
2412
+ finally {
2413
+ await rm(cwd, { recursive: true, force: true });
2414
+ }
2415
+ });
2416
+ it('rolls back only its unpublished owner artifacts and reports rollback ambiguity', async () => {
2417
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-publish-'));
2418
+ try {
2419
+ const context = resolveSessionPointerContext(cwd);
2420
+ await withPointerDependencies({
2421
+ token: () => TEST_TOKEN,
2422
+ fs: {
2423
+ writeFile: async (path, data, options) => {
2424
+ if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
2425
+ throw new Error('owner write failure');
2426
+ await writeFile(path, data, options);
2427
+ },
2428
+ },
2429
+ }, async () => {
2430
+ await assert.rejects(writeSessionStart(cwd, 'sess-owner-publish', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
2431
+ && error.code === 'session_pointer_io_failure'
2432
+ && error.operation === 'lock-owner-publish');
2433
+ });
2434
+ assert.equal(existsSync(context.lockPath), false);
2435
+ await withPointerDependencies({
2436
+ token: () => TEST_TOKEN,
2437
+ fs: {
2438
+ writeFile: async (path, data, options) => {
2439
+ if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
2440
+ throw new Error('owner write failure');
2441
+ await writeFile(path, data, options);
2442
+ },
2443
+ rmdir: async (path) => {
2444
+ if (path === context.lockPath)
2445
+ throw new Error('keep lock evidence');
2446
+ await rm(path, { recursive: false });
2447
+ },
2448
+ },
2449
+ }, async () => {
2450
+ await assert.rejects(writeSessionStart(cwd, 'sess-owner-residue', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
2451
+ && error.code === 'session_pointer_lock_recovery_required'
2452
+ && error.primaryOperation === 'lock-owner-publish'
2453
+ && error.secondaryFailures?.[0]?.phase === 'remove-unpublished-lock');
2454
+ });
2455
+ assert.equal(existsSync(context.lockPath), true);
2456
+ }
2457
+ finally {
2458
+ await rm(cwd, { recursive: true, force: true });
2459
+ }
2460
+ });
2461
+ it('publishes a legacy-like Linux state when process identity capture fails', async (t) => {
2462
+ if (process.platform !== 'linux') {
2463
+ t.skip('Linux identity publication semantics are under test.');
2464
+ return;
2465
+ }
2466
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-identity-capture-failure-'));
2467
+ try {
2468
+ await withPointerDependencies({
2469
+ runtimePlatform: 'linux',
2470
+ observeProcess: () => ({ kind: 'error' }),
2471
+ }, async () => {
2472
+ const state = await writeSessionStart(cwd, 'sess-identity-capture-failure', { platform: 'linux' });
2473
+ assert.equal(state.platform, 'linux');
2474
+ assert.equal(Object.hasOwn(state, 'identity_schema_version'), false);
2475
+ assert.equal(Object.hasOwn(state, 'process_identity'), false);
2476
+ assert.equal(Object.hasOwn(state, 'pid_start_ticks'), false);
2477
+ const persisted = await readSessionState(cwd);
2478
+ assert.ok(persisted);
2479
+ assert.equal(Object.hasOwn(persisted, 'identity_schema_version'), false);
2480
+ assert.equal(Object.hasOwn(persisted, 'process_identity'), false);
2481
+ });
2482
+ }
2483
+ finally {
2484
+ await rm(cwd, { recursive: true, force: true });
2485
+ }
2486
+ });
2487
+ it('publishes the owner and pointer on Windows when regular-file fsync reports EPERM', async () => {
2488
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-windows-eperm-'));
2489
+ let syncCalls = 0;
2490
+ const regularFileSync = async (platform) => {
2491
+ assert.equal(platform, 'win32');
2492
+ syncCalls += 1;
2493
+ throw codedError('EPERM');
2494
+ };
2495
+ try {
2496
+ const state = await writeSessionStart(cwd, 'sess-windows-eperm', {
2497
+ platform: 'win32',
2498
+ regularFileSync,
2499
+ });
2500
+ const context = resolveSessionPointerContext(cwd);
2501
+ assert.equal(state.session_id, 'sess-windows-eperm');
2502
+ assert.equal(syncCalls, 2, 'owner publication and pointer publication must both attempt fsync');
2503
+ assert.equal(existsSync(join(context.lockPath, 'owner.json')), false, 'lock owner is removed after commit');
2504
+ assert.equal(JSON.parse(await readFile(context.sessionPath, 'utf8')).session_id, 'sess-windows-eperm');
2505
+ }
2506
+ finally {
2507
+ await rm(cwd, { recursive: true, force: true });
2508
+ }
2509
+ });
2510
+ it('emits one post-release warning for a degraded session start and stays silent for synced or failed transactions', async () => {
2511
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-durability-warning-'));
2512
+ const originalWrite = process.stderr.write;
2513
+ const warnings = [];
2514
+ let warningCwd = cwd;
2515
+ process.stderr.write = ((value) => {
2516
+ warnings.push({ value, lockExists: existsSync(resolveSessionPointerContext(warningCwd).lockPath) });
2517
+ return true;
2518
+ });
2519
+ const regularFileSync = async () => { throw codedError('EPERM'); };
2520
+ try {
2521
+ await writeSessionStart(cwd, 'sess-degraded-start', { platform: 'win32', regularFileSync });
2522
+ assert.deepEqual(warnings, [{
2523
+ value: '[omx] warning: Windows EPERM regular-file fsync unsupported in session pointer start/reconcile; operation succeeded with degraded durability.\n',
2524
+ lockExists: false,
2525
+ }]);
2526
+ warnings.length = 0;
2527
+ const syncedCwd = join(cwd, 'synced');
2528
+ await mkdir(syncedCwd);
2529
+ warningCwd = syncedCwd;
2530
+ await writeSessionStart(syncedCwd, 'sess-synced-start', {
2531
+ platform: 'win32',
2532
+ regularFileSync: async () => { },
2533
+ });
2534
+ assert.deepEqual(warnings, []);
2535
+ const failedCwd = join(cwd, 'failed');
2536
+ await mkdir(failedCwd);
2537
+ warningCwd = failedCwd;
2538
+ await withPointerDependencies({
2539
+ fs: {
2540
+ rename: async (from, to) => {
2541
+ if (to === resolveSessionPointerContext(failedCwd).sessionPath)
2542
+ throw new Error('rename failure');
2543
+ await rename(from, to);
2544
+ },
2545
+ },
2546
+ }, async () => {
2547
+ await assert.rejects(writeSessionStart(failedCwd, 'sess-failed-start', { platform: 'win32', regularFileSync }));
2548
+ });
2549
+ assert.deepEqual(warnings, []);
2550
+ }
2551
+ finally {
2552
+ process.stderr.write = originalWrite;
2553
+ await rm(cwd, { recursive: true, force: true });
2554
+ }
2555
+ });
2556
+ it('types state-directory, pointer-read, owner-sync, owner-rename, and invalid-token failures before commit', async () => {
2557
+ const failures = [
2558
+ {
2559
+ name: 'state-dir',
2560
+ operation: 'state-dir-create',
2561
+ dependencies: (context) => ({
2562
+ fs: {
2563
+ mkdir: async (path, options) => {
2564
+ if (path === context.baseStateDir)
2565
+ throw new Error('state directory failure');
2566
+ await mkdir(path, options);
2567
+ },
2568
+ },
2569
+ }),
2570
+ },
2571
+ {
2572
+ name: 'pointer-read',
2573
+ operation: 'pointer-read',
2574
+ dependencies: (context) => ({
2575
+ token: () => TEST_TOKEN,
2576
+ fs: {
2577
+ readFile: async (path, encoding) => {
2578
+ if (path === context.sessionPath)
2579
+ throw new Error('pointer read failure');
2580
+ return await readFile(path, encoding);
2581
+ },
2582
+ },
2583
+ }),
2584
+ },
2585
+ {
2586
+ name: 'owner-sync',
2587
+ operation: 'lock-owner-publish',
2588
+ dependencies: (context) => ({
2589
+ token: () => TEST_TOKEN,
2590
+ fs: {
2591
+ openAndSync: async (path) => {
2592
+ if (path === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
2593
+ throw new Error('owner sync failure');
2594
+ return 'synced';
2595
+ },
2596
+ },
2597
+ }),
2598
+ },
2599
+ {
2600
+ name: 'owner-rename',
2601
+ operation: 'lock-owner-publish',
2602
+ dependencies: (context) => ({
2603
+ token: () => TEST_TOKEN,
2604
+ fs: {
2605
+ rename: async (from, to) => {
2606
+ if (from === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
2607
+ throw new Error('owner rename failure');
2608
+ await rename(from, to);
2609
+ },
2610
+ },
2611
+ }),
2612
+ },
2613
+ ];
2614
+ for (const failure of failures) {
2615
+ const cwd = await mkdtemp(join(tmpdir(), `omx-session-${failure.name}-`));
2616
+ try {
2617
+ const context = resolveSessionPointerContext(cwd);
2618
+ await withPointerDependencies(failure.dependencies(context), async () => {
2619
+ await assert.rejects(writeSessionStart(cwd, 'sess-precommit', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
2620
+ && error.code === 'session_pointer_io_failure'
2621
+ && error.operation === failure.operation);
2622
+ });
2623
+ assert.equal(existsSync(context.lockPath), false);
2624
+ }
2625
+ finally {
2626
+ await rm(cwd, { recursive: true, force: true });
2627
+ }
2628
+ }
2629
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-invalid-token-'));
2630
+ try {
2631
+ const context = resolveSessionPointerContext(cwd);
2632
+ await withPointerDependencies({ token: () => 'bad' }, async () => {
2633
+ await assert.rejects(writeSessionStart(cwd, 'sess-invalid-token', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
2634
+ && error.code === 'session_pointer_io_failure'
2635
+ && error.operation === 'lock-owner-publish');
2636
+ });
2637
+ assert.equal(existsSync(context.lockPath), false);
2638
+ assert.equal(existsSync(`${context.sessionPath}.tmp-bad`), false);
2639
+ }
2640
+ finally {
2641
+ await rm(cwd, { recursive: true, force: true });
2642
+ }
2643
+ });
2644
+ it('types pointer write, sync, and rename failures and removes only the owned temporary file', async () => {
2645
+ const phases = [
2646
+ { operation: 'pointer-temp-write', fail: 'write' },
2647
+ { operation: 'pointer-fsync', fail: 'sync' },
2648
+ { operation: 'pointer-rename', fail: 'rename' },
2649
+ ];
2650
+ for (const phase of phases) {
2651
+ const cwd = await mkdtemp(join(tmpdir(), `omx-session-${phase.fail}-`));
2652
+ try {
2653
+ const context = resolveSessionPointerContext(cwd);
2654
+ const tempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
2655
+ await withPointerDependencies({
2656
+ token: () => TEST_TOKEN,
2657
+ fs: {
2658
+ writeFile: async (path, data, options) => {
2659
+ await writeFile(path, data, options);
2660
+ if (phase.fail === 'write' && path === tempPath)
2661
+ throw new Error('pointer write failure');
2662
+ },
2663
+ openAndSync: async (path) => {
2664
+ if (phase.fail === 'sync' && path === tempPath)
2665
+ throw new Error('pointer sync failure');
2666
+ return 'synced';
2667
+ },
2668
+ rename: async (from, to) => {
2669
+ if (phase.fail === 'rename' && from === tempPath && to === context.sessionPath) {
2670
+ throw new Error('pointer rename failure');
2671
+ }
2672
+ await rename(from, to);
2673
+ },
2674
+ },
2675
+ }, async () => {
2676
+ await assert.rejects(writeSessionStart(cwd, 'sess-failure', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
2677
+ && error.code === 'session_pointer_io_failure'
2678
+ && error.operation === phase.operation);
2679
+ });
2680
+ assert.equal(existsSync(tempPath), false);
2681
+ assert.equal(existsSync(context.lockPath), false);
2682
+ }
2683
+ finally {
2684
+ await rm(cwd, { recursive: true, force: true });
2685
+ }
2686
+ }
2687
+ });
2688
+ it('preserves owned pointer residue and ordered cleanup evidence when cleanup or release cannot complete', async () => {
2689
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-residue-'));
2690
+ try {
2691
+ const context = resolveSessionPointerContext(cwd);
2692
+ const tempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
2693
+ await withPointerDependencies({
2694
+ token: () => TEST_TOKEN,
2695
+ fs: {
2696
+ writeFile: async (path, data, options) => {
2697
+ await writeFile(path, data, options);
2698
+ if (path === tempPath)
2699
+ throw new Error('pointer write failure');
2700
+ },
2701
+ unlink: async (path) => {
2702
+ if (path === tempPath)
2703
+ throw new Error('pointer temp cleanup failure');
2704
+ await rm(path, { force: false });
2705
+ },
2706
+ rmdir: async (path) => {
2707
+ if (path.endsWith(`.release-${TEST_TOKEN}`))
2708
+ throw new Error('release cleanup failure');
2709
+ await rm(path, { recursive: false });
2710
+ },
2711
+ },
2712
+ }, async () => {
2713
+ await assert.rejects(writeSessionStart(cwd, 'sess-residue', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
2714
+ && error.code === 'session_pointer_lock_recovery_required'
2715
+ && error.primaryOperation === 'pointer-temp-write'
2716
+ && error.secondaryFailures?.map((failure) => failure.phase).join(',') === 'remove-pointer-temp,remove-release-dir'
2717
+ && error.secondaryFailures?.[0]?.evidencePath === tempPath);
2718
+ });
2719
+ assert.equal(existsSync(tempPath), true);
2720
+ assert.equal(existsSync(`${context.lockPath}.release-${TEST_TOKEN}`), true);
2721
+ }
2722
+ finally {
2723
+ await rm(cwd, { recursive: true, force: true });
2724
+ }
2725
+ });
2726
+ it('leaves foreign pointer temps inert and a successor commits after explicit lock recovery', async () => {
2727
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-successor-'));
2728
+ try {
2729
+ const context = resolveSessionPointerContext(cwd);
2730
+ const ownTempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
2731
+ const foreignTempPath = `${context.sessionPath}.tmp-${FOREIGN_TOKEN}`;
2732
+ await mkdir(context.baseStateDir, { recursive: true });
2733
+ await writeFile(foreignTempPath, 'foreign transaction evidence', 'utf-8');
2734
+ await withPointerDependencies({
2735
+ token: () => TEST_TOKEN,
2736
+ fs: {
2737
+ writeFile: async (path, data, options) => {
2738
+ await writeFile(path, data, options);
2739
+ if (path === ownTempPath)
2740
+ throw new Error('pointer write failure');
2741
+ },
2742
+ unlink: async (path) => {
2743
+ if (path === ownTempPath)
2744
+ throw new Error('keep own residue');
2745
+ await rm(path, { force: false });
2746
+ },
2747
+ rename: async (from, to) => {
2748
+ if (from === context.lockPath)
2749
+ throw new Error('preserve canonical lock');
2750
+ await rename(from, to);
2751
+ },
2752
+ },
2753
+ }, async () => {
2754
+ await assert.rejects(writeSessionStart(cwd, 'sess-first', { platform: 'win32' }), isSessionPointerLaunchAbort);
2755
+ });
2756
+ assert.equal(existsSync(foreignTempPath), true);
2757
+ assert.equal(existsSync(context.lockPath), true);
2758
+ // This is documented stopped-session operator recovery, deliberately not
2759
+ // product transaction behavior.
2760
+ await rm(context.lockPath, { recursive: true, force: true });
2761
+ await withPointerDependencies({ token: () => SUCCESSOR_TOKEN }, async () => {
2762
+ await writeSessionStart(cwd, 'sess-successor', { platform: 'win32' });
2763
+ });
2764
+ assert.equal((await readSessionState(cwd))?.session_id, 'sess-successor');
2765
+ assert.equal(existsSync(ownTempPath), true);
2766
+ assert.equal(existsSync(foreignTempPath), true);
2767
+ assert.equal(existsSync(context.lockPath), false);
2768
+ }
2769
+ finally {
2770
+ await rm(cwd, { recursive: true, force: true });
2771
+ }
2772
+ });
2773
+ it('binds an owner alias only on a verified same-native/absent transition and never during replacement', async () => {
2774
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-alias-'));
2775
+ try {
2776
+ await withPointerDependencies({
2777
+ runtimePlatform: 'win32',
2778
+ observeProcess: () => matchingWin32Observation(),
2779
+ }, async () => {
2780
+ const nativeOnly = await reconcileNativeSessionStart(cwd, 'native-first', {
2781
+ platform: 'win32',
2782
+ ownerOmxSessionId: 'omx-owner',
2783
+ });
2784
+ assert.equal(nativeOnly.owner_omx_session_id, undefined);
2785
+ await withOwnerEnvironment('omx-owner', async () => {
2786
+ const bound = await reconcileNativeSessionStart(cwd, 'native-first', {
2787
+ platform: 'win32',
2788
+ ownerAliasVerified: true,
2789
+ });
2790
+ assert.equal(bound.session_id, 'native-first');
2791
+ assert.equal(bound.owner_omx_session_id, 'omx-owner');
2792
+ await assert.rejects(reconcileNativeSessionStart(cwd, 'native-second', {
2793
+ platform: 'win32',
2794
+ ownerAliasVerified: true,
2795
+ }), isOwnerConflict);
2796
+ const persisted = await readSessionState(cwd);
2797
+ assert.equal(persisted?.session_id, 'native-first');
2798
+ assert.equal(persisted?.owner_omx_session_id, 'omx-owner');
2799
+ });
2800
+ });
2801
+ }
2802
+ finally {
2803
+ await rm(cwd, { recursive: true, force: true });
2804
+ }
2805
+ });
2806
+ it('keeps native session owner sidecars isolated and rejects live cross-process reuse', async () => {
2807
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-sidecar-'));
2808
+ try {
2809
+ await withPointerDependencies({
2810
+ runtimePlatform: 'win32',
2811
+ observeProcess: () => matchingWin32Observation(),
2812
+ probePid: () => 'alive',
2813
+ }, async () => {
2814
+ const first = await writeNativeSessionOwner(cwd, 'native-owner-a', { pid: 11, platform: 'win32' });
2815
+ const second = await writeNativeSessionOwner(cwd, 'native-owner-b', { pid: 22, platform: 'win32' });
2816
+ assert.equal(first.pid, 11);
2817
+ assert.equal(second.pid, 22);
2818
+ const firstPath = join(cwd, '.omx', 'state', 'sessions', 'native-owner-a', 'session-owner.json');
2819
+ const secondPath = join(cwd, '.omx', 'state', 'sessions', 'native-owner-b', 'session-owner.json');
2820
+ assert.equal(JSON.parse(await readFile(firstPath, 'utf-8')).pid, 11);
2821
+ assert.equal(JSON.parse(await readFile(secondPath, 'utf-8')).pid, 22);
2822
+ await writeNativeSessionOwner(cwd, 'native-owner-current', {
2823
+ pid: process.pid,
2824
+ platform: 'win32',
2825
+ });
2826
+ assert.equal((await readNativeSessionOwner(cwd, 'native-owner-current'))?.pid, process.pid);
2827
+ await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-a', { pid: 22, platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
2828
+ && error.code === 'session_pointer_owner_conflict');
2829
+ });
2830
+ }
2831
+ finally {
2832
+ await rm(cwd, { recursive: true, force: true });
2833
+ }
2834
+ });
2835
+ it('rejects a symlinked native owner state root', async () => {
2836
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-root-symlink-'));
2837
+ try {
2838
+ const externalState = join(cwd, 'external-state');
2839
+ const ownerDir = join(externalState, 'sessions', 'native-owner-root-link');
2840
+ await mkdir(ownerDir, { recursive: true });
2841
+ await writeFile(join(ownerDir, 'session-owner.json'), JSON.stringify({
2842
+ session_id: 'native-owner-root-link',
2843
+ native_session_id: 'native-owner-root-link',
2844
+ cwd,
2845
+ platform: process.platform,
2846
+ }));
2847
+ await mkdir(join(cwd, '.omx'), { recursive: true });
2848
+ await symlink(externalState, join(cwd, '.omx', 'state'));
2849
+ assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-root-link')).status, 'malformed');
2850
+ }
2851
+ finally {
2852
+ await rm(cwd, { recursive: true, force: true });
2853
+ }
2854
+ });
2855
+ it('rejects symlinked native owner directories and sidecars', async () => {
2856
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-symlink-'));
2857
+ try {
2858
+ const stateDir = join(cwd, '.omx', 'state');
2859
+ const sessionsDir = join(stateDir, 'sessions');
2860
+ const externalDir = join(cwd, 'external-owner');
2861
+ await mkdir(sessionsDir, { recursive: true });
2862
+ await mkdir(externalDir, { recursive: true });
2863
+ await writeFile(join(externalDir, 'session-owner.json'), JSON.stringify({
2864
+ session_id: 'native-owner-dir-link',
2865
+ native_session_id: 'native-owner-dir-link',
2866
+ cwd,
2867
+ platform: process.platform,
2868
+ }));
2869
+ await symlink(externalDir, join(sessionsDir, 'native-owner-dir-link'));
2870
+ assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-dir-link')).status, 'malformed');
2871
+ const fileLinkDir = join(sessionsDir, 'native-owner-file-link');
2872
+ await mkdir(fileLinkDir, { recursive: true });
2873
+ const externalFile = join(cwd, 'external-session-owner.json');
2874
+ await writeFile(externalFile, JSON.stringify({
2875
+ session_id: 'native-owner-file-link',
2876
+ native_session_id: 'native-owner-file-link',
2877
+ cwd,
2878
+ platform: process.platform,
2879
+ }));
2880
+ await symlink(externalFile, join(fileLinkDir, 'session-owner.json'));
2881
+ assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-file-link')).status, 'malformed');
2882
+ const swappedOwnerId = 'native-owner-swapped-dir';
2883
+ const swappedOwnerDir = join(sessionsDir, swappedOwnerId);
2884
+ await mkdir(swappedOwnerDir, { recursive: true });
2885
+ await writeFile(join(swappedOwnerDir, 'session-owner.json'), JSON.stringify({
2886
+ session_id: swappedOwnerId,
2887
+ native_session_id: swappedOwnerId,
2888
+ cwd,
2889
+ platform: process.platform,
2890
+ }));
2891
+ let swappedOwnerLstatCalls = 0;
2892
+ await withPointerDependencies({
2893
+ fs: {
2894
+ lstat: async (path) => {
2895
+ const stat = await lstat(path);
2896
+ const simulateSwap = path === swappedOwnerDir && ++swappedOwnerLstatCalls === 2;
2897
+ return {
2898
+ dev: Number(stat.dev) + (simulateSwap ? 1 : 0),
2899
+ ino: Number(stat.ino),
2900
+ isDirectory: () => stat.isDirectory(),
2901
+ isFile: () => stat.isFile(),
2902
+ isSymbolicLink: () => stat.isSymbolicLink(),
2903
+ };
2904
+ },
2905
+ },
2906
+ }, async () => {
2907
+ assert.equal((await readNativeSessionOwnerEvidence(cwd, swappedOwnerId)).status, 'malformed');
2908
+ });
2909
+ }
2910
+ finally {
2911
+ await rm(cwd, { recursive: true, force: true });
2912
+ }
2913
+ });
2914
+ it('rejects owner sidecars recorded for another platform', async () => {
2915
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-platform-'));
2916
+ try {
2917
+ const ownerDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-platform');
2918
+ await mkdir(ownerDir, { recursive: true });
2919
+ const ownerPath = join(ownerDir, 'session-owner.json');
2920
+ const forgedPlatform = process.platform === 'win32'
2921
+ ? 'darwin'
2922
+ : 'win32';
2923
+ await writeFile(ownerPath, JSON.stringify({
2924
+ session_id: 'native-owner-platform',
2925
+ native_session_id: 'native-owner-platform',
2926
+ started_at: new Date().toISOString(),
2927
+ cwd,
2928
+ pid: process.pid,
2929
+ platform: forgedPlatform,
2930
+ }), 'utf-8');
2931
+ const before = await readFile(ownerPath, 'utf-8');
2932
+ assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-platform')).status, 'identity-indeterminate');
2933
+ assert.equal(await readNativeSessionOwner(cwd, 'native-owner-platform'), null);
2934
+ await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-platform', {
2935
+ pid: process.pid,
2936
+ }), (error) => isSessionPointerLaunchAbort(error)
2937
+ && error.code === 'session_pointer_unusable');
2938
+ assert.equal(await readFile(ownerPath, 'utf-8'), before);
2939
+ }
2940
+ finally {
2941
+ await rm(cwd, { recursive: true, force: true });
2942
+ }
2943
+ });
2944
+ it('replaces only stale-dead owner evidence and preserves malformed evidence', async () => {
2945
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-recovery-'));
2946
+ try {
2947
+ assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-absent')).status, 'absent');
2948
+ await withPointerDependencies({
2949
+ probePid: (pid) => pid === 11 ? 'dead' : 'alive',
2950
+ observeProcess: () => ({ kind: 'identity', identity: { platform: 'linux', birth: '1' } }),
2951
+ }, async () => {
2952
+ await writeNativeSessionOwner(cwd, 'native-owner-recovery', { pid: 11, platform: 'linux' });
2953
+ assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-recovery')).status, 'stale-dead');
2954
+ const recovered = await writeNativeSessionOwner(cwd, 'native-owner-recovery', { pid: 22, platform: 'linux' });
2955
+ assert.equal(recovered.pid, 22);
2956
+ const malformedDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-malformed');
2957
+ await mkdir(malformedDir, { recursive: true });
2958
+ const malformedPath = join(malformedDir, 'session-owner.json');
2959
+ await writeFile(malformedPath, '{ malformed', 'utf-8');
2960
+ assert.equal(await readNativeSessionOwner(cwd, 'native-owner-malformed'), null);
2961
+ assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-malformed')).status, 'malformed');
2962
+ await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-malformed', { pid: 22, platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
2963
+ && error.code === 'session_pointer_unusable'
2964
+ && error.pointerStatus === 'malformed');
2965
+ const forgedDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-forged');
2966
+ await mkdir(forgedDir, { recursive: true });
2967
+ const forgedPath = join(forgedDir, 'session-owner.json');
2968
+ await writeFile(forgedPath, JSON.stringify({
2969
+ session_id: 'native-owner-other',
2970
+ native_session_id: 'native-owner-other',
2971
+ started_at: new Date().toISOString(),
2972
+ cwd,
2973
+ pid: 22,
2974
+ platform: 'linux',
2975
+ pid_start_ticks: 1,
2976
+ }), 'utf-8');
2977
+ const forgedBefore = await readFile(forgedPath, 'utf-8');
2978
+ assert.equal(await readNativeSessionOwner(cwd, 'native-owner-forged'), null);
2979
+ await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-forged', { pid: 22, platform: 'linux' }), (error) => isSessionPointerLaunchAbort(error)
2980
+ && error.code === 'session_pointer_owner_conflict');
2981
+ assert.equal(await readFile(forgedPath, 'utf-8'), forgedBefore);
2982
+ const missingCwdDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-missing-cwd');
2983
+ await mkdir(missingCwdDir, { recursive: true });
2984
+ const missingCwdPath = join(missingCwdDir, 'session-owner.json');
2985
+ await writeFile(missingCwdPath, JSON.stringify({
2986
+ session_id: 'native-owner-missing-cwd',
2987
+ native_session_id: 'native-owner-missing-cwd',
2988
+ started_at: new Date().toISOString(),
2989
+ pid: 22,
2990
+ platform: 'linux',
2991
+ pid_start_ticks: 1,
2992
+ }), 'utf-8');
2993
+ const missingCwdBefore = await readFile(missingCwdPath, 'utf-8');
2994
+ assert.equal(await readNativeSessionOwner(cwd, 'native-owner-missing-cwd'), null);
2995
+ await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-missing-cwd', { pid: 22, platform: 'linux' }), (error) => isSessionPointerLaunchAbort(error)
2996
+ && error.code === 'session_pointer_owner_conflict');
2997
+ assert.equal(await readFile(missingCwdPath, 'utf-8'), missingCwdBefore);
2998
+ });
2999
+ }
3000
+ finally {
3001
+ await rm(cwd, { recursive: true, force: true });
3002
+ }
3003
+ });
3004
+ it('rejects cross-process native reconciliation while preserving the live selected pointer', async () => {
3005
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-cross-process-selected-'));
3006
+ try {
3007
+ await withPointerDependencies({
3008
+ runtimePlatform: 'win32',
3009
+ observeProcess: () => matchingWin32Observation(),
3010
+ probePid: () => 'alive',
3011
+ }, async () => {
3012
+ await writeSessionStart(cwd, 'native-selected-a', {
3013
+ nativeSessionId: 'native-selected-a',
3014
+ pid: 11,
3015
+ platform: 'win32',
3016
+ });
3017
+ const context = resolveSessionPointerContext(cwd);
3018
+ const before = await readFile(context.sessionPath, 'utf-8');
3019
+ await assert.rejects(reconcileNativeSessionStart(cwd, 'native-selected-b', { pid: 22, platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
3020
+ && error.code === 'session_pointer_owner_conflict');
3021
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), before);
3022
+ });
3023
+ }
3024
+ finally {
3025
+ await rm(cwd, { recursive: true, force: true });
3026
+ }
3027
+ });
3028
+ it('preserves pointer evidence when history cannot be appended and rejects unusable end states before history or HUD cleanup', async () => {
3029
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-preserve-'));
3030
+ try {
3031
+ await withPointerDependencies({
3032
+ runtimePlatform: 'win32',
3033
+ observeProcess: () => matchingWin32Observation(),
3034
+ }, async () => {
3035
+ const context = resolveSessionPointerContext(cwd);
3036
+ const established = await establishLaunchSessionBinding(cwd, 'sess-history', { platform: 'win32' });
3037
+ assert.equal(established.kind, 'committed-released');
3038
+ if (established.kind !== 'committed-released')
3039
+ return;
3040
+ await mkdir(join(cwd, '.omx', 'logs'), { recursive: true });
3041
+ await rm(join(cwd, '.omx', 'logs'), { recursive: true, force: true });
3042
+ await writeFile(join(cwd, '.omx', 'logs'), 'not-a-directory', 'utf-8');
3043
+ await assert.rejects(finalizeBoundOnce(established.binding, 'history-failure'));
3044
+ assert.equal((await readSessionState(cwd))?.session_id, 'sess-history');
3045
+ await rm(join(cwd, '.omx', 'logs'), { force: true });
3046
+ await writeFile(context.sessionPath, '{ malformed', 'utf-8');
3047
+ await assert.rejects(writeSessionEnd(cwd, 'sess-history', { binding: established.binding }), (error) => isSessionPointerLaunchAbort(error)
3048
+ && error.code === 'session_pointer_unusable'
3049
+ && error.pointerStatus === 'malformed');
3050
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), '{ malformed');
3051
+ assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
3052
+ });
3053
+ }
3054
+ finally {
3055
+ await rm(cwd, { recursive: true, force: true });
3056
+ }
3057
+ });
3058
+ });
3059
+ describe('bound launch authority', () => {
3060
+ it('returns one private binding and only capability-free metadata updates', async () => {
3061
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-authority-'));
3062
+ try {
3063
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-authority');
3064
+ assert.equal(established.kind, 'committed-released');
3065
+ if (established.kind !== 'committed-released')
3066
+ return;
3067
+ assert.match(established.binding.launchLineageToken, /^[A-Za-z0-9_-]{16,128}$/);
3068
+ assert.equal('handle' in established.binding, false);
3069
+ const metadata = await updateDetachedSessionMetadata(established.binding, {
3070
+ tmuxSessionName: 'omx-authority', tmuxPaneId: '%42',
3071
+ });
3072
+ assert.equal(metadata.kind, 'committed-released');
3073
+ assert.equal((await readSessionState(cwd))?.launch_lineage_token, established.binding.launchLineageToken);
3074
+ const firstFinalization = finalizeBoundOnce(established.binding, 'test');
3075
+ const secondFinalization = finalizeBoundOnce(established.binding, 'duplicate');
3076
+ assert.equal(firstFinalization, secondFinalization);
3077
+ const finalized = await firstFinalization;
3078
+ assert.equal(finalized.finalized, true);
3079
+ assert.equal(finalized.cleanup.comparison?.status, 'matched');
3080
+ assert.equal(existsSync(resolveSessionPointerContext(cwd).sessionPath), false);
3081
+ assert.equal((await closeLaunchSessionBindingOnce(established.binding)).status, 'closed');
3082
+ assert.equal((await closeLaunchSessionBindingOnce(established.binding)).status, 'closed');
3083
+ }
3084
+ finally {
3085
+ await rm(cwd, { recursive: true, force: true });
3086
+ }
3087
+ });
3088
+ it('preserves process_identity across detached metadata updates', async (t) => {
3089
+ if (process.platform !== 'linux') {
3090
+ t.skip('Linux v2 process identity publication semantics are under test.');
3091
+ return;
3092
+ }
3093
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-identity-preserve-'));
3094
+ let binding;
3095
+ try {
3096
+ await withPointerDependencies({
3097
+ runtimePlatform: 'linux',
3098
+ observeProcess: () => matchingObservation('linux'),
3099
+ }, async () => {
3100
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-identity-preserve', { platform: 'linux' });
3101
+ assert.equal(established.kind, 'committed-released');
3102
+ if (established.kind !== 'committed-released')
3103
+ return;
3104
+ const establishedBinding = established.binding;
3105
+ binding = establishedBinding;
3106
+ const before = await readSessionState(cwd);
3107
+ assert.ok(before?.process_identity);
3108
+ const metadata = await updateDetachedSessionMetadata(establishedBinding, { tmuxPaneId: '%identity-preserved' });
3109
+ assert.equal(metadata.kind, 'committed-released');
3110
+ const after = await readSessionState(cwd);
3111
+ assert.deepEqual(after?.process_identity, before?.process_identity);
3112
+ assert.equal(after?.identity_schema_version, 2);
3113
+ assert.equal(after?.tmux_pane_id, '%identity-preserved');
3114
+ assert.equal((await finalizeBoundOnce(establishedBinding, 'identity-preserved')).finalized, true);
3115
+ });
3116
+ }
3117
+ finally {
3118
+ if (binding)
3119
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3120
+ await rm(cwd, { recursive: true, force: true });
3121
+ }
3122
+ });
3123
+ it('anchors capability acquisition and finalization to context.baseStateDir rather than cwd', async () => {
3124
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-selected-root-'));
3125
+ let binding;
3126
+ try {
3127
+ const baseStateDir = join(cwd, 'selected', 'state');
3128
+ const context = {
3129
+ cwd,
3130
+ baseStateDir,
3131
+ rootSource: 'cwd-default',
3132
+ sessionPath: join(baseStateDir, 'session.json'),
3133
+ lockPath: join(baseStateDir, 'session.json.lock'),
3134
+ };
3135
+ const established = await establishLaunchSessionBinding(cwd, 'sess-selected-root', { context });
3136
+ assert.equal(established.kind, 'committed-released');
3137
+ if (established.kind !== 'committed-released')
3138
+ return;
3139
+ binding = established.binding;
3140
+ assert.equal(binding.context.baseStateDir, baseStateDir);
3141
+ assert.equal(existsSync(context.sessionPath), true);
3142
+ assert.equal(existsSync(join(cwd, '.omx', 'state', 'session.json')), false);
3143
+ assert.equal((await finalizeBoundOnce(binding, 'selected-root')).finalized, true);
3144
+ assert.equal(existsSync(context.sessionPath), false);
3145
+ }
3146
+ finally {
3147
+ if (binding)
3148
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3149
+ await rm(cwd, { recursive: true, force: true });
3150
+ }
3151
+ });
3152
+ it('rejects forged and changed-incarnation detached metadata before lifecycle mutation', async () => {
3153
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-metadata-hostile-'));
3154
+ let binding;
3155
+ try {
3156
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-metadata-hostile');
3157
+ assert.equal(established.kind, 'committed-released');
3158
+ if (established.kind !== 'committed-released')
3159
+ return;
3160
+ binding = established.binding;
3161
+ const context = resolveSessionPointerContext(cwd);
3162
+ const original = await readFile(context.sessionPath, 'utf-8');
3163
+ let mkdirCalls = 0;
3164
+ await withPointerDependencies({
3165
+ fs: { mkdir: async (path, options) => { mkdirCalls += 1; await mkdir(path, options); } },
3166
+ }, async () => {
3167
+ const forged = await updateDetachedSessionMetadata({ ...binding }, { tmuxPaneId: '%forged' });
3168
+ assert.equal(forged.kind, 'precommit-aborted');
3169
+ });
3170
+ assert.equal(mkdirCalls, 0);
3171
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), original);
3172
+ assert.equal(existsSync(context.lockPath), false);
3173
+ const parsed = JSON.parse(original);
3174
+ const state = { ...parsed, session_id: binding.canonicalSessionId };
3175
+ const changed = JSON.stringify({ ...state, started_at: '1999-01-01T00:00:00.000Z' }, null, 2);
3176
+ await writeFile(context.sessionPath, changed, 'utf-8');
3177
+ const denied = await updateDetachedSessionMetadata(binding, { tmuxPaneId: '%changed' });
3178
+ assert.equal(denied.kind, 'precommit-aborted');
3179
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), changed);
3180
+ assert.equal(existsSync(context.lockPath), false);
3181
+ }
3182
+ finally {
3183
+ if (binding)
3184
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3185
+ await rm(cwd, { recursive: true, force: true });
3186
+ }
3187
+ });
3188
+ it('rechecks supported directory identity under the metadata lock before pointer mutation', async () => {
3189
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-metadata-race-'));
3190
+ const retainedCwd = `${cwd}-retained`;
3191
+ let binding;
3192
+ try {
3193
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-metadata-race');
3194
+ assert.equal(established.kind, 'committed-released');
3195
+ if (established.kind !== 'committed-released')
3196
+ return;
3197
+ binding = established.binding;
3198
+ const context = resolveSessionPointerContext(cwd);
3199
+ const original = await readFile(context.sessionPath, 'utf-8');
3200
+ let replaced = false;
3201
+ let result;
3202
+ await withPointerDependencies({
3203
+ fs: {
3204
+ mkdir: async (path, options) => {
3205
+ if (!replaced && String(path) === context.baseStateDir) {
3206
+ replaced = true;
3207
+ await rename(cwd, retainedCwd);
3208
+ await mkdir(context.baseStateDir, { recursive: true });
3209
+ await writeFile(context.sessionPath, original, 'utf-8');
3210
+ return;
3211
+ }
3212
+ await mkdir(path, options);
3213
+ },
3214
+ },
3215
+ }, async () => { result = await updateDetachedSessionMetadata(binding, { tmuxPaneId: '%race' }); });
3216
+ assert.equal(result?.kind, 'precommit-aborted');
3217
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), original);
3218
+ assert.equal(await readFile(join(retainedCwd, '.omx', 'state', 'session.json'), 'utf-8'), original);
3219
+ assert.equal(existsSync(context.lockPath), false);
3220
+ }
3221
+ finally {
3222
+ if (binding)
3223
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3224
+ await rm(cwd, { recursive: true, force: true });
3225
+ await rm(retainedCwd, { recursive: true, force: true });
3226
+ }
3227
+ });
3228
+ it('permits an ordinary absent launch when directory identity is unsupported', async () => {
3229
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-unsupported-'));
3230
+ let binding;
3231
+ try {
3232
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-unsupported', { platform: 'win32' });
3233
+ assert.equal(established.kind, 'committed-released');
3234
+ if (established.kind !== 'committed-released')
3235
+ return;
3236
+ binding = established.binding;
3237
+ assert.deepEqual(binding.directoryIdentity, { kind: 'unsupported', reason: 'platform' });
3238
+ assert.equal((await readSessionState(cwd))?.session_id, 'sess-binding-unsupported');
3239
+ }
3240
+ finally {
3241
+ if (binding)
3242
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3243
+ await rm(cwd, { recursive: true, force: true });
3244
+ }
3245
+ });
3246
+ it('establishment preflight is absent-only under the selected pointer lock and preserves every existing pointer byte', async () => {
3247
+ const root = await mkdtemp(join(tmpdir(), 'omx-session-binding-preflight-'));
3248
+ try {
3249
+ const cases = [
3250
+ { name: 'usable', raw: JSON.stringify(makeState({ cwd: join(root, 'usable'), pid: process.pid, platform: 'linux', pid_start_ticks: 1 })) },
3251
+ { name: 'stale-dead', raw: JSON.stringify(makeState({ cwd: join(root, 'stale-dead'), pid: 424242, platform: 'linux', pid_start_ticks: 1 })), probePid: 'dead' },
3252
+ { name: 'malformed', raw: '{ malformed pointer' },
3253
+ { name: 'foreign', raw: JSON.stringify(makeState({ cwd: join(root, 'foreign', 'other'), platform: 'win32' })) },
3254
+ { name: 'indeterminate', raw: JSON.stringify(makeState({ cwd: join(root, 'indeterminate'), pid: 424242, platform: 'linux', pid_start_ticks: 1 })), probePid: 'indeterminate' },
3255
+ ];
3256
+ for (const testCase of cases) {
3257
+ const cwd = join(root, testCase.name);
3258
+ const context = resolveSessionPointerContext(cwd);
3259
+ const order = [];
3260
+ await mkdir(context.baseStateDir, { recursive: true });
3261
+ await writeFile(context.sessionPath, testCase.raw, 'utf-8');
3262
+ await withPointerDependencies({
3263
+ probePid: () => testCase.probePid ?? 'alive',
3264
+ observeProcess: () => matchingObservation(),
1124
3265
  fs: {
1125
- rename: async (from, to) => {
1126
- if (from === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
1127
- throw new Error('owner rename failure');
1128
- await rename(from, to);
1129
- },
3266
+ mkdir: async (path, options) => { order.push(`mkdir:${path}`); await mkdir(path, options); },
3267
+ readFile: async (path, encoding) => { order.push(`read:${path}`); return await readFile(path, encoding); },
3268
+ rename: async (from, to) => { order.push(`rename:${from}:${to}`); await rename(from, to); },
3269
+ unlink: async (path) => { order.push(`unlink:${path}`); await rm(path, { force: false }); },
1130
3270
  },
1131
- }),
1132
- },
1133
- ];
1134
- for (const failure of failures) {
1135
- const cwd = await mkdtemp(join(tmpdir(), `omx-session-${failure.name}-`));
1136
- try {
1137
- const context = resolveSessionPointerContext(cwd);
1138
- await withPointerDependencies(failure.dependencies(context), async () => {
1139
- await assert.rejects(writeSessionStart(cwd, 'sess-precommit', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
1140
- && error.code === 'session_pointer_io_failure'
1141
- && error.operation === failure.operation);
3271
+ }, async () => {
3272
+ const established = await establishLaunchSessionBinding(cwd, `sess-preflight-${testCase.name}`);
3273
+ assert.equal(established.kind, 'precommit-aborted', testCase.name);
1142
3274
  });
1143
- assert.equal(existsSync(context.lockPath), false);
1144
- }
1145
- finally {
1146
- await rm(cwd, { recursive: true, force: true });
3275
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), testCase.raw, testCase.name);
3276
+ assert.ok(order.indexOf(`mkdir:${context.lockPath}`) < order.indexOf(`read:${context.sessionPath}`), testCase.name);
3277
+ assert.equal(order.filter((entry) => entry === `rename:${context.sessionPath}`).length, 0, testCase.name);
3278
+ assert.equal(order.filter((entry) => entry === `unlink:${context.sessionPath}`).length, 0, testCase.name);
3279
+ assert.equal(existsSync(context.lockPath), false, testCase.name);
1147
3280
  }
1148
3281
  }
1149
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-invalid-token-'));
3282
+ finally {
3283
+ await rm(root, { recursive: true, force: true });
3284
+ }
3285
+ });
3286
+ it('denies an absent bound pointer without creating synthetic terminal history', async () => {
3287
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-absent-finalization-'));
3288
+ let binding;
1150
3289
  try {
3290
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-absent-finalization');
3291
+ assert.equal(established.kind, 'committed-released');
3292
+ if (established.kind !== 'committed-released')
3293
+ return;
3294
+ binding = established.binding;
1151
3295
  const context = resolveSessionPointerContext(cwd);
1152
- await withPointerDependencies({ token: () => 'bad' }, async () => {
1153
- await assert.rejects(writeSessionStart(cwd, 'sess-invalid-token', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
1154
- && error.code === 'session_pointer_io_failure'
1155
- && error.operation === 'lock-owner-publish');
1156
- });
3296
+ await rm(context.sessionPath);
3297
+ const finalized = await finalizeBoundOnce(binding, 'absent');
3298
+ assert.equal(finalized.finalized, false);
3299
+ assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
3300
+ assert.equal(existsSync(join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`)), true);
3301
+ }
3302
+ finally {
3303
+ if (binding)
3304
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3305
+ await rm(cwd, { recursive: true, force: true });
3306
+ }
3307
+ });
3308
+ it('rejects unbound usable finalization before changing any lifecycle bytes', async () => {
3309
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-unbound-'));
3310
+ let binding;
3311
+ try {
3312
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-unbound');
3313
+ assert.equal(established.kind, 'committed-released');
3314
+ if (established.kind !== 'committed-released')
3315
+ return;
3316
+ binding = established.binding;
3317
+ const context = resolveSessionPointerContext(cwd);
3318
+ const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
3319
+ const dailyPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
3320
+ const hudPath = join(cwd, '.omx', 'state', 'hud-state.json');
3321
+ const metricsPath = join(cwd, '.omx', 'metrics.json');
3322
+ await writeFile(historyPath, 'history-sentinel\n', 'utf-8');
3323
+ await writeFile(hudPath, 'hud-sentinel\n', 'utf-8');
3324
+ await writeFile(metricsPath, 'metrics-sentinel\n', 'utf-8');
3325
+ const before = {
3326
+ pointer: await readFile(context.sessionPath, 'utf-8'),
3327
+ history: await readFile(historyPath, 'utf-8'),
3328
+ daily: await readFile(dailyPath, 'utf-8'),
3329
+ hud: await readFile(hudPath, 'utf-8'),
3330
+ metrics: await readFile(metricsPath, 'utf-8'),
3331
+ };
3332
+ await assert.rejects(() => writeSessionEnd(cwd, 'sess-binding-unbound'), isSessionPointerLaunchAbort);
3333
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), before.pointer);
3334
+ assert.equal(await readFile(historyPath, 'utf-8'), before.history);
3335
+ assert.equal(await readFile(dailyPath, 'utf-8'), before.daily);
3336
+ assert.equal(await readFile(hudPath, 'utf-8'), before.hud);
3337
+ assert.equal(await readFile(metricsPath, 'utf-8'), before.metrics);
1157
3338
  assert.equal(existsSync(context.lockPath), false);
1158
- assert.equal(existsSync(`${context.sessionPath}.tmp-bad`), false);
1159
3339
  }
1160
3340
  finally {
3341
+ if (binding)
3342
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
1161
3343
  await rm(cwd, { recursive: true, force: true });
1162
3344
  }
1163
3345
  });
1164
- it('types pointer write, sync, and rename failures and removes only the owned temporary file', async () => {
1165
- const phases = [
1166
- { operation: 'pointer-temp-write', fail: 'write' },
1167
- { operation: 'pointer-fsync', fail: 'sync' },
1168
- { operation: 'pointer-rename', fail: 'rename' },
1169
- ];
1170
- for (const phase of phases) {
1171
- const cwd = await mkdtemp(join(tmpdir(), `omx-session-${phase.fail}-`));
1172
- try {
1173
- const context = resolveSessionPointerContext(cwd);
1174
- const tempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
3346
+ it('preserves a compatible tokenless pointer and refuses to establish cleanup authority from it', async () => {
3347
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-tokenless-'));
3348
+ try {
3349
+ const context = resolveSessionPointerContext(cwd);
3350
+ await reconcileNativeSessionStart(cwd, 'native-compatible-tokenless');
3351
+ const beforeUpdate = await readFile(context.sessionPath, 'utf-8');
3352
+ await assert.rejects(() => writeSessionStart(cwd, 'native-compatible-tokenless', { tmuxPaneId: '%42' }), isSessionPointerLaunchAbort);
3353
+ assert.equal((await readSessionState(cwd))?.launch_lineage_token, undefined);
3354
+ const afterUpdate = await readFile(context.sessionPath, 'utf-8');
3355
+ assert.equal(afterUpdate, beforeUpdate);
3356
+ const established = await establishLaunchSessionBinding(cwd, 'native-compatible-tokenless');
3357
+ assert.equal(established.kind, 'precommit-aborted');
3358
+ if (established.kind !== 'precommit-aborted')
3359
+ return;
3360
+ assert.equal(established.abort.committed, false);
3361
+ assert.equal(established.abort.code, 'session_pointer_owner_conflict');
3362
+ assert.equal((await readSessionState(cwd))?.launch_lineage_token, undefined);
3363
+ assert.equal(afterUpdate.includes('launch_lineage_token'), false);
3364
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), afterUpdate);
3365
+ }
3366
+ finally {
3367
+ await rm(cwd, { recursive: true, force: true });
3368
+ }
3369
+ });
3370
+ it('preserves a compatible malformed lineage token without granting cleanup authority', async () => {
3371
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-malformed-token-'));
3372
+ try {
3373
+ const context = resolveSessionPointerContext(cwd);
3374
+ const tokenless = await reconcileNativeSessionStart(cwd, 'native-compatible-malformed-token');
3375
+ await writeFile(context.sessionPath, JSON.stringify({ ...tokenless, launch_lineage_token: 'bad' }), 'utf-8');
3376
+ const beforeUpdate = await readFile(context.sessionPath, 'utf-8');
3377
+ await assert.rejects(() => writeSessionStart(cwd, 'native-compatible-malformed-token', { tmuxPaneId: '%42' }), isSessionPointerLaunchAbort);
3378
+ const afterUpdate = await readFile(context.sessionPath, 'utf-8');
3379
+ assert.equal(afterUpdate, beforeUpdate);
3380
+ assert.match(afterUpdate, /"launch_lineage_token":"bad"|"launch_lineage_token": "bad"/);
3381
+ assert.equal((await reconcileNativeSessionStart(cwd, 'native-compatible-malformed-token')).launch_lineage_token, 'bad');
3382
+ const beforeEstablishment = await readFile(context.sessionPath, 'utf-8');
3383
+ const established = await establishLaunchSessionBinding(cwd, 'native-compatible-malformed-token');
3384
+ assert.equal(established.kind, 'precommit-aborted');
3385
+ if (established.kind !== 'precommit-aborted')
3386
+ return;
3387
+ assert.equal(established.abort.committed, false);
3388
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), beforeEstablishment);
3389
+ }
3390
+ finally {
3391
+ await rm(cwd, { recursive: true, force: true });
3392
+ }
3393
+ });
3394
+ it('preserves malformed pointer bytes when binding establishment aborts before commit', async () => {
3395
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-malformed-'));
3396
+ try {
3397
+ const context = resolveSessionPointerContext(cwd);
3398
+ const malformed = '{ malformed pointer';
3399
+ await mkdir(context.baseStateDir, { recursive: true });
3400
+ await writeFile(context.sessionPath, malformed, 'utf-8');
3401
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-malformed');
3402
+ assert.equal(established.kind, 'precommit-aborted');
3403
+ if (established.kind !== 'precommit-aborted')
3404
+ return;
3405
+ assert.equal(established.abort.committed, false);
3406
+ assert.equal(established.abort.code, 'session_pointer_unusable');
3407
+ assert.equal(established.abort.pointerStatus, 'malformed');
3408
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), malformed);
3409
+ }
3410
+ finally {
3411
+ await rm(cwd, { recursive: true, force: true });
3412
+ }
3413
+ });
3414
+ it('finalizes only its exact stale-dead native pointer after history is retained', async () => {
3415
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-stale-dead-'));
3416
+ try {
3417
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-stale-dead');
3418
+ assert.equal(established.kind, 'committed-released');
3419
+ if (established.kind !== 'committed-released')
3420
+ return;
3421
+ await reconcileNativeSessionStart(cwd, 'native-binding-stale-dead', { pid: 424242 });
3422
+ await withPointerDependencies({ probePid: () => 'dead' }, async () => {
3423
+ const finalized = await finalizeBoundOnce(established.binding, 'test');
3424
+ assert.equal(finalized.finalized, true);
3425
+ });
3426
+ await closeLaunchSessionBindingOnce(established.binding);
3427
+ assert.equal(existsSync(resolveSessionPointerContext(cwd).sessionPath), false);
3428
+ const history = await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8');
3429
+ assert.match(history, /native-binding-stale-dead/);
3430
+ }
3431
+ finally {
3432
+ await rm(cwd, { recursive: true, force: true });
3433
+ }
3434
+ });
3435
+ it('preserves a stale-dead pointer whose bound lineage token does not match', async () => {
3436
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-stale-mismatch-'));
3437
+ let binding;
3438
+ try {
3439
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-stale-mismatch');
3440
+ assert.equal(established.kind, 'committed-released');
3441
+ if (established.kind !== 'committed-released')
3442
+ return;
3443
+ binding = established.binding;
3444
+ const context = resolveSessionPointerContext(cwd);
3445
+ const state = await reconcileNativeSessionStart(cwd, 'native-binding-stale-mismatch', { pid: 424242 });
3446
+ await writeFile(context.sessionPath, JSON.stringify({ ...state, launch_lineage_token: FOREIGN_TOKEN }), 'utf-8');
3447
+ await withPointerDependencies({ probePid: () => 'dead' }, async () => {
3448
+ const denied = await finalizeBoundOnce(binding, 'test');
3449
+ assert.equal(denied.finalized, false);
3450
+ });
3451
+ assert.equal(existsSync(context.sessionPath), true);
3452
+ assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
3453
+ }
3454
+ finally {
3455
+ if (binding)
3456
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3457
+ await rm(cwd, { recursive: true, force: true });
3458
+ }
3459
+ });
3460
+ it('finalizes an exact bound current process when identity observation is unavailable', async () => {
3461
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-indeterminate-current-'));
3462
+ let binding;
3463
+ try {
3464
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-indeterminate-current');
3465
+ assert.equal(established.kind, 'committed-released');
3466
+ if (established.kind !== 'committed-released')
3467
+ return;
3468
+ binding = established.binding;
3469
+ await withPointerDependencies({
3470
+ probePid: () => 'indeterminate',
3471
+ observeProcess: () => ({ kind: 'error' }),
3472
+ }, async () => {
3473
+ await writeSessionEnd(cwd, binding.canonicalSessionId, { binding });
3474
+ });
3475
+ assert.equal(await readSessionState(cwd), null);
3476
+ }
3477
+ finally {
3478
+ if (binding)
3479
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3480
+ await rm(cwd, { recursive: true, force: true });
3481
+ }
3482
+ });
3483
+ it('preserves usable and stale bound pointers without the exact valid lineage token before any mutation', async () => {
3484
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-token-gate-'));
3485
+ let binding;
3486
+ try {
3487
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-token-gate');
3488
+ assert.equal(established.kind, 'committed-released');
3489
+ if (established.kind !== 'committed-released')
3490
+ return;
3491
+ binding = established.binding;
3492
+ const context = resolveSessionPointerContext(cwd);
3493
+ const state = await readSessionState(cwd);
3494
+ assert.ok(state);
3495
+ const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
3496
+ const dailyPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
3497
+ const hudPath = join(cwd, '.omx', 'state', 'hud-state.json');
3498
+ const metricsPath = join(cwd, '.omx', 'metrics.json');
3499
+ await writeFile(historyPath, 'history-sentinel\n', 'utf-8');
3500
+ await writeFile(hudPath, 'hud-sentinel\n', 'utf-8');
3501
+ await writeFile(metricsPath, 'metrics-sentinel\n', 'utf-8');
3502
+ const lifecycleBytes = {
3503
+ history: await readFile(historyPath, 'utf-8'),
3504
+ daily: await readFile(dailyPath, 'utf-8'),
3505
+ hud: await readFile(hudPath, 'utf-8'),
3506
+ metrics: await readFile(metricsPath, 'utf-8'),
3507
+ };
3508
+ const cases = [
3509
+ { name: 'tokenless usable', state: (() => { const { launch_lineage_token: _, ...tokenless } = state; return tokenless; })() },
3510
+ { name: 'malformed usable', state: { ...state, launch_lineage_token: 'bad' } },
3511
+ { name: 'mismatched usable', state: { ...state, launch_lineage_token: FOREIGN_TOKEN } },
3512
+ { name: 'replaced usable', state: { ...state, launch_lineage_token: SUCCESSOR_TOKEN } },
3513
+ { name: 'same-token changed incarnation', state: { ...state, started_at: '1999-01-01T00:00:00.000Z' } },
3514
+ { name: 'unknown identity', state: { ...state, pid: 424242 }, probePid: 'indeterminate' },
3515
+ {
3516
+ name: 'tokenless stale native',
3517
+ state: (() => {
3518
+ const { launch_lineage_token: _, ...tokenless } = state;
3519
+ return { ...tokenless, native_session_id: 'native-tokenless-stale', pid: 424242 };
3520
+ })(),
3521
+ probePid: 'dead',
3522
+ },
3523
+ ];
3524
+ for (const testCase of cases) {
3525
+ const raw = JSON.stringify(testCase.state);
3526
+ let mkdirCalls = 0;
3527
+ let unlinkCalls = 0;
3528
+ await writeFile(context.sessionPath, raw, 'utf-8');
1175
3529
  await withPointerDependencies({
1176
- token: () => TEST_TOKEN,
3530
+ probePid: () => testCase.probePid ?? 'alive',
1177
3531
  fs: {
1178
- writeFile: async (path, data, options) => {
1179
- await writeFile(path, data, options);
1180
- if (phase.fail === 'write' && path === tempPath)
1181
- throw new Error('pointer write failure');
1182
- },
1183
- openAndSync: async (path) => {
1184
- if (phase.fail === 'sync' && path === tempPath)
1185
- throw new Error('pointer sync failure');
1186
- return 'synced';
1187
- },
1188
- rename: async (from, to) => {
1189
- if (phase.fail === 'rename' && from === tempPath && to === context.sessionPath) {
1190
- throw new Error('pointer rename failure');
1191
- }
1192
- await rename(from, to);
1193
- },
3532
+ mkdir: async (path, options) => { mkdirCalls += 1; await mkdir(path, options); },
3533
+ unlink: async (path) => { if (path === context.sessionPath)
3534
+ unlinkCalls += 1; await rm(path, { force: false }); },
1194
3535
  },
1195
3536
  }, async () => {
1196
- await assert.rejects(writeSessionStart(cwd, 'sess-failure', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
1197
- && error.code === 'session_pointer_io_failure'
1198
- && error.operation === phase.operation);
3537
+ await assert.rejects(writeSessionEnd(cwd, binding.canonicalSessionId, { binding }), isSessionPointerLaunchAbort);
1199
3538
  });
1200
- assert.equal(existsSync(tempPath), false);
1201
- assert.equal(existsSync(context.lockPath), false);
1202
- }
1203
- finally {
1204
- await rm(cwd, { recursive: true, force: true });
3539
+ assert.equal(await readFile(context.sessionPath, 'utf-8'), raw, testCase.name);
3540
+ assert.ok(mkdirCalls <= 1, testCase.name);
3541
+ assert.equal(unlinkCalls, 0, testCase.name);
3542
+ assert.equal(existsSync(context.lockPath), false, testCase.name);
3543
+ assert.equal(await readFile(historyPath, 'utf-8'), lifecycleBytes.history, testCase.name);
3544
+ assert.equal(await readFile(dailyPath, 'utf-8'), lifecycleBytes.daily, testCase.name);
3545
+ assert.equal(await readFile(hudPath, 'utf-8'), lifecycleBytes.hud, testCase.name);
3546
+ assert.equal(await readFile(metricsPath, 'utf-8'), lifecycleBytes.metrics, testCase.name);
1205
3547
  }
1206
3548
  }
3549
+ finally {
3550
+ if (binding)
3551
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3552
+ await rm(cwd, { recursive: true, force: true });
3553
+ }
1207
3554
  });
1208
- it('preserves owned pointer residue and ordered cleanup evidence when cleanup or release cannot complete', async () => {
1209
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-residue-'));
3555
+ it('denies selected-root replacement under lock before pointer publication', async () => {
3556
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-establish-root-race-'));
3557
+ const displacedRoot = join(cwd, 'displaced-state');
1210
3558
  try {
1211
3559
  const context = resolveSessionPointerContext(cwd);
1212
- const tempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
3560
+ let replaced = false;
3561
+ let established;
1213
3562
  await withPointerDependencies({
1214
- token: () => TEST_TOKEN,
1215
3563
  fs: {
1216
- writeFile: async (path, data, options) => {
1217
- await writeFile(path, data, options);
1218
- if (path === tempPath)
1219
- throw new Error('pointer write failure');
1220
- },
1221
- unlink: async (path) => {
1222
- if (path === tempPath)
1223
- throw new Error('pointer temp cleanup failure');
1224
- await rm(path, { force: false });
1225
- },
1226
- rmdir: async (path) => {
1227
- if (path.endsWith(`.release-${TEST_TOKEN}`))
1228
- throw new Error('release cleanup failure');
1229
- await rm(path, { recursive: false });
3564
+ openAndSync: async (path) => {
3565
+ if (!replaced && path.startsWith(`${context.sessionPath}.tmp-`)) {
3566
+ replaced = true;
3567
+ await rename(context.baseStateDir, displacedRoot);
3568
+ await mkdir(context.baseStateDir, { recursive: true });
3569
+ }
3570
+ return 'synced';
1230
3571
  },
1231
3572
  },
1232
- }, async () => {
1233
- await assert.rejects(writeSessionStart(cwd, 'sess-residue', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
1234
- && error.code === 'session_pointer_lock_recovery_required'
1235
- && error.primaryOperation === 'pointer-temp-write'
1236
- && error.secondaryFailures?.map((failure) => failure.phase).join(',') === 'remove-pointer-temp,remove-release-dir'
1237
- && error.secondaryFailures?.[0]?.evidencePath === tempPath);
1238
- });
1239
- assert.equal(existsSync(tempPath), true);
1240
- assert.equal(existsSync(`${context.lockPath}.release-${TEST_TOKEN}`), true);
3573
+ }, async () => { established = await establishLaunchSessionBinding(cwd, 'sess-establish-root-race'); });
3574
+ assert.ok(established);
3575
+ assert.notEqual(established.kind, 'committed-released');
3576
+ assert.equal(existsSync(context.sessionPath), false);
3577
+ assert.equal(existsSync(join(displacedRoot, 'session.json')), false);
1241
3578
  }
1242
3579
  finally {
1243
3580
  await rm(cwd, { recursive: true, force: true });
1244
3581
  }
1245
3582
  });
1246
- it('leaves foreign pointer temps inert and a successor commits after explicit lock recovery', async () => {
1247
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-successor-'));
3583
+ it('returns committed blocked evidence when selected root changes after pointer rename', async () => {
3584
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-establish-post-rename-race-'));
3585
+ const displacedRoot = join(cwd, 'displaced-after-rename');
1248
3586
  try {
1249
3587
  const context = resolveSessionPointerContext(cwd);
1250
- const ownTempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
1251
- const foreignTempPath = `${context.sessionPath}.tmp-${FOREIGN_TOKEN}`;
1252
- await mkdir(context.baseStateDir, { recursive: true });
1253
- await writeFile(foreignTempPath, 'foreign transaction evidence', 'utf-8');
3588
+ let replaced = false;
3589
+ let established;
1254
3590
  await withPointerDependencies({
1255
- token: () => TEST_TOKEN,
1256
3591
  fs: {
1257
- writeFile: async (path, data, options) => {
1258
- await writeFile(path, data, options);
1259
- if (path === ownTempPath)
1260
- throw new Error('pointer write failure');
1261
- },
1262
- unlink: async (path) => {
1263
- if (path === ownTempPath)
1264
- throw new Error('keep own residue');
1265
- await rm(path, { force: false });
1266
- },
1267
3592
  rename: async (from, to) => {
1268
- if (from === context.lockPath)
1269
- throw new Error('preserve canonical lock');
1270
3593
  await rename(from, to);
3594
+ if (!replaced && to === context.sessionPath) {
3595
+ replaced = true;
3596
+ await rename(context.baseStateDir, displacedRoot);
3597
+ await mkdir(context.baseStateDir, { recursive: true });
3598
+ }
1271
3599
  },
1272
3600
  },
1273
- }, async () => {
1274
- await assert.rejects(writeSessionStart(cwd, 'sess-first', { platform: 'win32' }), isSessionPointerLaunchAbort);
1275
- });
1276
- assert.equal(existsSync(foreignTempPath), true);
1277
- assert.equal(existsSync(context.lockPath), true);
1278
- // This is documented stopped-session operator recovery, deliberately not
1279
- // product transaction behavior.
1280
- await rm(context.lockPath, { recursive: true, force: true });
1281
- await withPointerDependencies({ token: () => SUCCESSOR_TOKEN }, async () => {
1282
- await writeSessionStart(cwd, 'sess-successor', { platform: 'win32' });
1283
- });
1284
- assert.equal((await readSessionState(cwd))?.session_id, 'sess-successor');
1285
- assert.equal(existsSync(ownTempPath), true);
1286
- assert.equal(existsSync(foreignTempPath), true);
3601
+ }, async () => { established = await establishLaunchSessionBinding(cwd, 'sess-establish-post-rename-race'); });
3602
+ assert.ok(established);
3603
+ assert.equal(established.kind, 'committed-release-failed');
3604
+ assert.equal(existsSync(context.sessionPath), false);
3605
+ assert.equal(existsSync(join(displacedRoot, 'session.json')), true);
3606
+ }
3607
+ finally {
3608
+ await rm(cwd, { recursive: true, force: true });
3609
+ }
3610
+ });
3611
+ it('denies same-path replacement before creating state, lock, history, or HUD artifacts', async () => {
3612
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-replaced-path-'));
3613
+ const retainedCwd = `${cwd}-retained`;
3614
+ let binding;
3615
+ try {
3616
+ const established = await establishLaunchSessionBinding(cwd, 'sess-binding-replaced-path');
3617
+ assert.equal(established.kind, 'committed-released');
3618
+ if (established.kind !== 'committed-released')
3619
+ return;
3620
+ binding = established.binding;
3621
+ await rename(cwd, retainedCwd);
3622
+ await mkdir(cwd);
3623
+ const context = resolveSessionPointerContext(cwd);
3624
+ const finalized = await finalizeBoundOnce(binding, 'test');
3625
+ assert.equal(finalized.finalized, false);
3626
+ assert.equal(finalized.cleanup.comparison?.status, 'denied');
3627
+ assert.equal(existsSync(context.baseStateDir), false);
1287
3628
  assert.equal(existsSync(context.lockPath), false);
3629
+ assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
3630
+ assert.equal(existsSync(join(cwd, '.omx', 'state', 'hud-state.json')), false);
1288
3631
  }
1289
3632
  finally {
3633
+ if (binding)
3634
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
1290
3635
  await rm(cwd, { recursive: true, force: true });
3636
+ await rm(retainedCwd, { recursive: true, force: true });
1291
3637
  }
1292
3638
  });
1293
- it('binds an owner alias only on a verified same-native/absent transition and never during replacement', async () => {
1294
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-alias-'));
3639
+ it('keeps native absent reconciliation tokenless without backfill', async () => {
3640
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-tokenless-'));
1295
3641
  try {
1296
- const nativeOnly = await reconcileNativeSessionStart(cwd, 'native-first', {
1297
- platform: 'win32',
1298
- ownerOmxSessionId: 'omx-owner',
1299
- });
1300
- assert.equal(nativeOnly.owner_omx_session_id, undefined);
1301
- await withOwnerEnvironment('omx-owner', async () => {
1302
- const bound = await reconcileNativeSessionStart(cwd, 'native-first', {
3642
+ assert.equal((await reconcileNativeSessionStart(cwd, 'native-tokenless')).launch_lineage_token, undefined);
3643
+ assert.equal((await reconcileNativeSessionStart(cwd, 'native-tokenless')).launch_lineage_token, undefined);
3644
+ assert.equal((await readSessionState(cwd))?.launch_lineage_token, undefined);
3645
+ }
3646
+ finally {
3647
+ await rm(cwd, { recursive: true, force: true });
3648
+ }
3649
+ });
3650
+ it('allows authorized unsupported-capability stale-dead finalization without weakening bound lineage checks', async () => {
3651
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-unsupported-lifecycle-'));
3652
+ let ordinary;
3653
+ let stale;
3654
+ try {
3655
+ await withPointerDependencies({
3656
+ runtimePlatform: 'win32',
3657
+ observeProcess: () => matchingWin32Observation(),
3658
+ }, async () => {
3659
+ const ordinaryEstablished = await establishLaunchSessionBinding(cwd, 'sess-unsupported-ordinary', { platform: 'win32' });
3660
+ assert.equal(ordinaryEstablished.kind, 'committed-released');
3661
+ if (ordinaryEstablished.kind !== 'committed-released')
3662
+ return;
3663
+ ordinary = ordinaryEstablished.binding;
3664
+ assert.deepEqual(ordinary.directoryIdentity, { kind: 'unsupported', reason: 'platform' });
3665
+ const ordinaryFinalized = await finalizeBoundOnce(ordinary, 'ordinary-unsupported');
3666
+ assert.equal(ordinaryFinalized.finalized, true);
3667
+ assert.equal(existsSync(resolveSessionPointerContext(cwd).sessionPath), false);
3668
+ assert.equal((await closeLaunchSessionBindingOnce(ordinary)).status, 'closed');
3669
+ const staleEstablished = await establishLaunchSessionBinding(cwd, 'sess-unsupported-stale', {
3670
+ nativeSessionId: 'native-unsupported-stale',
1303
3671
  platform: 'win32',
1304
- ownerAliasVerified: true,
1305
3672
  });
1306
- assert.equal(bound.session_id, 'native-first');
1307
- assert.equal(bound.owner_omx_session_id, 'omx-owner');
1308
- const replacement = await reconcileNativeSessionStart(cwd, 'native-second', {
1309
- platform: 'win32',
1310
- ownerAliasVerified: true,
3673
+ assert.equal(staleEstablished.kind, 'committed-released');
3674
+ if (staleEstablished.kind !== 'committed-released')
3675
+ return;
3676
+ stale = staleEstablished.binding;
3677
+ await reconcileNativeSessionStart(cwd, 'native-unsupported-stale', { pid: 424242, platform: 'win32' });
3678
+ const context = resolveSessionPointerContext(cwd);
3679
+ const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
3680
+ await withPointerDependencies({
3681
+ runtimePlatform: 'win32',
3682
+ observeProcess: () => matchingWin32Observation(),
3683
+ probePid: () => 'dead',
3684
+ }, async () => {
3685
+ const finalized = await finalizeBoundOnce(stale, 'stale-unsupported');
3686
+ assert.equal(finalized.finalized, true);
3687
+ assert.equal(finalized.cleanup.comparison?.status, 'matched');
3688
+ assert.match(finalized.cleanup.comparison?.reason ?? '', /unsupported-directory-capability:platform/);
1311
3689
  });
1312
- assert.equal(replacement.session_id, 'native-second');
1313
- assert.equal(replacement.owner_omx_session_id, 'omx-owner');
3690
+ assert.equal(existsSync(context.sessionPath), false);
3691
+ const history = await readFile(historyPath, 'utf-8');
3692
+ assert.match(history, /native-unsupported-stale/);
1314
3693
  });
1315
3694
  }
1316
3695
  finally {
3696
+ if (ordinary)
3697
+ await closeLaunchSessionBindingOnce(ordinary).catch(() => { });
3698
+ if (stale)
3699
+ await closeLaunchSessionBindingOnce(stale).catch(() => { });
1317
3700
  await rm(cwd, { recursive: true, force: true });
1318
3701
  }
1319
3702
  });
1320
- it('preserves pointer evidence when history cannot be appended and rejects unusable end states before history or HUD cleanup', async () => {
1321
- const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-preserve-'));
3703
+ it('preserves native lineage and tmux metadata when rejecting a live native-ID replacement', async () => {
3704
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-lineage-metadata-'));
3705
+ let binding;
3706
+ try {
3707
+ const established = await establishLaunchSessionBinding(cwd, 'sess-native-lineage-metadata', {
3708
+ nativeSessionId: 'native-before',
3709
+ tmuxSessionName: 'omx-native-lineage',
3710
+ tmuxPaneId: '%88',
3711
+ });
3712
+ assert.equal(established.kind, 'committed-released');
3713
+ if (established.kind !== 'committed-released')
3714
+ return;
3715
+ binding = established.binding;
3716
+ await assert.rejects(reconcileNativeSessionStart(cwd, 'native-after', { pid: process.pid }), isOwnerConflict);
3717
+ const persisted = await readSessionState(cwd);
3718
+ assert.equal(persisted?.session_id, binding.canonicalSessionId);
3719
+ assert.equal(persisted?.native_session_id, 'native-before');
3720
+ assert.equal(persisted?.previous_native_session_id, undefined);
3721
+ assert.equal(persisted?.launch_lineage_token, binding.launchLineageToken);
3722
+ assert.equal(persisted?.tmux_session_name, 'omx-native-lineage');
3723
+ assert.equal(persisted?.tmux_pane_id, '%88');
3724
+ }
3725
+ finally {
3726
+ if (binding)
3727
+ await closeLaunchSessionBindingOnce(binding).catch(() => { });
3728
+ await rm(cwd, { recursive: true, force: true });
3729
+ }
3730
+ });
3731
+ it('reports owner unlink residue and does not rmdir it', async () => {
3732
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-unlink-'));
1322
3733
  try {
1323
3734
  const context = resolveSessionPointerContext(cwd);
1324
- await writeSessionStart(cwd, 'sess-history', { platform: 'win32' });
1325
- await mkdir(join(cwd, '.omx', 'logs'), { recursive: true });
1326
- await rm(join(cwd, '.omx', 'logs'), { recursive: true, force: true });
1327
- await writeFile(join(cwd, '.omx', 'logs'), 'not-a-directory', 'utf-8');
1328
- await assert.rejects(writeSessionEnd(cwd, 'sess-history'));
1329
- assert.equal((await readSessionState(cwd))?.session_id, 'sess-history');
1330
- await rm(join(cwd, '.omx', 'logs'), { force: true });
1331
- await writeFile(context.sessionPath, '{ malformed', 'utf-8');
1332
- await assert.rejects(writeSessionEnd(cwd, 'sess-history'), (error) => isSessionPointerLaunchAbort(error)
1333
- && error.code === 'session_pointer_unusable'
1334
- && error.pointerStatus === 'malformed');
1335
- assert.equal(await readFile(context.sessionPath, 'utf-8'), '{ malformed');
1336
- assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
3735
+ let rmdirCalls = 0;
3736
+ await withPointerDependencies({
3737
+ token: () => TEST_TOKEN,
3738
+ fs: {
3739
+ unlink: async (path) => {
3740
+ if (path.endsWith(`.release-${TEST_TOKEN}/owner.json`))
3741
+ throw new Error('owner unlink failure');
3742
+ await rm(path, { force: false });
3743
+ },
3744
+ rmdir: async (path) => { rmdirCalls += 1; await rm(path, { recursive: false }); },
3745
+ },
3746
+ }, async () => {
3747
+ const result = await establishLaunchSessionBinding(cwd, 'sess-owner-unlink');
3748
+ assert.equal(result.kind, 'committed-release-failed');
3749
+ if (result.kind === 'committed-release-failed') {
3750
+ assert.equal(result.secondaryFailures[0]?.phase, 'remove-release-owner');
3751
+ assert.equal(result.lockDisposition, 'released-with-residue');
3752
+ }
3753
+ });
3754
+ assert.equal(rmdirCalls, 0);
3755
+ assert.equal(existsSync(`${context.lockPath}.release-${TEST_TOKEN}`), true);
1337
3756
  }
1338
3757
  finally {
1339
3758
  await rm(cwd, { recursive: true, force: true });