oh-my-codex 0.21.0 → 0.21.2

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 (346) hide show
  1. package/Cargo.toml +1 -1
  2. package/README.md +4 -2
  3. package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.d.ts +2 -0
  4. package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.d.ts.map +1 -0
  5. package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.js +592 -0
  6. package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.js.map +1 -0
  7. package/dist/cli/__tests__/detached-leader-teardown.test.js +270 -33
  8. package/dist/cli/__tests__/detached-leader-teardown.test.js.map +1 -1
  9. package/dist/cli/__tests__/doctor-warning-copy.test.js +133 -5
  10. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  11. package/dist/cli/__tests__/index.test.js +543 -2
  12. package/dist/cli/__tests__/index.test.js.map +1 -1
  13. package/dist/cli/__tests__/issue-windows-cancel-identity.test.d.ts +2 -0
  14. package/dist/cli/__tests__/issue-windows-cancel-identity.test.d.ts.map +1 -0
  15. package/dist/cli/__tests__/issue-windows-cancel-identity.test.js +17 -0
  16. package/dist/cli/__tests__/issue-windows-cancel-identity.test.js.map +1 -0
  17. package/dist/cli/__tests__/launch-fallback.test.js +15 -3
  18. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  19. package/dist/cli/__tests__/package-bin-contract.test.js +20 -0
  20. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  21. package/dist/cli/__tests__/plugin-cache-stale-lock.test.d.ts +2 -0
  22. package/dist/cli/__tests__/plugin-cache-stale-lock.test.d.ts.map +1 -0
  23. package/dist/cli/__tests__/plugin-cache-stale-lock.test.js +76 -0
  24. package/dist/cli/__tests__/plugin-cache-stale-lock.test.js.map +1 -0
  25. package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.d.ts +2 -0
  26. package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.d.ts.map +1 -0
  27. package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.js +1662 -0
  28. package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.js.map +1 -0
  29. package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.d.ts +2 -0
  30. package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.d.ts.map +1 -0
  31. package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.js +522 -0
  32. package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.js.map +1 -0
  33. package/dist/cli/__tests__/ralplan-advisory.test.d.ts +2 -0
  34. package/dist/cli/__tests__/ralplan-advisory.test.d.ts.map +1 -0
  35. package/dist/cli/__tests__/ralplan-advisory.test.js +301 -0
  36. package/dist/cli/__tests__/ralplan-advisory.test.js.map +1 -0
  37. package/dist/cli/__tests__/ralplan.test.js +39 -0
  38. package/dist/cli/__tests__/ralplan.test.js.map +1 -1
  39. package/dist/cli/__tests__/resume.test.js +81 -6
  40. package/dist/cli/__tests__/resume.test.js.map +1 -1
  41. package/dist/cli/__tests__/session-scoped-runtime.test.js +423 -4
  42. package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
  43. package/dist/cli/__tests__/setup-install-mode.test.js +60 -3
  44. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  45. package/dist/cli/__tests__/setup-refresh.test.js +9 -0
  46. package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
  47. package/dist/cli/__tests__/sparkshell-cli.test.js +4 -0
  48. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  49. package/dist/cli/__tests__/ultragoal.test.js +71 -0
  50. package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
  51. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +30 -4
  52. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  53. package/dist/cli/doctor.d.ts.map +1 -1
  54. package/dist/cli/doctor.js +57 -8
  55. package/dist/cli/doctor.js.map +1 -1
  56. package/dist/cli/index.d.ts +113 -7
  57. package/dist/cli/index.d.ts.map +1 -1
  58. package/dist/cli/index.js +1850 -185
  59. package/dist/cli/index.js.map +1 -1
  60. package/dist/cli/native-assets.d.ts +2 -1
  61. package/dist/cli/native-assets.d.ts.map +1 -1
  62. package/dist/cli/native-assets.js +7 -7
  63. package/dist/cli/native-assets.js.map +1 -1
  64. package/dist/cli/plugin-marketplace.d.ts +89 -4
  65. package/dist/cli/plugin-marketplace.d.ts.map +1 -1
  66. package/dist/cli/plugin-marketplace.js +2135 -118
  67. package/dist/cli/plugin-marketplace.js.map +1 -1
  68. package/dist/cli/ralplan.d.ts +2 -1
  69. package/dist/cli/ralplan.d.ts.map +1 -1
  70. package/dist/cli/ralplan.js +149 -9
  71. package/dist/cli/ralplan.js.map +1 -1
  72. package/dist/cli/setup.d.ts.map +1 -1
  73. package/dist/cli/setup.js +7 -1
  74. package/dist/cli/setup.js.map +1 -1
  75. package/dist/cli/sparkshell.d.ts.map +1 -1
  76. package/dist/cli/sparkshell.js +9 -10
  77. package/dist/cli/sparkshell.js.map +1 -1
  78. package/dist/cli/update-worker.js +9 -0
  79. package/dist/cli/update-worker.js.map +1 -1
  80. package/dist/cli/update.d.ts +1 -0
  81. package/dist/cli/update.d.ts.map +1 -1
  82. package/dist/cli/update.js +9 -0
  83. package/dist/cli/update.js.map +1 -1
  84. package/dist/config/__tests__/generator-idempotent.test.js +118 -0
  85. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  86. package/dist/config/generator.d.ts.map +1 -1
  87. package/dist/config/generator.js +74 -4
  88. package/dist/config/generator.js.map +1 -1
  89. package/dist/hooks/__tests__/keyword-detector.test.js +74 -0
  90. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  91. package/dist/hooks/__tests__/ralplan-advisory-keyword.test.d.ts +2 -0
  92. package/dist/hooks/__tests__/ralplan-advisory-keyword.test.d.ts.map +1 -0
  93. package/dist/hooks/__tests__/ralplan-advisory-keyword.test.js +250 -0
  94. package/dist/hooks/__tests__/ralplan-advisory-keyword.test.js.map +1 -0
  95. package/dist/hooks/__tests__/session.test.js +10 -1
  96. package/dist/hooks/__tests__/session.test.js.map +1 -1
  97. package/dist/hooks/keyword-detector.d.ts +8 -1
  98. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  99. package/dist/hooks/keyword-detector.js +130 -9
  100. package/dist/hooks/keyword-detector.js.map +1 -1
  101. package/dist/hooks/session.d.ts +5 -0
  102. package/dist/hooks/session.d.ts.map +1 -1
  103. package/dist/hooks/session.js +17 -3
  104. package/dist/hooks/session.js.map +1 -1
  105. package/dist/hud/__tests__/index.test.js +16 -1
  106. package/dist/hud/__tests__/index.test.js.map +1 -1
  107. package/dist/hud/__tests__/reconcile.test.js +134 -7
  108. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  109. package/dist/hud/__tests__/render.test.js +57 -0
  110. package/dist/hud/__tests__/render.test.js.map +1 -1
  111. package/dist/hud/__tests__/session-attached.test.d.ts +2 -0
  112. package/dist/hud/__tests__/session-attached.test.d.ts.map +1 -0
  113. package/dist/hud/__tests__/session-attached.test.js +82 -0
  114. package/dist/hud/__tests__/session-attached.test.js.map +1 -0
  115. package/dist/hud/__tests__/state.test.js +94 -2
  116. package/dist/hud/__tests__/state.test.js.map +1 -1
  117. package/dist/hud/__tests__/tmux-split-realtmux.test.js +169 -2
  118. package/dist/hud/__tests__/tmux-split-realtmux.test.js.map +1 -1
  119. package/dist/hud/__tests__/tmux.test.js +104 -22
  120. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  121. package/dist/hud/__tests__/types.test.js +9 -0
  122. package/dist/hud/__tests__/types.test.js.map +1 -1
  123. package/dist/hud/__tests__/watch-detached.test.d.ts +2 -0
  124. package/dist/hud/__tests__/watch-detached.test.d.ts.map +1 -0
  125. package/dist/hud/__tests__/watch-detached.test.js +383 -0
  126. package/dist/hud/__tests__/watch-detached.test.js.map +1 -0
  127. package/dist/hud/__tests__/watch-realtmux.test.d.ts +2 -0
  128. package/dist/hud/__tests__/watch-realtmux.test.d.ts.map +1 -0
  129. package/dist/hud/__tests__/watch-realtmux.test.js +322 -0
  130. package/dist/hud/__tests__/watch-realtmux.test.js.map +1 -0
  131. package/dist/hud/index.d.ts +3 -0
  132. package/dist/hud/index.d.ts.map +1 -1
  133. package/dist/hud/index.js +98 -20
  134. package/dist/hud/index.js.map +1 -1
  135. package/dist/hud/reconcile.d.ts.map +1 -1
  136. package/dist/hud/reconcile.js +68 -24
  137. package/dist/hud/reconcile.js.map +1 -1
  138. package/dist/hud/render.d.ts.map +1 -1
  139. package/dist/hud/render.js +20 -0
  140. package/dist/hud/render.js.map +1 -1
  141. package/dist/hud/session-attached.d.ts +15 -0
  142. package/dist/hud/session-attached.d.ts.map +1 -0
  143. package/dist/hud/session-attached.js +47 -0
  144. package/dist/hud/session-attached.js.map +1 -0
  145. package/dist/hud/state.d.ts +9 -1
  146. package/dist/hud/state.d.ts.map +1 -1
  147. package/dist/hud/state.js +140 -3
  148. package/dist/hud/state.js.map +1 -1
  149. package/dist/hud/tmux.d.ts +4 -0
  150. package/dist/hud/tmux.d.ts.map +1 -1
  151. package/dist/hud/tmux.js +86 -23
  152. package/dist/hud/tmux.js.map +1 -1
  153. package/dist/hud/types.d.ts +20 -0
  154. package/dist/hud/types.d.ts.map +1 -1
  155. package/dist/hud/types.js +3 -0
  156. package/dist/hud/types.js.map +1 -1
  157. package/dist/modes/base.d.ts +15 -2
  158. package/dist/modes/base.d.ts.map +1 -1
  159. package/dist/modes/base.js +105 -16
  160. package/dist/modes/base.js.map +1 -1
  161. package/dist/question/autopilot-wait.js +2 -2
  162. package/dist/question/autopilot-wait.js.map +1 -1
  163. package/dist/question/deep-interview.js +2 -2
  164. package/dist/question/deep-interview.js.map +1 -1
  165. package/dist/ralplan/__tests__/advisory-activation.test.d.ts +2 -0
  166. package/dist/ralplan/__tests__/advisory-activation.test.d.ts.map +1 -0
  167. package/dist/ralplan/__tests__/advisory-activation.test.js +700 -0
  168. package/dist/ralplan/__tests__/advisory-activation.test.js.map +1 -0
  169. package/dist/ralplan/__tests__/advisory-detached-transition.test.d.ts +2 -0
  170. package/dist/ralplan/__tests__/advisory-detached-transition.test.d.ts.map +1 -0
  171. package/dist/ralplan/__tests__/advisory-detached-transition.test.js +88 -0
  172. package/dist/ralplan/__tests__/advisory-detached-transition.test.js.map +1 -0
  173. package/dist/ralplan/__tests__/advisory.test.d.ts +2 -0
  174. package/dist/ralplan/__tests__/advisory.test.d.ts.map +1 -0
  175. package/dist/ralplan/__tests__/advisory.test.js +1087 -0
  176. package/dist/ralplan/__tests__/advisory.test.js.map +1 -0
  177. package/dist/ralplan/__tests__/runtime.test.js +416 -7
  178. package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
  179. package/dist/ralplan/advisory-activation-verifier.d.ts +19 -0
  180. package/dist/ralplan/advisory-activation-verifier.d.ts.map +1 -0
  181. package/dist/ralplan/advisory-activation-verifier.js +77 -0
  182. package/dist/ralplan/advisory-activation-verifier.js.map +1 -0
  183. package/dist/ralplan/advisory-activation.d.ts +34 -0
  184. package/dist/ralplan/advisory-activation.d.ts.map +1 -0
  185. package/dist/ralplan/advisory-activation.js +205 -0
  186. package/dist/ralplan/advisory-activation.js.map +1 -0
  187. package/dist/ralplan/advisory-contract.d.ts +103 -0
  188. package/dist/ralplan/advisory-contract.d.ts.map +1 -0
  189. package/dist/ralplan/advisory-contract.js +83 -0
  190. package/dist/ralplan/advisory-contract.js.map +1 -0
  191. package/dist/ralplan/advisory-detached-transition.d.ts +11 -0
  192. package/dist/ralplan/advisory-detached-transition.d.ts.map +1 -0
  193. package/dist/ralplan/advisory-detached-transition.js +21 -0
  194. package/dist/ralplan/advisory-detached-transition.js.map +1 -0
  195. package/dist/ralplan/advisory-evidence.d.ts +91 -0
  196. package/dist/ralplan/advisory-evidence.d.ts.map +1 -0
  197. package/dist/ralplan/advisory-evidence.js +393 -0
  198. package/dist/ralplan/advisory-evidence.js.map +1 -0
  199. package/dist/ralplan/advisory-lifecycle-validation.d.ts +11 -0
  200. package/dist/ralplan/advisory-lifecycle-validation.d.ts.map +1 -0
  201. package/dist/ralplan/advisory-lifecycle-validation.js +72 -0
  202. package/dist/ralplan/advisory-lifecycle-validation.js.map +1 -0
  203. package/dist/ralplan/advisory-postconditions.d.ts +3 -0
  204. package/dist/ralplan/advisory-postconditions.d.ts.map +1 -0
  205. package/dist/ralplan/advisory-postconditions.js +33 -0
  206. package/dist/ralplan/advisory-postconditions.js.map +1 -0
  207. package/dist/ralplan/advisory-recovery-journal.d.ts +56 -0
  208. package/dist/ralplan/advisory-recovery-journal.d.ts.map +1 -0
  209. package/dist/ralplan/advisory-recovery-journal.js +136 -0
  210. package/dist/ralplan/advisory-recovery-journal.js.map +1 -0
  211. package/dist/ralplan/advisory-storage.d.ts +24 -0
  212. package/dist/ralplan/advisory-storage.d.ts.map +1 -0
  213. package/dist/ralplan/advisory-storage.js +106 -0
  214. package/dist/ralplan/advisory-storage.js.map +1 -0
  215. package/dist/ralplan/advisory.d.ts +99 -0
  216. package/dist/ralplan/advisory.d.ts.map +1 -0
  217. package/dist/ralplan/advisory.js +914 -0
  218. package/dist/ralplan/advisory.js.map +1 -0
  219. package/dist/ralplan/documented-leader-preflight.d.ts +8 -0
  220. package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -1
  221. package/dist/ralplan/documented-leader-preflight.js +60 -2
  222. package/dist/ralplan/documented-leader-preflight.js.map +1 -1
  223. package/dist/ralplan/runtime-advisory-lifecycle.d.ts +74 -0
  224. package/dist/ralplan/runtime-advisory-lifecycle.d.ts.map +1 -0
  225. package/dist/ralplan/runtime-advisory-lifecycle.js +352 -0
  226. package/dist/ralplan/runtime-advisory-lifecycle.js.map +1 -0
  227. package/dist/ralplan/runtime-contract.d.ts +126 -0
  228. package/dist/ralplan/runtime-contract.d.ts.map +1 -0
  229. package/dist/ralplan/runtime-contract.js +2 -0
  230. package/dist/ralplan/runtime-contract.js.map +1 -0
  231. package/dist/ralplan/runtime.d.ts +6 -109
  232. package/dist/ralplan/runtime.d.ts.map +1 -1
  233. package/dist/ralplan/runtime.js +174 -56
  234. package/dist/ralplan/runtime.js.map +1 -1
  235. package/dist/scripts/__tests__/postinstall.test.js +26 -0
  236. package/dist/scripts/__tests__/postinstall.test.js.map +1 -1
  237. package/dist/scripts/__tests__/ralplan-advisory-hook.test.d.ts +2 -0
  238. package/dist/scripts/__tests__/ralplan-advisory-hook.test.d.ts.map +1 -0
  239. package/dist/scripts/__tests__/ralplan-advisory-hook.test.js +299 -0
  240. package/dist/scripts/__tests__/ralplan-advisory-hook.test.js.map +1 -0
  241. package/dist/scripts/__tests__/smoke-packed-install.test.js +102 -8
  242. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  243. package/dist/scripts/codex-native-hook.d.ts +20 -0
  244. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  245. package/dist/scripts/codex-native-hook.js +226 -15
  246. package/dist/scripts/codex-native-hook.js.map +1 -1
  247. package/dist/scripts/postinstall.d.ts +7 -0
  248. package/dist/scripts/postinstall.d.ts.map +1 -1
  249. package/dist/scripts/postinstall.js +40 -0
  250. package/dist/scripts/postinstall.js.map +1 -1
  251. package/dist/scripts/smoke-packed-install.d.ts +21 -5
  252. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  253. package/dist/scripts/smoke-packed-install.js +157 -57
  254. package/dist/scripts/smoke-packed-install.js.map +1 -1
  255. package/dist/state/__tests__/mode-binding-lease.test.d.ts +2 -0
  256. package/dist/state/__tests__/mode-binding-lease.test.d.ts.map +1 -0
  257. package/dist/state/__tests__/mode-binding-lease.test.js +498 -0
  258. package/dist/state/__tests__/mode-binding-lease.test.js.map +1 -0
  259. package/dist/state/__tests__/operations.test.js +1 -0
  260. package/dist/state/__tests__/operations.test.js.map +1 -1
  261. package/dist/state/__tests__/pinned-atomic-file.test.d.ts +2 -0
  262. package/dist/state/__tests__/pinned-atomic-file.test.d.ts.map +1 -0
  263. package/dist/state/__tests__/pinned-atomic-file.test.js +438 -0
  264. package/dist/state/__tests__/pinned-atomic-file.test.js.map +1 -0
  265. package/dist/state/__tests__/skill-active-exact-session-transaction.test.d.ts +2 -0
  266. package/dist/state/__tests__/skill-active-exact-session-transaction.test.d.ts.map +1 -0
  267. package/dist/state/__tests__/skill-active-exact-session-transaction.test.js +179 -0
  268. package/dist/state/__tests__/skill-active-exact-session-transaction.test.js.map +1 -0
  269. package/dist/state/__tests__/skill-active.test.js +29 -1
  270. package/dist/state/__tests__/skill-active.test.js.map +1 -1
  271. package/dist/state/mode-binding-lease-helper.d.ts +2 -0
  272. package/dist/state/mode-binding-lease-helper.d.ts.map +1 -0
  273. package/dist/state/mode-binding-lease-helper.js +623 -0
  274. package/dist/state/mode-binding-lease-helper.js.map +1 -0
  275. package/dist/state/mode-binding-lease.d.ts +25 -0
  276. package/dist/state/mode-binding-lease.d.ts.map +1 -0
  277. package/dist/state/mode-binding-lease.js +380 -0
  278. package/dist/state/mode-binding-lease.js.map +1 -0
  279. package/dist/state/operations.d.ts +29 -1
  280. package/dist/state/operations.d.ts.map +1 -1
  281. package/dist/state/operations.js +203 -122
  282. package/dist/state/operations.js.map +1 -1
  283. package/dist/state/pinned-atomic-file-darwin-client.d.ts +34 -0
  284. package/dist/state/pinned-atomic-file-darwin-client.d.ts.map +1 -0
  285. package/dist/state/pinned-atomic-file-darwin-client.js +174 -0
  286. package/dist/state/pinned-atomic-file-darwin-client.js.map +1 -0
  287. package/dist/state/pinned-atomic-file-darwin-helper.d.ts +2 -0
  288. package/dist/state/pinned-atomic-file-darwin-helper.d.ts.map +1 -0
  289. package/dist/state/pinned-atomic-file-darwin-helper.js +275 -0
  290. package/dist/state/pinned-atomic-file-darwin-helper.js.map +1 -0
  291. package/dist/state/pinned-atomic-file-darwin-janitor.d.ts +2 -0
  292. package/dist/state/pinned-atomic-file-darwin-janitor.d.ts.map +1 -0
  293. package/dist/state/pinned-atomic-file-darwin-janitor.js +272 -0
  294. package/dist/state/pinned-atomic-file-darwin-janitor.js.map +1 -0
  295. package/dist/state/pinned-atomic-file.d.ts +29 -0
  296. package/dist/state/pinned-atomic-file.d.ts.map +1 -0
  297. package/dist/state/pinned-atomic-file.js +536 -0
  298. package/dist/state/pinned-atomic-file.js.map +1 -0
  299. package/dist/state/process-identity.d.ts +30 -0
  300. package/dist/state/process-identity.d.ts.map +1 -0
  301. package/dist/state/process-identity.js +99 -0
  302. package/dist/state/process-identity.js.map +1 -0
  303. package/dist/state/skill-active.d.ts +18 -1
  304. package/dist/state/skill-active.d.ts.map +1 -1
  305. package/dist/state/skill-active.js +82 -5
  306. package/dist/state/skill-active.js.map +1 -1
  307. package/dist/state/workflow-transition-reconcile.js +1 -1
  308. package/dist/state/workflow-transition-reconcile.js.map +1 -1
  309. package/dist/team/__tests__/tmux-session.test.js +109 -0
  310. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  311. package/dist/team/tmux-session.d.ts.map +1 -1
  312. package/dist/team/tmux-session.js +10 -1
  313. package/dist/team/tmux-session.js.map +1 -1
  314. package/dist/utils/__tests__/file-durability.test.js +20 -0
  315. package/dist/utils/__tests__/file-durability.test.js.map +1 -1
  316. package/dist/utils/file-durability.d.ts +1 -1
  317. package/dist/utils/file-durability.d.ts.map +1 -1
  318. package/dist/utils/file-durability.js.map +1 -1
  319. package/dist/verification/__tests__/ci-rust-gates.test.js +12 -4
  320. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  321. package/dist/verification/__tests__/ci-trusted-publish.test.d.ts +2 -0
  322. package/dist/verification/__tests__/ci-trusted-publish.test.d.ts.map +1 -0
  323. package/dist/verification/__tests__/ci-trusted-publish.test.js +205 -0
  324. package/dist/verification/__tests__/ci-trusted-publish.test.js.map +1 -0
  325. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +10 -5
  326. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  327. package/dist/verification/__tests__/publish-npm-manual-retired.test.d.ts +2 -0
  328. package/dist/verification/__tests__/publish-npm-manual-retired.test.d.ts.map +1 -0
  329. package/dist/verification/__tests__/publish-npm-manual-retired.test.js +22 -0
  330. package/dist/verification/__tests__/publish-npm-manual-retired.test.js.map +1 -0
  331. package/package.json +1 -1
  332. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  333. package/plugins/oh-my-codex/skills/doctor/SKILL.md +1 -1
  334. package/plugins/oh-my-codex/skills/omx-setup/SKILL.md +1 -1
  335. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +10 -1
  336. package/plugins/oh-my-codex/skills/team/SKILL.md +2 -0
  337. package/skills/doctor/SKILL.md +1 -1
  338. package/skills/omx-setup/SKILL.md +1 -1
  339. package/skills/ralplan/SKILL.md +10 -1
  340. package/skills/team/SKILL.md +2 -0
  341. package/src/scripts/__tests__/postinstall.test.ts +28 -0
  342. package/src/scripts/__tests__/ralplan-advisory-hook.test.ts +320 -0
  343. package/src/scripts/__tests__/smoke-packed-install.test.ts +120 -7
  344. package/src/scripts/codex-native-hook.ts +220 -10
  345. package/src/scripts/postinstall.ts +46 -0
  346. package/src/scripts/smoke-packed-install.ts +175 -70
package/dist/cli/index.js CHANGED
@@ -3,9 +3,9 @@
3
3
  * Multi-agent orchestration for OpenAI Codex CLI
4
4
  */
5
5
  import { execFileSync, spawn } from "child_process";
6
- import { basename, dirname, isAbsolute, join, posix, relative, resolve, win32 } from "path";
6
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "path";
7
7
  import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "fs";
8
- import { copyFile, cp, lstat, mkdir, open, readFile, readdir, rm, stat, symlink, utimes, writeFile } from "fs/promises";
8
+ import { access, chmod, copyFile, cp, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, symlink, utimes, writeFile } from "fs/promises";
9
9
  import { constants as osConstants, homedir } from "os";
10
10
  import { createHash, randomUUID } from "crypto";
11
11
  import { setup, SETUP_MCP_MODES, SETUP_SCOPES, SETUP_TEAM_MODES, } from "./setup.js";
@@ -53,10 +53,11 @@ import { ROOT_REASONING_EFFORTS, isUnsupportedRootReasoningEffort, normalizeUnsu
53
53
  export { readPersistedSetupPreferences, readPersistedSetupScope, resolveCodexConfigPathForLaunch, resolveCodexHomeForLaunch, resolveProjectLocalCodexHomeForLaunch, } from "./codex-home.js";
54
54
  import { SKILL_ACTIVE_STATE_MODE, extractSessionIdFromInitializedStatePath, listActiveSkills, syncCanonicalSkillStateForMode, updateRootSkillActiveStateForStateDir, } from "../state/skill-active.js";
55
55
  import { isTrackedWorkflowMode } from "../state/workflow-transition.js";
56
+ import { validateDetachedAdvisorySessionSkillTransition } from "../ralplan/advisory-detached-transition.js";
56
57
  import { maybeCheckAndPromptUpdate, runImmediateUpdate } from "./update.js";
57
58
  import { maybePromptGithubStar } from "./star-prompt.js";
58
59
  import { generateOverlay, removeSessionModelInstructionsFile, resolveSessionOrchestrationMode, sessionModelInstructionsPath, writeSessionModelInstructionsFile, } from "../hooks/agents-overlay.js";
59
- import { closeLaunchSessionBindingOnce, establishLaunchSessionBinding, readNativeSessionOwnerEvidence, readSessionPointer, readSessionState, resolveSessionPointerContext, finalizeBoundOnce, isSessionPointerLaunchAbort, normalizeSessionId, resetSessionMetrics, } from "../hooks/session.js";
60
+ import { closeLaunchSessionBindingOnce, establishLaunchSessionBinding, readNativeSessionOwnerEvidence, readSessionPointer, readSessionState, resolveSessionPointerContext, sessionOwnerFileIdentityMatches, finalizeBoundOnce, isSessionPointerLaunchAbort, normalizeSessionId, resetSessionMetrics, } from "../hooks/session.js";
60
61
  import { probeActualTmuxInstanceEvidence, tmuxEvidenceBindsCandidate } from "../scripts/notify-hook/managed-tmux.js";
61
62
  import { buildClientAttachedReconcileHookName, buildReconcileHudResizeArgs, buildRegisterClientAttachedReconcileArgs, buildRegisterResizeHookArgs, buildResizeHookName, buildResizeHookTarget, buildScheduleDelayedHudResizeArgs, buildUnregisterClientAttachedReconcileArgs, buildUnregisterResizeHookArgs, enableMouseScrolling, isMsysOrGitBash, isNativeWindows, isTmuxAvailable, } from "../team/tmux-session.js";
62
63
  import { getPackageRoot } from "../utils/package.js";
@@ -633,10 +634,10 @@ export const CODEX_SQLITE_HOME_ENV = "CODEX_SQLITE_HOME";
633
634
  export function runtimeCodexHomePath(cwd, sessionId) {
634
635
  return join(omxRoot(cwd), "runtime", "codex-home", sessionId);
635
636
  }
636
- async function linkOrCopyCodexHomeEntry(source, destination) {
637
+ async function linkOrCopyCodexHomeEntry(source, destination, createSymlink = symlink) {
637
638
  const stat = await lstat(source);
638
639
  try {
639
- await symlink(source, destination, stat.isDirectory() && process.platform === "win32" ? "junction" : undefined);
640
+ await createSymlink(source, destination, stat.isDirectory() && process.platform === "win32" ? "junction" : undefined);
640
641
  }
641
642
  catch {
642
643
  if (stat.isDirectory()) {
@@ -646,10 +647,116 @@ async function linkOrCopyCodexHomeEntry(source, destination) {
646
647
  await copyFile(source, destination);
647
648
  }
648
649
  }
649
- async function copyFilePreservingTimestamps(source, destination) {
650
- await copyFile(source, destination);
651
- const sourceStat = await stat(source);
652
- await utimes(destination, sourceStat.atime, sourceStat.mtime);
650
+ async function copyFilePreservingTimestamps(source, destination, options = {}) {
651
+ const sourcePath = options.allowTopLevelSymlink === true ? await realpath(source) : source;
652
+ const sourceRoot = options.sourceRoot ? await realpath(options.sourceRoot) : undefined;
653
+ const destinationRoot = options.destinationRoot ? await realpath(options.destinationRoot) : undefined;
654
+ if (sourceRoot)
655
+ await assertHistoryPathWithin(sourcePath, sourceRoot);
656
+ if (destinationRoot)
657
+ await assertHistoryDestinationParentWithin(destination, destinationRoot);
658
+ const sourceHandle = await open(sourcePath, fsConstants.O_RDONLY | historyNoFollowFlag());
659
+ let destinationHandle;
660
+ const temporary = `${destination}.omx-history-${randomUUID()}`;
661
+ try {
662
+ const sourceStat = await sourceHandle.stat();
663
+ if (!sourceStat.isFile())
664
+ throw new Error(`history source is not a regular file: ${source}`);
665
+ if (options.expectedSourceStat && !hasSameHistoryIdentity(sourceStat, options.expectedSourceStat)) {
666
+ throw new HistoryEntryChangedError(`history source changed during copy: ${source}`);
667
+ }
668
+ await options.afterSourceOpen?.();
669
+ if (destinationRoot)
670
+ await assertHistoryDestinationParentWithin(destination, destinationRoot);
671
+ await mkdir(dirname(destination), { recursive: true });
672
+ destinationHandle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | historyNoFollowFlag(), historyDestinationMode(sourceStat.mode));
673
+ const buffer = Buffer.alloc(64 * 1024);
674
+ let position = 0;
675
+ const copiedHash = createHash("sha256");
676
+ while (true) {
677
+ const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, position);
678
+ if (bytesRead === 0)
679
+ break;
680
+ copiedHash.update(buffer.subarray(0, bytesRead));
681
+ let written = 0;
682
+ while (written < bytesRead) {
683
+ const { bytesWritten } = await destinationHandle.write(buffer, written, bytesRead - written);
684
+ written += bytesWritten;
685
+ }
686
+ position += bytesRead;
687
+ }
688
+ const copiedSourceStat = await sourceHandle.stat();
689
+ if (!hasSameHistoryVersion(copiedSourceStat, sourceStat)) {
690
+ throw new HistoryEntryChangedError(`history source content changed during copy: ${source}`);
691
+ }
692
+ const verificationHash = createHash("sha256");
693
+ position = 0;
694
+ while (true) {
695
+ const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, position);
696
+ if (bytesRead === 0)
697
+ break;
698
+ verificationHash.update(buffer.subarray(0, bytesRead));
699
+ position += bytesRead;
700
+ }
701
+ if (verificationHash.digest("hex") !== copiedHash.digest("hex")) {
702
+ throw new HistoryEntryChangedError(`history source bytes changed during copy: ${source}`);
703
+ }
704
+ await destinationHandle.chmod(historyDestinationMode(sourceStat.mode));
705
+ await destinationHandle.utimes(sourceStat.atime, sourceStat.mtime);
706
+ await destinationHandle.close();
707
+ destinationHandle = undefined;
708
+ await options.afterStage?.(temporary, destination);
709
+ const stagedStat = await lstat(temporary);
710
+ if (!stagedStat.isFile() || stagedStat.isSymbolicLink() || stagedStat.nlink !== 1) {
711
+ throw new Error(`history staging path is not a regular file: ${temporary}`);
712
+ }
713
+ if (destinationRoot)
714
+ await assertHistoryDestinationParentWithin(destination, destinationRoot);
715
+ await publishHistoryReplacement(temporary, destination, false);
716
+ }
717
+ finally {
718
+ await destinationHandle?.close().catch(() => undefined);
719
+ await rm(temporary, { force: true }).catch(() => undefined);
720
+ await sourceHandle.close();
721
+ }
722
+ }
723
+ async function readHistoryFileWithoutSymlink(source, allowTopLevelSymlink = false, sourceRoot) {
724
+ const sourcePath = allowTopLevelSymlink === true ? await realpath(source) : source;
725
+ const canonicalSourceRoot = sourceRoot ? await realpath(sourceRoot) : undefined;
726
+ if (canonicalSourceRoot)
727
+ await assertHistoryPathWithin(sourcePath, canonicalSourceRoot);
728
+ const sourceHandle = await open(sourcePath, fsConstants.O_RDONLY | historyNoFollowFlag());
729
+ try {
730
+ const sourceStat = await sourceHandle.stat();
731
+ if (!sourceStat.isFile())
732
+ throw new Error(`history source is not a regular file: ${source}`);
733
+ return await sourceHandle.readFile("utf-8");
734
+ }
735
+ finally {
736
+ await sourceHandle.close();
737
+ }
738
+ }
739
+ async function writeHistoryFileAtomically(destination, contents, mode, destinationRoot) {
740
+ const canonicalDestinationRoot = destinationRoot ? await realpath(destinationRoot) : undefined;
741
+ if (canonicalDestinationRoot)
742
+ await assertHistoryDestinationParentWithin(destination, canonicalDestinationRoot);
743
+ const destinationMode = historyDestinationMode(mode);
744
+ const temporary = `${destination}.omx-history-${randomUUID()}`;
745
+ let destinationHandle;
746
+ try {
747
+ destinationHandle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | historyNoFollowFlag(), destinationMode);
748
+ await destinationHandle.writeFile(contents, "utf-8");
749
+ await destinationHandle.chmod(destinationMode);
750
+ await destinationHandle.close();
751
+ destinationHandle = undefined;
752
+ if (canonicalDestinationRoot)
753
+ await assertHistoryDestinationParentWithin(destination, canonicalDestinationRoot);
754
+ await publishHistoryReplacement(temporary, destination, false);
755
+ }
756
+ finally {
757
+ await destinationHandle?.close().catch(() => undefined);
758
+ await rm(temporary, { force: true }).catch(() => undefined);
759
+ }
653
760
  }
654
761
  function isCodexSqliteArtifact(entryName) {
655
762
  return /^(?:state|logs)_\d+\.sqlite(?:-(?:shm|wal))?$/.test(entryName);
@@ -680,6 +787,333 @@ function shouldMirrorProjectLaunchRuntimeEntry(entryName, includeHistoryArtifact
680
787
  function shouldPersistProjectLaunchRuntimeEntry(entryName) {
681
788
  return PROJECT_LAUNCH_PERSISTED_RUNTIME_ENTRY_NAMES.has(entryName);
682
789
  }
790
+ function isRegularHistoryTarget(sourceStat) {
791
+ return sourceStat.isDirectory() || sourceStat.isFile();
792
+ }
793
+ export function historyDestinationMode(sourceMode) {
794
+ const permissions = sourceMode & 0o777;
795
+ return permissions | ((permissions & 0o070) << 3) | ((permissions & 0o007) << 6);
796
+ }
797
+ function historyNoFollowFlag() {
798
+ return process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
799
+ }
800
+ function isHistoryPathWithin(candidate, root) {
801
+ const normalizedRoot = process.platform === "win32" ? win32.normalize(root).toLowerCase() : root;
802
+ const normalizedCandidate = process.platform === "win32" ? win32.normalize(candidate).toLowerCase() : candidate;
803
+ const path = process.platform === "win32"
804
+ ? win32.relative(normalizedRoot, normalizedCandidate)
805
+ : relative(normalizedRoot, normalizedCandidate);
806
+ const escapes = path === ".." || path.startsWith(`..${sep}`);
807
+ return path === "" || (!escapes && !isAbsolute(path));
808
+ }
809
+ async function assertHistoryPathWithin(path, root, allowMissing = false) {
810
+ const canonicalRoot = root;
811
+ const canonicalPath = await realpath(path).catch((error) => {
812
+ if (allowMissing && isUnresolvableHistoryLinkError(error))
813
+ return undefined;
814
+ throw error;
815
+ });
816
+ if (canonicalPath && !isHistoryPathWithin(canonicalPath, canonicalRoot)) {
817
+ throw new Error(`history path escapes its authorized root: ${path}`);
818
+ }
819
+ }
820
+ async function assertHistoryDestinationParentWithin(destination, root) {
821
+ await assertHistoryPathWithin(dirname(destination), root);
822
+ }
823
+ async function assertHistoryDestinationDirectorySafe(destination, root) {
824
+ const destinationStat = await lstat(destination);
825
+ if (!destinationStat.isDirectory() || destinationStat.isSymbolicLink()) {
826
+ throw new HistoryEntryChangedError(`history destination changed during copy: ${destination}`);
827
+ }
828
+ await assertHistoryPathWithin(destination, root);
829
+ }
830
+ async function chmodHistoryDestination(destination, mode, tolerateForeignOwner) {
831
+ try {
832
+ await chmod(destination, mode);
833
+ }
834
+ catch (error) {
835
+ if (tolerateForeignOwner && error.code === "EPERM")
836
+ return;
837
+ throw error;
838
+ }
839
+ }
840
+ async function utimesHistoryDestination(destination, atime, mtime, tolerateForeignOwner) {
841
+ try {
842
+ await utimes(destination, atime, mtime);
843
+ }
844
+ catch (error) {
845
+ if (tolerateForeignOwner && error.code === "EPERM")
846
+ return;
847
+ throw error;
848
+ }
849
+ }
850
+ async function publishHistoryReplacement(temporary, destination, recursive) {
851
+ try {
852
+ await rename(temporary, destination);
853
+ return;
854
+ }
855
+ catch (error) {
856
+ const code = error.code;
857
+ if (code !== "EEXIST" && code !== "EPERM" && code !== "ENOTEMPTY")
858
+ throw error;
859
+ }
860
+ const backup = `${destination}.omx-history-backup-${randomUUID()}`;
861
+ await rename(destination, backup);
862
+ try {
863
+ await rename(temporary, destination);
864
+ }
865
+ catch (error) {
866
+ await rename(backup, destination).catch(() => undefined);
867
+ throw error;
868
+ }
869
+ await rm(backup, { recursive, force: true }).catch(() => undefined);
870
+ }
871
+ function isUnresolvableHistoryLinkError(error) {
872
+ if (!error || typeof error !== "object")
873
+ return false;
874
+ const code = error.code;
875
+ return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
876
+ }
877
+ class HistoryEntryChangedError extends Error {
878
+ code = "ESTALE";
879
+ }
880
+ function isDiscardableHistoryCopyError(error) {
881
+ return isUnresolvableHistoryLinkError(error) || error instanceof HistoryEntryChangedError;
882
+ }
883
+ const HISTORY_PERSISTENCE_LOCK_TIMEOUT_MS = 5_000;
884
+ const HISTORY_PERSISTENCE_LOCK_STALE_MS = 30_000;
885
+ const HISTORY_PERSISTENCE_LOCK_HEARTBEAT_MS = 5_000;
886
+ const HISTORY_PERSISTENCE_LOCK_RENAME_RETRIES = 20;
887
+ const HISTORY_PERSISTENCE_LOCK_RENAME_RETRY_DELAY_MS = 10;
888
+ async function readHistoryPersistenceLockOwner(lockPath) {
889
+ try {
890
+ const raw = await readFile(join(lockPath, "owner.json"), "utf-8");
891
+ const parts = raw.split("\n");
892
+ const completeLines = raw.endsWith("\n") || parts.length === 1 ? parts.filter(Boolean) : parts.slice(0, -1).filter(Boolean);
893
+ const owner = JSON.parse(completeLines.at(-1) ?? "");
894
+ if (typeof owner.token !== "string" || typeof owner.pid !== "number")
895
+ return null;
896
+ if (owner.startIdentity !== undefined && typeof owner.startIdentity !== "string")
897
+ return null;
898
+ return { token: owner.token, pid: owner.pid, startIdentity: owner.startIdentity };
899
+ }
900
+ catch (error) {
901
+ if (error.code === "ENOENT")
902
+ return undefined;
903
+ return null;
904
+ }
905
+ }
906
+ async function historyProcessStartIdentity(pid) {
907
+ if (process.platform !== "linux")
908
+ return undefined;
909
+ try {
910
+ const stat = await readFile(`/proc/${pid}/stat`, "utf-8");
911
+ const fields = stat.slice(stat.lastIndexOf(") ") + 2).split(" ");
912
+ return fields[19] ? `${pid}:${fields[19]}` : undefined;
913
+ }
914
+ catch {
915
+ return undefined;
916
+ }
917
+ }
918
+ async function isHistoryProcessAlive(owner) {
919
+ try {
920
+ process.kill(owner.pid, 0);
921
+ }
922
+ catch (error) {
923
+ if (error.code === "ESRCH")
924
+ return false;
925
+ return true;
926
+ }
927
+ if (!owner.startIdentity)
928
+ return undefined;
929
+ const currentIdentity = await historyProcessStartIdentity(owner.pid);
930
+ if (!currentIdentity)
931
+ return undefined;
932
+ return currentIdentity === owner.startIdentity;
933
+ }
934
+ async function historyLockIsCurrent(lease) {
935
+ const lockStat = await lstat(lease.lockPath).catch(() => undefined);
936
+ if (!lockStat || !lockStat.isDirectory() || lockStat.isSymbolicLink())
937
+ return false;
938
+ if (!hasSameHistoryIdentity(lockStat, lease.lockIdentity))
939
+ return false;
940
+ const owner = await readHistoryPersistenceLockOwner(lease.lockPath);
941
+ return owner?.token === lease.token;
942
+ }
943
+ async function retireHistoryLock(lockPath, root, expectedIdentity, expectedToken) {
944
+ const currentStat = await lstat(lockPath).catch(() => undefined);
945
+ if (!currentStat || !currentStat.isDirectory() || currentStat.isSymbolicLink())
946
+ return;
947
+ if (!hasSameHistoryIdentity(currentStat, expectedIdentity))
948
+ return;
949
+ if (expectedToken) {
950
+ const owner = await readHistoryPersistenceLockOwner(lockPath);
951
+ if (owner?.token !== expectedToken)
952
+ return;
953
+ }
954
+ const retiredPath = `${lockPath}.retired-${randomUUID()}`;
955
+ let retired = false;
956
+ for (let attempt = 0; attempt < HISTORY_PERSISTENCE_LOCK_RENAME_RETRIES; attempt += 1) {
957
+ try {
958
+ await rename(lockPath, retiredPath);
959
+ retired = true;
960
+ break;
961
+ }
962
+ catch {
963
+ if (attempt + 1 < HISTORY_PERSISTENCE_LOCK_RENAME_RETRIES) {
964
+ await new Promise((resolveWait) => setTimeout(resolveWait, HISTORY_PERSISTENCE_LOCK_RENAME_RETRY_DELAY_MS));
965
+ }
966
+ }
967
+ }
968
+ if (!retired)
969
+ return;
970
+ await assertHistoryPathWithin(retiredPath, root);
971
+ await rm(retiredPath, { recursive: true, force: true }).catch(() => undefined);
972
+ }
973
+ export async function releaseHistoryPersistenceLock(lease) {
974
+ clearInterval(lease.heartbeat);
975
+ await lease.waitForHeartbeat();
976
+ if (!(await historyLockIsCurrent(lease)))
977
+ return;
978
+ await retireHistoryLock(lease.lockPath, lease.root, lease.lockIdentity, lease.token);
979
+ }
980
+ export async function acquireHistoryPersistenceLock(root, options = {}) {
981
+ root = await realpath(root);
982
+ const lockPath = join(root, ".omx-history.lock");
983
+ const ownerPath = join(lockPath, "owner.json");
984
+ const timeoutMs = options.timeoutMs ?? HISTORY_PERSISTENCE_LOCK_TIMEOUT_MS;
985
+ const staleMs = options.staleMs ?? HISTORY_PERSISTENCE_LOCK_STALE_MS;
986
+ const heartbeatMs = options.heartbeatMs ?? HISTORY_PERSISTENCE_LOCK_HEARTBEAT_MS;
987
+ const deadline = Date.now() + timeoutMs;
988
+ while (true) {
989
+ try {
990
+ await mkdir(lockPath, { mode: 0o700 });
991
+ const lockStat = await lstat(lockPath);
992
+ if (!lockStat.isDirectory() || lockStat.isSymbolicLink()) {
993
+ throw new Error(`history persistence lock is not a private directory: ${lockPath}`);
994
+ }
995
+ await assertHistoryPathWithin(lockPath, root);
996
+ const lockIdentity = { dev: lockStat.dev, ino: lockStat.ino };
997
+ if (process.platform !== "win32" && (lockStat.mode & 0o077) !== 0) {
998
+ throw new Error(`history persistence lock is not private: ${lockPath}`);
999
+ }
1000
+ const token = randomUUID();
1001
+ const startIdentity = await historyProcessStartIdentity(process.pid);
1002
+ const initialOwnerPath = `${ownerPath}.${randomUUID()}.tmp`;
1003
+ await writeFile(initialOwnerPath, `${JSON.stringify({ token, pid: process.pid, startIdentity, heartbeatAt: Date.now() })}\n`, { flag: "wx", mode: 0o600 });
1004
+ await rename(initialOwnerPath, ownerPath);
1005
+ let heartbeatInFlight = Promise.resolve();
1006
+ const runHeartbeat = async () => {
1007
+ let temporaryOwnerPath;
1008
+ try {
1009
+ const owner = await readHistoryPersistenceLockOwner(lockPath);
1010
+ if (owner?.token !== token)
1011
+ return;
1012
+ if (process.platform === "win32") {
1013
+ const currentLockStat = await lstat(lockPath);
1014
+ if (!hasSameHistoryIdentity(currentLockStat, lockIdentity))
1015
+ return;
1016
+ await utimes(lockPath, new Date(), new Date());
1017
+ await writeFile(ownerPath, `${JSON.stringify({ token, pid: process.pid, startIdentity, heartbeatAt: Date.now() })}\n`, { flag: "a" });
1018
+ return;
1019
+ }
1020
+ temporaryOwnerPath = `${ownerPath}.${randomUUID()}.tmp`;
1021
+ await writeFile(temporaryOwnerPath, `${JSON.stringify({ token, pid: process.pid, startIdentity, heartbeatAt: Date.now() })}\n`, { flag: "wx", mode: 0o600 });
1022
+ const currentLockStat = await lstat(lockPath);
1023
+ const currentOwner = await readHistoryPersistenceLockOwner(lockPath);
1024
+ if (!hasSameHistoryIdentity(currentLockStat, lockIdentity) || currentOwner?.token !== token) {
1025
+ await rm(temporaryOwnerPath, { force: true }).catch(() => undefined);
1026
+ return;
1027
+ }
1028
+ await rename(temporaryOwnerPath, ownerPath);
1029
+ }
1030
+ catch {
1031
+ // A contender may have removed a dead owner's lock between checks.
1032
+ if (temporaryOwnerPath)
1033
+ await rm(temporaryOwnerPath, { force: true }).catch(() => undefined);
1034
+ }
1035
+ };
1036
+ const heartbeat = setInterval(() => {
1037
+ heartbeatInFlight = heartbeatInFlight.then(runHeartbeat, runHeartbeat);
1038
+ }, heartbeatMs);
1039
+ heartbeat.unref?.();
1040
+ return { root, lockPath, token, lockIdentity, heartbeat, waitForHeartbeat: () => heartbeatInFlight };
1041
+ }
1042
+ catch (error) {
1043
+ if (error.code !== "EEXIST")
1044
+ throw error;
1045
+ const lockStat = await lstat(lockPath).catch(() => undefined);
1046
+ if (!lockStat)
1047
+ continue;
1048
+ if (lockStat?.isSymbolicLink())
1049
+ throw new Error(`history persistence lock is a symlink: ${lockPath}`);
1050
+ const owner = await readHistoryPersistenceLockOwner(lockPath);
1051
+ const lockAge = lockStat ? Date.now() - lockStat.mtimeMs : 0;
1052
+ if (owner && owner !== null && (await isHistoryProcessAlive(owner)) === false) {
1053
+ const ownerStat = await stat(join(lockPath, "owner.json")).catch(() => undefined);
1054
+ if (ownerStat && Date.now() - ownerStat.mtimeMs > staleMs) {
1055
+ await retireHistoryLock(lockPath, root, lockStat ? { dev: lockStat.dev, ino: lockStat.ino } : { dev: -1, ino: -1 }, owner.token);
1056
+ continue;
1057
+ }
1058
+ }
1059
+ else if (owner === undefined && lockAge > staleMs) {
1060
+ await retireHistoryLock(lockPath, root, lockStat ? { dev: lockStat.dev, ino: lockStat.ino } : { dev: -1, ino: -1 });
1061
+ continue;
1062
+ }
1063
+ if (process.platform !== "win32" && (lockStat.mode & 0o077) !== 0) {
1064
+ throw new Error(`history persistence lock is not private: ${lockPath}`);
1065
+ }
1066
+ if (Date.now() >= deadline)
1067
+ throw new Error(`timed out waiting for history persistence lock: ${lockPath}`);
1068
+ await new Promise((resolve) => setTimeout(resolve, 20));
1069
+ }
1070
+ }
1071
+ }
1072
+ function hasSameHistoryIdentity(actual, expected) {
1073
+ return actual.dev === expected.dev && actual.ino === expected.ino;
1074
+ }
1075
+ function hasSameHistoryVersion(actual, expected) {
1076
+ return hasSameHistoryIdentity(actual, expected) && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs;
1077
+ }
1078
+ async function inspectProjectLaunchRuntimeHistoryEntry(source) {
1079
+ // Durable history names are an explicit allowlist. Follow only the named
1080
+ // entry's top-level link so a directory link is classified as a directory;
1081
+ // the destination remains the fixed runtime history path below.
1082
+ let linkStat;
1083
+ try {
1084
+ linkStat = await lstat(source);
1085
+ }
1086
+ catch (error) {
1087
+ if (isUnresolvableHistoryLinkError(error))
1088
+ return undefined;
1089
+ throw error;
1090
+ }
1091
+ if (!linkStat.isSymbolicLink())
1092
+ return { linkStat, targetStat: linkStat, targetRealpath: undefined };
1093
+ try {
1094
+ return { linkStat, targetStat: await stat(source), targetRealpath: await realpath(source) };
1095
+ }
1096
+ catch (error) {
1097
+ if (isUnresolvableHistoryLinkError(error))
1098
+ return { linkStat, targetStat: undefined };
1099
+ throw error;
1100
+ }
1101
+ }
1102
+ async function historyEntryStillMatches(source, inspection) {
1103
+ if (!inspection)
1104
+ return false;
1105
+ const current = await inspectProjectLaunchRuntimeHistoryEntry(source);
1106
+ const inspectedTarget = inspection.targetStat;
1107
+ const currentTarget = current?.targetStat;
1108
+ if (!current || !inspectedTarget || !currentTarget)
1109
+ return false;
1110
+ if (current.linkStat.dev !== inspection.linkStat.dev || current.linkStat.ino !== inspection.linkStat.ino)
1111
+ return false;
1112
+ if (inspection.targetRealpath !== current.targetRealpath)
1113
+ return false;
1114
+ return (currentTarget.dev === inspectedTarget.dev &&
1115
+ currentTarget.ino === inspectedTarget.ino);
1116
+ }
683
1117
  function uniqueJsonlLines(contents) {
684
1118
  const seen = new Set();
685
1119
  const lines = [];
@@ -691,84 +1125,366 @@ function uniqueJsonlLines(contents) {
691
1125
  }
692
1126
  return lines;
693
1127
  }
694
- async function persistProjectLaunchRuntimeJsonlArtifact(source, destination) {
695
- const existing = existsSync(destination) ? await readFile(destination, "utf-8").catch(() => "") : "";
696
- const sourceContents = await readFile(source, "utf-8");
697
- const separator = existing === "" || existing.endsWith("\n") || sourceContents === "" ? "" : "\n";
698
- const lines = uniqueJsonlLines(`${existing}${separator}${sourceContents}`);
699
- await writeFile(destination, lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf-8");
700
- }
701
- async function persistProjectLaunchRuntimeHistoryArtifacts(runtimeCodexHome, projectCodexHome) {
702
- if (!runtimeCodexHome || !projectCodexHome)
703
- return;
704
- if (!existsSync(runtimeCodexHome))
1128
+ async function persistProjectLaunchRuntimeJsonlArtifact(source, destination, sourceRoot, destinationRoot, sourceMode, expectedSourceStat) {
1129
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1130
+ const destinationInspection = await inspectProjectLaunchRuntimeHistoryEntry(destination);
1131
+ if (destinationInspection && !destinationInspection.targetStat?.isFile())
1132
+ return;
1133
+ const destinationPath = destinationInspection?.targetRealpath ?? destination;
1134
+ await assertHistoryPathWithin(destinationPath, destinationRoot, true);
1135
+ const destinationVersion = destinationInspection?.targetStat;
1136
+ const existing = destinationInspection ? await readHistoryFileWithoutSymlink(destinationPath, false, destinationRoot) : "";
1137
+ const sourceContents = await readHistoryFileWithoutSymlink(source, false, sourceRoot);
1138
+ const sourceStat = await stat(source);
1139
+ if (!hasSameHistoryVersion(sourceStat, expectedSourceStat)) {
1140
+ throw new HistoryEntryChangedError(`history source changed during JSONL read: ${source}`);
1141
+ }
1142
+ const latestDestinationInspection = await inspectProjectLaunchRuntimeHistoryEntry(destination);
1143
+ const latestDestinationVersion = latestDestinationInspection?.targetStat;
1144
+ if ((destinationVersion && !latestDestinationVersion) ||
1145
+ (!destinationVersion && latestDestinationVersion) ||
1146
+ (destinationVersion && latestDestinationVersion && !hasSameHistoryVersion(latestDestinationVersion, destinationVersion))) {
1147
+ continue;
1148
+ }
1149
+ const separator = existing === "" || existing.endsWith("\n") || sourceContents === "" ? "" : "\n";
1150
+ const lines = uniqueJsonlLines(`${existing}${separator}${sourceContents}`);
1151
+ const mode = destinationInspection?.targetStat?.mode ?? historyDestinationMode(sourceMode);
1152
+ await writeHistoryFileAtomically(destinationPath, lines.length > 0 ? `${lines.join("\n")}\n` : "", mode, destinationRoot);
705
1153
  return;
706
- await mkdir(projectCodexHome, { recursive: true });
1154
+ }
1155
+ throw new HistoryEntryChangedError(`history destination changed during JSONL persistence: ${destination}`);
1156
+ }
1157
+ async function resolveHistoryPersistenceLockRoot(projectCodexHome, destinationRoot) {
1158
+ const candidates = [];
707
1159
  for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
708
- const source = join(runtimeCodexHome, entryName);
709
- if (!existsSync(source))
710
- continue;
711
- const sourceStat = await lstat(source);
712
- if (sourceStat.isSymbolicLink())
713
- continue;
714
1160
  const destination = join(projectCodexHome, entryName);
715
- if (sourceStat.isDirectory()) {
716
- await cp(source, destination, { recursive: true, force: true, preserveTimestamps: true, verbatimSymlinks: true });
1161
+ const inspection = await inspectProjectLaunchRuntimeHistoryEntry(destination);
1162
+ const targetStat = inspection?.targetStat;
1163
+ const target = inspection?.targetRealpath ?? (targetStat ? await realpath(destination).catch(() => undefined) : undefined);
1164
+ if (!target)
717
1165
  continue;
1166
+ const anchors = [dirname(target), ...(targetStat?.isDirectory() ? [target] : [])];
1167
+ for (const anchor of anchors) {
1168
+ const canonicalAnchor = await realpath(anchor).catch(() => undefined);
1169
+ if (!canonicalAnchor)
1170
+ continue;
1171
+ if (await access(canonicalAnchor, fsConstants.W_OK).then(() => true, () => false)) {
1172
+ candidates.push(canonicalAnchor);
1173
+ break;
1174
+ }
718
1175
  }
719
- if (entryName === "history.jsonl" || entryName === "session_index.jsonl") {
720
- await persistProjectLaunchRuntimeJsonlArtifact(source, destination);
721
- continue;
722
- }
723
- if (sourceStat.isFile()) {
724
- await copyFilePreservingTimestamps(source, destination);
725
- }
1176
+ }
1177
+ return (candidates.length > 0 ? [...new Set(candidates)] : [destinationRoot]).sort();
1178
+ }
1179
+ async function withHistoryPersistenceLocks(roots, action) {
1180
+ const leases = [];
1181
+ try {
1182
+ for (const root of [...new Set(roots)].sort())
1183
+ leases.push(await acquireHistoryPersistenceLock(root));
1184
+ return await action();
1185
+ }
1186
+ finally {
1187
+ for (const lease of leases.reverse())
1188
+ await releaseHistoryPersistenceLock(lease);
726
1189
  }
727
1190
  }
728
- async function ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome) {
1191
+ async function persistProjectLaunchRuntimeHistoryArtifacts(runtimeCodexHome, projectCodexHome, expectedRuntimeStat) {
1192
+ if (!runtimeCodexHome || !projectCodexHome)
1193
+ return true;
1194
+ if (!existsSync(runtimeCodexHome))
1195
+ return true;
1196
+ await mkdir(projectCodexHome, { recursive: true });
1197
+ const destinationRoot = await realpath(projectCodexHome);
1198
+ const lockRoots = await resolveHistoryPersistenceLockRoot(projectCodexHome, destinationRoot);
1199
+ return withHistoryPersistenceLocks(lockRoots, async () => {
1200
+ let complete = true;
1201
+ for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
1202
+ if (expectedRuntimeStat) {
1203
+ const runtimeStat = await lstat(runtimeCodexHome).catch(() => undefined);
1204
+ if (!runtimeStat || !hasSameHistoryIdentity(runtimeStat, expectedRuntimeStat)) {
1205
+ complete = false;
1206
+ break;
1207
+ }
1208
+ }
1209
+ const source = join(runtimeCodexHome, entryName);
1210
+ const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
1211
+ if (!sourceInspection?.targetStat || !isRegularHistoryTarget(sourceInspection.targetStat))
1212
+ continue;
1213
+ const sourcePath = sourceInspection.targetRealpath ?? source;
1214
+ const sourceRoot = sourceInspection.targetRealpath ?? source;
1215
+ if (!(await historyEntryStillMatches(source, sourceInspection))) {
1216
+ complete = false;
1217
+ continue;
1218
+ }
1219
+ const destination = join(projectCodexHome, entryName);
1220
+ const destinationInspection = await inspectProjectLaunchRuntimeHistoryEntry(destination);
1221
+ if (destinationInspection && !destinationInspection.targetStat) {
1222
+ complete = false;
1223
+ continue;
1224
+ }
1225
+ if (destinationInspection && !isRegularHistoryTarget(destinationInspection.targetStat)) {
1226
+ complete = false;
1227
+ continue;
1228
+ }
1229
+ const destinationPath = destinationInspection?.targetRealpath ?? destination;
1230
+ const sourceCanonical = await realpath(sourcePath).catch((error) => {
1231
+ if (isDiscardableHistoryCopyError(error))
1232
+ return undefined;
1233
+ throw error;
1234
+ });
1235
+ const destinationCanonical = await realpath(destinationPath).catch((error) => {
1236
+ if (isDiscardableHistoryCopyError(error))
1237
+ return undefined;
1238
+ throw error;
1239
+ });
1240
+ if (sourceCanonical && destinationCanonical && sourceCanonical === destinationCanonical)
1241
+ continue;
1242
+ await assertHistoryPathWithin(destinationPath, destinationRoot, true);
1243
+ if (sourceInspection.targetStat.isDirectory()) {
1244
+ try {
1245
+ await copyProjectLaunchRuntimeHistoryDirectory(sourcePath, destinationPath, false, sourceRoot, destinationRoot, sourceInspection.targetStat);
1246
+ }
1247
+ catch (error) {
1248
+ if (isDiscardableHistoryCopyError(error)) {
1249
+ complete = false;
1250
+ continue;
1251
+ }
1252
+ throw error;
1253
+ }
1254
+ continue;
1255
+ }
1256
+ if (entryName === "history.jsonl" || entryName === "session_index.jsonl") {
1257
+ try {
1258
+ await persistProjectLaunchRuntimeJsonlArtifact(sourcePath, destinationPath, sourceRoot, destinationRoot, sourceInspection.targetStat.mode, sourceInspection.targetStat);
1259
+ }
1260
+ catch (error) {
1261
+ if (isDiscardableHistoryCopyError(error)) {
1262
+ complete = false;
1263
+ continue;
1264
+ }
1265
+ throw error;
1266
+ }
1267
+ continue;
1268
+ }
1269
+ if (sourceInspection.targetStat.isFile()) {
1270
+ try {
1271
+ await copyFilePreservingTimestamps(sourcePath, destinationPath, {
1272
+ sourceRoot,
1273
+ destinationRoot,
1274
+ expectedSourceStat: sourceInspection.targetStat,
1275
+ });
1276
+ }
1277
+ catch (error) {
1278
+ if (isDiscardableHistoryCopyError(error)) {
1279
+ complete = false;
1280
+ continue;
1281
+ }
1282
+ throw error;
1283
+ }
1284
+ }
1285
+ }
1286
+ return complete;
1287
+ });
1288
+ }
1289
+ async function ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome, skipEntryNames = new Set(), createSymlink = symlink) {
729
1290
  await mkdir(projectCodexHome, { recursive: true });
730
1291
  for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
1292
+ if (skipEntryNames.has(entryName))
1293
+ continue;
731
1294
  const runtimeEntry = join(runtimeCodexHome, entryName);
732
1295
  if (existsSync(runtimeEntry))
733
1296
  continue;
734
1297
  const projectEntry = join(projectCodexHome, entryName);
1298
+ const projectEntryInspection = await inspectProjectLaunchRuntimeHistoryEntry(projectEntry);
1299
+ if (projectEntryInspection &&
1300
+ (!projectEntryInspection.targetStat || !isRegularHistoryTarget(projectEntryInspection.targetStat))) {
1301
+ continue;
1302
+ }
735
1303
  if (entryName === "sessions") {
736
1304
  await mkdir(projectEntry, { recursive: true });
737
1305
  }
738
1306
  else if (!existsSync(projectEntry)) {
739
1307
  await writeFile(projectEntry, "");
740
1308
  }
741
- await linkOrCopyCodexHomeEntry(projectEntry, runtimeEntry);
1309
+ await linkOrCopyCodexHomeEntry(projectEntry, runtimeEntry, createSymlink);
742
1310
  }
743
1311
  }
744
- async function materializeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, sourceCodexHome) {
1312
+ async function materializeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, sourceCodexHome, options = {}) {
1313
+ const skipEntryNames = new Set();
1314
+ const destinationRoot = await realpath(runtimeCodexHome);
745
1315
  for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
746
1316
  const source = join(sourceCodexHome, entryName);
747
- if (!existsSync(source))
1317
+ const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
1318
+ if (!sourceInspection || !sourceInspection.targetStat)
1319
+ continue;
1320
+ if (options.onlySymlinks === true && !sourceInspection.linkStat.isSymbolicLink())
748
1321
  continue;
749
1322
  const destination = join(runtimeCodexHome, entryName);
1323
+ await options.beforeCopy?.(entryName, source, destination);
1324
+ if (!(await historyEntryStillMatches(source, sourceInspection))) {
1325
+ skipEntryNames.add(entryName);
1326
+ continue;
1327
+ }
1328
+ await options.afterValidation?.(entryName, source, destination);
1329
+ if (!(await historyEntryStillMatches(source, sourceInspection))) {
1330
+ skipEntryNames.add(entryName);
1331
+ continue;
1332
+ }
1333
+ try {
1334
+ const sourceStat = sourceInspection.targetStat;
1335
+ if (!sourceStat)
1336
+ continue;
1337
+ const sourcePath = sourceInspection.targetRealpath ?? source;
1338
+ if (sourceStat.isDirectory()) {
1339
+ await replaceProjectLaunchRuntimeHistoryDirectory(sourcePath, destination, false, destinationRoot, sourceStat, options.afterStage
1340
+ ? (temporary, stagedDestination) => options.afterStage(entryName, temporary, stagedDestination)
1341
+ : undefined);
1342
+ continue;
1343
+ }
1344
+ if (sourceStat.isFile()) {
1345
+ await copyFilePreservingTimestamps(sourcePath, destination, {
1346
+ allowTopLevelSymlink: false,
1347
+ sourceRoot: sourcePath,
1348
+ destinationRoot,
1349
+ expectedSourceStat: sourceStat,
1350
+ afterStage: options.afterStage
1351
+ ? (temporary, stagedDestination) => options.afterStage(entryName, temporary, stagedDestination)
1352
+ : undefined,
1353
+ afterSourceOpen: options.afterSourceOpen
1354
+ ? () => options.afterSourceOpen(entryName, sourcePath)
1355
+ : undefined,
1356
+ });
1357
+ }
1358
+ }
1359
+ catch (error) {
1360
+ if (isDiscardableHistoryCopyError(error)) {
1361
+ skipEntryNames.add(entryName);
1362
+ continue;
1363
+ }
1364
+ throw error;
1365
+ }
1366
+ }
1367
+ return skipEntryNames;
1368
+ }
1369
+ async function copyProjectLaunchRuntimeHistoryDirectory(source, destination, allowTopLevelSymlink = false, sourceRoot = source, destinationRoot = destination, expectedSourceStat) {
1370
+ sourceRoot = await realpath(sourceRoot);
1371
+ destinationRoot = await realpath(destinationRoot);
1372
+ const sourceStat = allowTopLevelSymlink ? await stat(source) : await lstat(source);
1373
+ if (!sourceStat.isDirectory() || (!allowTopLevelSymlink && sourceStat.isSymbolicLink()))
1374
+ return;
1375
+ if (expectedSourceStat && !hasSameHistoryIdentity(sourceStat, expectedSourceStat)) {
1376
+ throw new HistoryEntryChangedError(`history source changed during directory copy: ${source}`);
1377
+ }
1378
+ await assertHistoryPathWithin(source, sourceRoot);
1379
+ const destinationStat = await lstat(destination).catch((error) => {
1380
+ if (isUnresolvableHistoryLinkError(error))
1381
+ return undefined;
1382
+ throw error;
1383
+ });
1384
+ if (destinationStat && (!destinationStat.isDirectory() || destinationStat.isSymbolicLink())) {
750
1385
  await rm(destination, { recursive: true, force: true });
751
- const sourceStat = await lstat(source);
752
- if (sourceStat.isDirectory()) {
753
- await cp(source, destination, { recursive: true, force: true, dereference: true, preserveTimestamps: true });
1386
+ }
1387
+ await assertHistoryDestinationParentWithin(destination, destinationRoot);
1388
+ const destinationMode = destinationStat && destinationStat.isDirectory() && !destinationStat.isSymbolicLink()
1389
+ ? destinationStat.mode & historyDestinationMode(sourceStat.mode) & 0o7777
1390
+ : undefined;
1391
+ const destinationWasExistingDirectory = Boolean(destinationStat && destinationStat.isDirectory() && !destinationStat.isSymbolicLink());
1392
+ const sourceDestinationMode = historyDestinationMode(sourceStat.mode);
1393
+ await mkdir(destination, { recursive: true, mode: destinationMode ?? sourceDestinationMode });
1394
+ await assertHistoryDestinationDirectorySafe(destination, destinationRoot);
1395
+ await chmodHistoryDestination(destination, (destinationMode ?? sourceDestinationMode) | 0o700, destinationWasExistingDirectory);
1396
+ for (const entry of await readdir(source, { withFileTypes: true })) {
1397
+ if (entry.name === ".omx-history.lock" || entry.name.startsWith(".omx-history.lock."))
1398
+ continue;
1399
+ const sourceEntry = join(source, entry.name);
1400
+ const destinationEntry = join(destination, entry.name);
1401
+ const entryStat = await lstat(sourceEntry).catch((error) => {
1402
+ if (isUnresolvableHistoryLinkError(error))
1403
+ return undefined;
1404
+ throw error;
1405
+ });
1406
+ if (!entryStat || entryStat.isSymbolicLink())
754
1407
  continue;
1408
+ if (entryStat.isDirectory()) {
1409
+ await copyProjectLaunchRuntimeHistoryDirectory(sourceEntry, destinationEntry, false, sourceRoot, destinationRoot, entryStat);
1410
+ }
1411
+ else if (entryStat.isFile()) {
1412
+ await copyFilePreservingTimestamps(sourceEntry, destinationEntry, {
1413
+ sourceRoot,
1414
+ destinationRoot,
1415
+ expectedSourceStat: entryStat,
1416
+ });
1417
+ }
1418
+ }
1419
+ await assertHistoryDestinationDirectorySafe(destination, destinationRoot);
1420
+ await chmodHistoryDestination(destination, destinationMode ?? sourceDestinationMode, destinationWasExistingDirectory);
1421
+ await assertHistoryDestinationDirectorySafe(destination, destinationRoot);
1422
+ await utimesHistoryDestination(destination, sourceStat.atime, sourceStat.mtime, destinationWasExistingDirectory);
1423
+ }
1424
+ async function replaceProjectLaunchRuntimeHistoryDirectory(source, destination, allowTopLevelSymlink, destinationRoot, expectedSourceStat, afterStage) {
1425
+ const temporary = `${destination}.omx-history-${randomUUID()}`;
1426
+ try {
1427
+ const sourceStat = allowTopLevelSymlink ? await stat(source) : await lstat(source);
1428
+ if (!sourceStat.isDirectory() || (!allowTopLevelSymlink && sourceStat.isSymbolicLink())) {
1429
+ throw new Error(`history source is not a directory: ${source}`);
1430
+ }
1431
+ if (!hasSameHistoryIdentity(sourceStat, expectedSourceStat)) {
1432
+ throw new HistoryEntryChangedError(`history source changed during directory publication: ${source}`);
1433
+ }
1434
+ await copyProjectLaunchRuntimeHistoryDirectory(source, temporary, allowTopLevelSymlink, source, destinationRoot, sourceStat);
1435
+ await afterStage?.(temporary, destination);
1436
+ const stagedStat = await lstat(temporary);
1437
+ if (!stagedStat.isDirectory() || stagedStat.isSymbolicLink()) {
1438
+ throw new Error(`history staging path is not a directory: ${temporary}`);
755
1439
  }
756
- await copyFilePreservingTimestamps(source, destination);
1440
+ await assertHistoryDestinationParentWithin(destination, destinationRoot);
1441
+ await publishHistoryReplacement(temporary, destination, true);
1442
+ }
1443
+ finally {
1444
+ await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
757
1445
  }
758
1446
  }
759
1447
  async function mergeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, sourceCodexHome, mergedHistorySourceRealpaths) {
1448
+ const destinationRoot = await realpath(runtimeCodexHome);
760
1449
  for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
761
1450
  const source = join(sourceCodexHome, entryName);
762
- if (!existsSync(source))
1451
+ const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
1452
+ if (!sourceInspection?.targetStat || !isRegularHistoryTarget(sourceInspection.targetStat))
1453
+ continue;
1454
+ const sourceRealpath = sourceInspection.targetRealpath ??
1455
+ await realpath(source).catch((error) => {
1456
+ if (isUnresolvableHistoryLinkError(error))
1457
+ return undefined;
1458
+ throw error;
1459
+ });
1460
+ if (!sourceRealpath)
763
1461
  continue;
764
- const sourceRealpath = realpathSync(source);
765
1462
  if (mergedHistorySourceRealpaths.has(sourceRealpath))
766
1463
  continue;
1464
+ if (!(await historyEntryStillMatches(source, sourceInspection)))
1465
+ continue;
767
1466
  const destination = join(runtimeCodexHome, entryName);
768
- const sourceStat = await stat(source);
1467
+ const sourceStat = sourceInspection.targetStat;
1468
+ if (!sourceStat)
1469
+ continue;
769
1470
  if (sourceStat.isDirectory()) {
1471
+ const destinationStat = await lstat(destination).catch((error) => {
1472
+ if (isUnresolvableHistoryLinkError(error))
1473
+ return undefined;
1474
+ throw error;
1475
+ });
1476
+ if (destinationStat && (!destinationStat.isDirectory() || destinationStat.isSymbolicLink())) {
1477
+ await rm(destination, { recursive: true, force: true });
1478
+ }
770
1479
  await mkdir(destination, { recursive: true });
771
- await cp(source, destination, { recursive: true, force: true, dereference: true, preserveTimestamps: true });
1480
+ try {
1481
+ await copyProjectLaunchRuntimeHistoryDirectory(sourceInspection.targetRealpath ?? source, destination, false, sourceInspection.targetRealpath ?? source, destinationRoot, sourceStat);
1482
+ }
1483
+ catch (error) {
1484
+ if (isDiscardableHistoryCopyError(error))
1485
+ continue;
1486
+ throw error;
1487
+ }
772
1488
  mergedHistorySourceRealpaths.add(sourceRealpath);
773
1489
  continue;
774
1490
  }
@@ -776,22 +1492,70 @@ async function mergeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, sourceC
776
1492
  continue;
777
1493
  if (!sourceStat.isFile())
778
1494
  continue;
779
- if (existsSync(destination)) {
780
- const destinationStat = await stat(destination);
1495
+ const destinationLinkStat = await lstat(destination).catch((error) => {
1496
+ if (isUnresolvableHistoryLinkError(error))
1497
+ return undefined;
1498
+ throw error;
1499
+ });
1500
+ if (destinationLinkStat?.isSymbolicLink()) {
1501
+ await rm(destination, { force: true });
1502
+ }
1503
+ if (destinationLinkStat && !destinationLinkStat.isSymbolicLink()) {
1504
+ const destinationStat = destinationLinkStat;
781
1505
  if (!destinationStat.isFile()) {
782
1506
  await rm(destination, { recursive: true, force: true });
783
- await copyFilePreservingTimestamps(source, destination);
1507
+ try {
1508
+ await copyFilePreservingTimestamps(sourceInspection.targetRealpath ?? source, destination, {
1509
+ allowTopLevelSymlink: false,
1510
+ sourceRoot: sourceInspection.targetRealpath ?? source,
1511
+ destinationRoot,
1512
+ expectedSourceStat: sourceStat,
1513
+ });
1514
+ }
1515
+ catch (error) {
1516
+ if (isDiscardableHistoryCopyError(error))
1517
+ continue;
1518
+ throw error;
1519
+ }
784
1520
  mergedHistorySourceRealpaths.add(sourceRealpath);
785
1521
  continue;
786
1522
  }
787
- const existing = await readFile(destination, "utf-8").catch(() => "");
788
- const addition = await readFile(source, "utf-8");
1523
+ let existing;
1524
+ let addition;
1525
+ try {
1526
+ existing = await readHistoryFileWithoutSymlink(destination, false, destinationRoot);
1527
+ addition = await readHistoryFileWithoutSymlink(sourceInspection.targetRealpath ?? source, false, sourceInspection.targetRealpath ?? source);
1528
+ }
1529
+ catch (error) {
1530
+ if (isDiscardableHistoryCopyError(error))
1531
+ continue;
1532
+ throw error;
1533
+ }
789
1534
  const separator = existing === "" || existing.endsWith("\n") || addition === "" ? "" : "\n";
790
- await writeFile(destination, `${existing}${separator}${addition}`, "utf-8");
1535
+ try {
1536
+ await writeHistoryFileAtomically(destination, `${existing}${separator}${addition}`, destinationStat.mode & 0o7777, destinationRoot);
1537
+ }
1538
+ catch (error) {
1539
+ if (isDiscardableHistoryCopyError(error))
1540
+ continue;
1541
+ throw error;
1542
+ }
791
1543
  mergedHistorySourceRealpaths.add(sourceRealpath);
792
1544
  continue;
793
1545
  }
794
- await copyFilePreservingTimestamps(source, destination);
1546
+ try {
1547
+ await copyFilePreservingTimestamps(sourceInspection.targetRealpath ?? source, destination, {
1548
+ allowTopLevelSymlink: false,
1549
+ sourceRoot: sourceInspection.targetRealpath ?? source,
1550
+ destinationRoot,
1551
+ expectedSourceStat: sourceStat,
1552
+ });
1553
+ }
1554
+ catch (error) {
1555
+ if (isDiscardableHistoryCopyError(error))
1556
+ continue;
1557
+ throw error;
1558
+ }
795
1559
  mergedHistorySourceRealpaths.add(sourceRealpath);
796
1560
  }
797
1561
  }
@@ -812,7 +1576,7 @@ export async function prepareRuntimeCodexHomeForProjectLaunch(cwd, sessionId, pr
812
1576
  await rm(runtimeCodexHome, { recursive: true, force: true });
813
1577
  await mkdir(runtimeCodexHome, { recursive: true });
814
1578
  if (!existsSync(projectCodexHome)) {
815
- await ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome);
1579
+ await ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome, new Set(), options.createSymlink);
816
1580
  return runtimeCodexHome;
817
1581
  }
818
1582
  for (const entry of await readdir(projectCodexHome, { withFileTypes: true })) {
@@ -822,6 +1586,16 @@ export async function prepareRuntimeCodexHomeForProjectLaunch(cwd, sessionId, pr
822
1586
  continue;
823
1587
  const source = join(projectCodexHome, entry.name);
824
1588
  const destination = join(runtimeCodexHome, entry.name);
1589
+ if (PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES.has(entry.name)) {
1590
+ const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
1591
+ if (!sourceInspection?.targetStat)
1592
+ continue;
1593
+ if (options.includeHistoryArtifacts === true) {
1594
+ if (!isRegularHistoryTarget(sourceInspection.targetStat))
1595
+ continue;
1596
+ continue;
1597
+ }
1598
+ }
825
1599
  if (entry.name === "config.toml") {
826
1600
  const projectHooksPath = join(projectCodexHome, "hooks.json");
827
1601
  const projectConfig = await readFile(source, "utf-8");
@@ -838,19 +1612,37 @@ export async function prepareRuntimeCodexHomeForProjectLaunch(cwd, sessionId, pr
838
1612
  }
839
1613
  await linkOrCopyCodexHomeEntry(source, destination);
840
1614
  }
841
- await ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome);
842
- if (options.includeHistoryArtifacts === true && (options.extraHistoryCodexHomes?.length ?? 0) > 0) {
1615
+ let skippedHistoryEntryNames = new Set();
1616
+ if (options.includeHistoryArtifacts === true) {
1617
+ const extraHistoryCodexHomes = options.extraHistoryCodexHomes ?? [];
1618
+ const mergingHistory = extraHistoryCodexHomes.length > 0;
843
1619
  const mergedHistorySourceRealpaths = new Set();
844
1620
  for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
845
1621
  const source = join(projectCodexHome, entryName);
846
- if (existsSync(source))
847
- mergedHistorySourceRealpaths.add(realpathSync(source));
1622
+ const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
1623
+ if (!sourceInspection?.targetStat || !isRegularHistoryTarget(sourceInspection.targetStat))
1624
+ continue;
1625
+ const sourceRealpath = sourceInspection.targetRealpath ??
1626
+ await realpath(source).catch((error) => {
1627
+ if (isUnresolvableHistoryLinkError(error))
1628
+ return undefined;
1629
+ throw error;
1630
+ });
1631
+ if (sourceRealpath)
1632
+ mergedHistorySourceRealpaths.add(sourceRealpath);
848
1633
  }
849
- await materializeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, projectCodexHome);
850
- for (const extraCodexHome of options.extraHistoryCodexHomes ?? []) {
1634
+ skippedHistoryEntryNames = await materializeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, projectCodexHome, {
1635
+ onlySymlinks: !mergingHistory,
1636
+ beforeCopy: options.beforeHistoryEntryCopy,
1637
+ afterValidation: options.afterHistoryEntryValidation,
1638
+ afterStage: options.afterHistoryEntryStage,
1639
+ afterSourceOpen: options.afterHistorySourceOpen,
1640
+ });
1641
+ for (const extraCodexHome of extraHistoryCodexHomes) {
851
1642
  await mergeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, extraCodexHome, mergedHistorySourceRealpaths);
852
1643
  }
853
1644
  }
1645
+ await ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome, skippedHistoryEntryNames, options.createSymlink);
854
1646
  return runtimeCodexHome;
855
1647
  }
856
1648
  function resolveProjectSqliteHomeForLaunch(projectCodexHome, env) {
@@ -1013,6 +1805,8 @@ async function prepareResumeCodexHomeForLaunch(cwd, sessionId, args, env = proce
1013
1805
  args: selection.args,
1014
1806
  prepared: {
1015
1807
  codexHomeOverride: runtimeCodexHome,
1808
+ projectLocalCodexHomeForCleanup: projectHomes[0].path,
1809
+ runtimeCodexHomeForCleanup: runtimeCodexHome,
1016
1810
  },
1017
1811
  };
1018
1812
  }
@@ -1044,9 +1838,36 @@ export async function persistProjectLaunchRuntimeProjectTrustState(runtimeCodexH
1044
1838
  export async function cleanupRuntimeCodexHome(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence) {
1045
1839
  if (!runtimeCodexHomeForCleanup)
1046
1840
  return;
1841
+ const runtimeParent = dirname(runtimeCodexHomeForCleanup);
1842
+ const initialParentStat = await lstat(runtimeParent).catch(() => undefined);
1843
+ const initialParentRealpath = await realpath(runtimeParent).catch(() => undefined);
1844
+ const initialRuntimeStat = await lstat(runtimeCodexHomeForCleanup).catch(() => undefined);
1845
+ if (!initialParentStat || !initialParentRealpath || !initialRuntimeStat)
1846
+ return;
1847
+ if (initialParentStat.isSymbolicLink())
1848
+ return;
1849
+ try {
1850
+ await assertHistoryPathWithin(runtimeCodexHomeForCleanup, initialParentRealpath);
1851
+ }
1852
+ catch {
1853
+ return;
1854
+ }
1047
1855
  await persistProjectLaunchRuntimeAuthState(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence);
1048
- await persistProjectLaunchRuntimeHistoryArtifacts(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence);
1856
+ const historyPersisted = await persistProjectLaunchRuntimeHistoryArtifacts(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence, { dev: initialRuntimeStat.dev, ino: initialRuntimeStat.ino });
1857
+ if (!historyPersisted)
1858
+ return;
1049
1859
  await persistProjectLaunchRuntimeProjectTrustState(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence);
1860
+ const finalParentStat = await lstat(runtimeParent).catch(() => undefined);
1861
+ const finalParentRealpath = await realpath(runtimeParent).catch(() => undefined);
1862
+ const finalRuntimeStat = await lstat(runtimeCodexHomeForCleanup).catch(() => undefined);
1863
+ if (!finalParentStat ||
1864
+ finalParentStat.isSymbolicLink() ||
1865
+ !finalParentRealpath ||
1866
+ finalParentRealpath !== initialParentRealpath ||
1867
+ !hasSameHistoryIdentity(finalParentStat, initialParentStat) ||
1868
+ !finalRuntimeStat ||
1869
+ !hasSameHistoryIdentity(finalRuntimeStat, initialRuntimeStat))
1870
+ return;
1050
1871
  await rm(runtimeCodexHomeForCleanup, { recursive: true, force: true });
1051
1872
  }
1052
1873
  function execTmuxFileSync(args, options) {
@@ -1091,6 +1912,27 @@ export function parseDetachedLeaderPaneIdByPid(snapshot, leaderPid) {
1091
1912
  throw new Error("detached leader pane identity is unavailable");
1092
1913
  return matches[0];
1093
1914
  }
1915
+ function resolveDetachedLeaderPaneIdentity(sessionName, expectedTmuxSessionId, inheritedPane, platform, leaderPid, requireInheritedPane, probeInheritedPane, listPanes) {
1916
+ if (inheritedPane && /^%[0-9]+$/.test(inheritedPane)) {
1917
+ try {
1918
+ const [observedSessionName, observedSessionId, observedPaneId] = probeInheritedPane(inheritedPane).split("\t");
1919
+ if (observedSessionName === sessionName && observedPaneId === inheritedPane
1920
+ && (expectedTmuxSessionId === undefined || observedSessionId === expectedTmuxSessionId))
1921
+ return inheritedPane;
1922
+ }
1923
+ catch {
1924
+ // Fall through to the platform-specific resolution policy below.
1925
+ }
1926
+ }
1927
+ if (requireInheritedPane || platform === "win32") {
1928
+ throw new Error("detached leader inherited pane identity is unavailable");
1929
+ }
1930
+ return parseDetachedLeaderPaneIdByPid(listPanes(), leaderPid);
1931
+ }
1932
+ /** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
1933
+ export function resolveDetachedLeaderPaneForTest(options) {
1934
+ return resolveDetachedLeaderPaneIdentity(options.sessionName, options.expectedTmuxSessionId, options.inheritedPane, options.platform, options.leaderPid, options.requireInheritedPane, () => options.identityOutput, () => options.paneSnapshot);
1935
+ }
1094
1936
  function detachedLeaderAuthorityCondition(authority, requireOwner = true) {
1095
1937
  const conditions = [
1096
1938
  "#{==:#{pane_dead},0}",
@@ -1104,14 +1946,17 @@ function detachedLeaderAuthorityCondition(authority, requireOwner = true) {
1104
1946
  ];
1105
1947
  return conditions.reduce((combined, condition) => `#{&&:${combined},${condition}}`);
1106
1948
  }
1107
- function detachedHudAuthorityCondition(authority, requireMarker = true) {
1949
+ function detachedHudAuthorityCondition(authority, requireMarker = true, ownerId) {
1108
1950
  const conditions = [
1109
1951
  "#{==:#{pane_dead},0}",
1110
1952
  `#{==:#{pane_id},${authority.paneId}}`,
1111
1953
  `#{==:#{pane_pid},${authority.panePid}}`,
1954
+ `#{==:#{session_name},${authority.sessionName}}`,
1112
1955
  `#{==:#{session_id},${authority.sessionId}}`,
1956
+ `#{==:#{session_created},${authority.sessionCreated}}`,
1113
1957
  `#{==:#{window_id},${authority.windowId}}`,
1114
1958
  ...(requireMarker ? [`#{m:*OMX_DETACHED_HUD_OPERATION=${authority.operationMarker}*,#{pane_start_command}}`] : []),
1959
+ ...(ownerId ? [`#{==:#{@omx_hud_owner},${ownerId}}`] : []),
1115
1960
  ];
1116
1961
  return conditions.reduce((combined, condition) => `#{&&:${combined},${condition}}`);
1117
1962
  }
@@ -1146,26 +1991,167 @@ function runDetachedLeaderMutation(authority, args, requireOwner = true, retryMi
1146
1991
  const mutation = args.length >= 4 && args[0] === "set-option" ? `${args[0]} ${args.at(-2)}` : args[0] ?? "unknown";
1147
1992
  throw new Error(`detached leader authority blocked tmux mutation ${mutation}: ${describeDetachedLeaderAuthorityMismatch(authority)}`);
1148
1993
  }
1149
- function detachedPreReportCleanupCondition(authority) {
1994
+ function runDetachedLeaderTagMutation(authority, ownerId) {
1995
+ const receipt = detachedAuthorityReceipt();
1996
+ const phaseMarker = ["set-option", "-t", authority.sessionName, "@omx_detached_owner_tag_attempted", "1"].map(quoteShellArg).join(" ");
1997
+ const ownerTag = ["set-option", "-t", authority.sessionName, OMX_INSTANCE_OPTION, ownerId].map(quoteShellArg).join(" ");
1998
+ const success = `${phaseMarker} ; ${ownerTag} ; display-message -p ${quoteShellArg(receipt)}`;
1999
+ const output = execTmuxFileSync([
2000
+ "if-shell", "-F", "-t", authority.paneId, detachedLeaderAuthorityCondition(authority, false),
2001
+ success, "display-message -p ''",
2002
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2003
+ if (output !== receipt)
2004
+ throw new Error(`detached leader authority blocked tmux mutation tag-session: ${describeDetachedLeaderAuthorityMismatch(authority)}`);
2005
+ }
2006
+ /**
2007
+ * #3578: session-scoped fence for pre-report cleanup when the leader pane no
2008
+ * longer exists at all. A normal leader exit with `remain-on-exit off` (set by
2009
+ * the tag-session step) removes the pane, so the pane-dead predicate above can
2010
+ * never match and rollback leaked a HUD-only session. The replacement fence is
2011
+ * evaluated against the SESSION (a server-scoped stable identity), not a pane:
2012
+ * exact session_name + session_id + session_created, plus either this launch's
2013
+ * @omx_instance_id owner tag or the empty owner state that exists before the
2014
+ * tag-session step succeeds. A foreign tag is never accepted. The leader-pane
2015
+ * probe is retained as a fail-closed existence check, but is not reused as a
2016
+ * pane-targeted destructive sink; a replacement session has a different
2017
+ * session_id/session_created and fails the session fence.
2018
+ */
2019
+ function detachedPreReportSessionCleanupCondition(authority, allowUnownedPreTag, requireCapturedDeadPane) {
2020
+ // The tag-session step is the first mutation after new-session. A bootstrap
2021
+ // failure can therefore leave a retained dead leader pane in a session whose
2022
+ // exact identity is ours but whose owner option is still unset. When the
2023
+ // caller has authenticated the pre-tag phase, accept only that empty state
2024
+ // or our expected tag; otherwise require our expected tag. A foreign tag
2025
+ // remains a hard denial. Session id + creation time prevent a name-reused
2026
+ // session from satisfying the pre-tag branch.
2027
+ const owner = allowUnownedPreTag
2028
+ ? `#{||:#{==:#{@omx_instance_id},${authority.ownerId}},#{==:#{@omx_instance_id},}}`
2029
+ : `#{==:#{@omx_instance_id},${authority.ownerId}}`;
1150
2030
  const conditions = [
1151
- "#{==:#{pane_dead},1}",
1152
- `#{==:#{pane_id},${authority.paneId}}`,
1153
2031
  `#{==:#{session_name},${authority.sessionName}}`,
1154
2032
  `#{==:#{session_id},${authority.sessionId}}`,
1155
2033
  `#{==:#{session_created},${authority.sessionCreated}}`,
1156
- `#{==:#{window_id},${authority.windowId}}`,
2034
+ owner,
1157
2035
  ];
2036
+ if (requireCapturedDeadPane) {
2037
+ // The session target resolves the active pane without using a pane target.
2038
+ // If the captured dead pane was respawned, its PID or liveness changes and
2039
+ // this single queued tmux predicate refuses the destructive sink. If a
2040
+ // different pane is active, preserving is the only safe result.
2041
+ conditions.push(`#{==:#{pane_id},${authority.paneId}}`, `#{==:#{pane_pid},${authority.panePid}}`, "#{==:#{pane_dead},1}");
2042
+ }
1158
2043
  return conditions.reduce((combined, condition) => `#{&&:${combined},${condition}}`);
1159
2044
  }
1160
- export function cleanupDetachedPreReportSession(authority) {
2045
+ function detachedPreReportLeaderPaneState(authority) {
2046
+ // Enumerate the exact named session and classify the captured pane before any
2047
+ // destructive command. A live captured leader is always preserved: a report
2048
+ // can arrive after the final file read and before a separate tmux command,
2049
+ // so timeout-based cleanup must never kill a pane that is still running.
2050
+ // Enumeration failures (notably the exact session no longer exists) are
2051
+ // fail-closed: unknown topology preserves without mutation.
2052
+ try {
2053
+ const sessionTarget = authority.sessionName;
2054
+ const output = execTmuxFileSync(["list-panes", "-s", "-t", sessionTarget, "-F", "#{pane_id}\t#{pane_dead}"], {
2055
+ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
2056
+ });
2057
+ const row = output.split("\n").map((line) => line.trim()).filter(Boolean).find((line) => line.split("\t")[0] === authority.paneId);
2058
+ if (!row)
2059
+ return "absent";
2060
+ const paneDead = row.split("\t")[1];
2061
+ if (paneDead === "1")
2062
+ return "dead";
2063
+ if (paneDead === "0")
2064
+ return "live";
2065
+ return "unknown";
2066
+ }
2067
+ catch {
2068
+ return "unknown";
2069
+ }
2070
+ }
2071
+ function detachedSessionExists(sessionName) {
2072
+ try {
2073
+ execTmuxFileSync(["has-session", "-t", sessionName], {
2074
+ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
2075
+ });
2076
+ return true;
2077
+ }
2078
+ catch {
2079
+ return false;
2080
+ }
2081
+ }
2082
+ function cleanupDetachedPreReportSessionInternal(authority, afterTopologyProbe, allowUnownedPreTag = false, authenticatedReadyOrTerminalProbe, afterAuthenticatedReadyOrTerminalProbe) {
1161
2083
  const receipt = detachedAuthorityReceipt();
1162
- const success = `kill-session -t ${quoteShellArg(authority.sessionName)} ; display-message -p ${quoteShellArg(receipt)}`;
1163
- const output = execTmuxFileSync([
1164
- "if-shell", "-F", "-t", authority.paneId, detachedPreReportCleanupCondition(authority),
2084
+ const sessionTarget = authority.sessionName;
2085
+ const success = `kill-session -t ${quoteShellArg(sessionTarget)} ; display-message -p ${quoteShellArg(receipt)}`;
2086
+ // #3562/#3578: probe the exact session topology for diagnostics, but never
2087
+ // feed the captured pane id into the destructive sink. The probe is
2088
+ // non-atomic; a leader pane can disappear or be reused before the sink runs.
2089
+ // The sink therefore targets only the session name and rechecks the complete
2090
+ // session identity plus the authenticated owner/pre-tag fence in the same
2091
+ // tmux command queue.
2092
+ const paneState = detachedPreReportLeaderPaneState(authority);
2093
+ if (paneState === "unknown")
2094
+ throw new Error("detached pre-report topology changed before cleanup");
2095
+ afterTopologyProbe?.();
2096
+ const authenticatedReady = authenticatedReadyOrTerminalProbe?.() === true;
2097
+ afterAuthenticatedReadyOrTerminalProbe?.();
2098
+ if (authenticatedReady) {
2099
+ throw new Error("detached leader became ready or terminal before pre-report cleanup");
2100
+ }
2101
+ if (paneState === "live")
2102
+ return "preserved-live";
2103
+ const sessionScoped = execTmuxFileSync([
2104
+ "if-shell", "-F", "-t", sessionTarget, detachedPreReportSessionCleanupCondition(authority, allowUnownedPreTag, paneState === "dead"),
1165
2105
  success, "display-message -p ''",
1166
2106
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1167
- if (output !== receipt)
2107
+ if (sessionScoped !== receipt)
1168
2108
  throw new Error("detached pre-report topology changed before cleanup");
2109
+ return "cleaned";
2110
+ }
2111
+ function cleanupDetachedPreReportSessionWithRetry(authority, allowUnownedPreTag, authenticatedReadyOrTerminalProbe, authenticatedFailedReportProbe, retryAuthenticatedFailure = true) {
2112
+ let result;
2113
+ try {
2114
+ result = cleanupDetachedPreReportSessionInternal(authority, undefined, allowUnownedPreTag, authenticatedReadyOrTerminalProbe);
2115
+ }
2116
+ catch (error) {
2117
+ // A failed leader may have already removed its own exact session before
2118
+ // the parent reaches rollback. Only an authenticated failed report permits
2119
+ // acknowledging that disappearance as completed cleanup.
2120
+ if (authenticatedFailedReportProbe?.() && !detachedSessionExists(authority.sessionName))
2121
+ return "cleaned";
2122
+ throw error;
2123
+ }
2124
+ if (result === "cleaned" || !retryAuthenticatedFailure || !authenticatedFailedReportProbe?.())
2125
+ return result;
2126
+ // A failed report may be published while the leader pane is still live. Do
2127
+ // not treat that observation as successful cleanup: retain the marker and
2128
+ // retry the identity-fenced decision after the leader exits. A later ready,
2129
+ // terminal, respawn, or ownership change fails closed inside the retry.
2130
+ const deadline = Date.now() + 30_000;
2131
+ while (Date.now() < deadline) {
2132
+ blockMs(20);
2133
+ try {
2134
+ result = cleanupDetachedPreReportSessionInternal(authority, undefined, allowUnownedPreTag, authenticatedReadyOrTerminalProbe);
2135
+ }
2136
+ catch (error) {
2137
+ if (authenticatedFailedReportProbe() && !detachedSessionExists(authority.sessionName))
2138
+ return "cleaned";
2139
+ throw error;
2140
+ }
2141
+ if (result === "cleaned")
2142
+ return result;
2143
+ }
2144
+ throw new Error("detached leader remained live before pre-report cleanup retry");
2145
+ }
2146
+ export function cleanupDetachedPreReportSession(authority, allowUnownedPreTag = false, authenticatedReadyOrTerminalProbe, authenticatedFailedReportProbe, retryAuthenticatedFailure = true) {
2147
+ return cleanupDetachedPreReportSessionWithRetry(authority, allowUnownedPreTag, authenticatedReadyOrTerminalProbe, authenticatedFailedReportProbe, retryAuthenticatedFailure);
2148
+ }
2149
+ /** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
2150
+ export function cleanupDetachedPreReportSessionForTest(authority, afterTopologyProbe, allowUnownedPreTag = false, authenticatedReadyOrTerminalProbe, afterAuthenticatedReadyOrTerminalProbe, retryAfterLive = false, authenticatedFailedReportProbe) {
2151
+ const result = cleanupDetachedPreReportSessionInternal(authority, afterTopologyProbe, allowUnownedPreTag, authenticatedReadyOrTerminalProbe, afterAuthenticatedReadyOrTerminalProbe);
2152
+ if (retryAfterLive && result === "preserved-live") {
2153
+ cleanupDetachedPreReportSessionWithRetry(authority, allowUnownedPreTag, authenticatedReadyOrTerminalProbe, authenticatedFailedReportProbe);
2154
+ }
1169
2155
  }
1170
2156
  function runDetachedHudMutation(leaderAuthority, hudAuthority, args, requireMarker = true) {
1171
2157
  const receipt = detachedAuthorityReceipt();
@@ -1178,47 +2164,151 @@ function runDetachedHudMutation(leaderAuthority, hudAuthority, args, requireMark
1178
2164
  if (output !== receipt)
1179
2165
  throw new Error("detached leader or HUD authority changed before tmux mutation");
1180
2166
  }
1181
- export function cleanupDetachedHudPane(authority, ownerId) {
1182
- if (ownerId) {
1183
- execTmuxFileSync(["kill-pane", "-t", authority.paneId], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
1184
- }
1185
- else {
1186
- execTmuxFileSync([
1187
- "if-shell", "-F", "-t", authority.paneId, detachedHudAuthorityCondition(authority, false),
1188
- `kill-pane -t ${quoteShellArg(authority.paneId)}`, "",
2167
+ function detachedHudPaneProofFormat() {
2168
+ // The trailing literal sentinel keeps an empty @omx_hud_owner from being
2169
+ // trimmed away as trailing whitespace by tmux's format output.
2170
+ return "#{pane_id}\t#{pane_pid}\t#{session_name}\t#{session_id}\t#{session_created}\t#{window_id}\t#{pane_dead}\t#{@omx_hud_owner}\t#{pane_start_command}\tEND";
2171
+ }
2172
+ function parseDetachedHudPaneProofLine(line) {
2173
+ const fields = line.split("\t");
2174
+ // Seven fixed fields, then @omx_hud_owner, then pane_start_command (which may
2175
+ // itself contain TAB bytes), then the literal END sentinel.
2176
+ if (fields.length < 10 || fields.at(-1) !== "END")
2177
+ return undefined;
2178
+ const [paneId, panePid, sessionName, sessionId, sessionCreated, windowId, paneDead, hudOwner] = fields;
2179
+ const paneStartCommand = fields.slice(8, -1).join("\t");
2180
+ return {
2181
+ paneId: paneId ?? "", panePid: panePid ?? "", sessionName: sessionName ?? "",
2182
+ sessionId: sessionId ?? "", sessionCreated: sessionCreated ?? "", windowId: windowId ?? "",
2183
+ paneDead: paneDead ?? "", paneStartCommand, hudOwner: hudOwner ?? "",
2184
+ };
2185
+ }
2186
+ function matchesDetachedHudAuthority(proof, authority, ownerId) {
2187
+ if (proof.paneId !== authority.paneId)
2188
+ return false;
2189
+ if (Number(proof.panePid) !== authority.panePid)
2190
+ return false;
2191
+ if (proof.paneDead !== "0")
2192
+ return false;
2193
+ if (proof.sessionName !== authority.sessionName)
2194
+ return false;
2195
+ if (proof.sessionId !== authority.sessionId)
2196
+ return false;
2197
+ if (proof.sessionCreated !== authority.sessionCreated)
2198
+ return false;
2199
+ if (proof.windowId !== authority.windowId)
2200
+ return false;
2201
+ // Marker non-vacuity: tmux reports pane_start_command wrapped in double
2202
+ // quotes; the command must begin with the exact env assignment the
2203
+ // production split sink injects. A pane whose command merely contains the
2204
+ // marker text (e.g. an echo) is not the HUD.
2205
+ const markerPrefix = `env OMX_DETACHED_HUD_OPERATION=${authority.operationMarker} `;
2206
+ const startCommand = proof.paneStartCommand.replace(/^"|"$/g, "");
2207
+ if (!startCommand.startsWith(markerPrefix))
2208
+ return false;
2209
+ if (ownerId !== undefined && proof.hudOwner !== ownerId)
2210
+ return false;
2211
+ return true;
2212
+ }
2213
+ /**
2214
+ * Enumerate the exact expected HUD pane inside its proven session scope and
2215
+ * evaluate the full proof locally. The tmux enumeration target is the session
2216
+ * name (a server-scoped stable identity), never the pane id: a stale or
2217
+ * missing pane proof must resolve to an ordinary fail-closed preserve result
2218
+ * instead of an `if-shell -t <stale pane>` evaluation that can tear down the
2219
+ * server with the leader and any replacement/foreign panes (#3562).
2220
+ * `-s` widens the enumeration to every window of the session: an attached user
2221
+ * may select another window, and a window-scoped listing would hide a fully
2222
+ * matching HUD in the original window (#3562 review).
2223
+ *
2224
+ * Preserve (undefined) on malformed enumeration output and any tmux command
2225
+ * failure; callers preserve on zero or multiple authority matches.
2226
+ */
2227
+ function listDetachedHudPaneProofsInSession(authority) {
2228
+ try {
2229
+ const output = execTmuxFileSync([
2230
+ "list-panes", "-s", "-t", authority.sessionName, "-F", detachedHudPaneProofFormat(),
1189
2231
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
2232
+ const proofs = [];
2233
+ for (const line of output.split("\n")) {
2234
+ const trimmed = line.trim();
2235
+ if (!trimmed)
2236
+ continue;
2237
+ const proof = parseDetachedHudPaneProofLine(trimmed);
2238
+ if (!proof)
2239
+ return undefined;
2240
+ proofs.push(proof);
2241
+ }
2242
+ return proofs;
2243
+ }
2244
+ catch {
2245
+ return undefined;
2246
+ }
2247
+ }
2248
+ function removeDetachedHudPaneIfAuthorized(authority, ownerId) {
2249
+ const proofs = listDetachedHudPaneProofsInSession(authority);
2250
+ if (!proofs)
2251
+ return false;
2252
+ const matching = proofs.filter((proof) => matchesDetachedHudAuthority(proof, authority, ownerId));
2253
+ if (matching.length !== 1)
2254
+ return false;
2255
+ const receipt = detachedAuthorityReceipt();
2256
+ const killed = `killed:${receipt}`;
2257
+ const mismatched = `mismatch:${receipt}`;
2258
+ try {
2259
+ const output = execTmuxFileSync([
2260
+ "if-shell", "-F", "-t", matching[0].paneId,
2261
+ detachedHudAuthorityCondition(authority, true, ownerId),
2262
+ `kill-pane -t ${quoteShellArg(matching[0].paneId)} ; display-message -p ${quoteShellArg(killed)}`,
2263
+ `display-message -p ${quoteShellArg(mismatched)}`,
2264
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2265
+ return output === killed;
2266
+ }
2267
+ catch {
2268
+ return false;
1190
2269
  }
2270
+ }
2271
+ function awaitDetachedHudPaneRemoval(paneId) {
1191
2272
  for (let attempt = 0; attempt < 50; attempt += 1) {
1192
2273
  const panes = execTmuxFileSync(["list-panes", "-a", "-F", "#{pane_id}"], {
1193
2274
  encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
1194
2275
  }).split("\n").map((value) => value.trim()).filter(Boolean);
1195
- if (!panes.includes(authority.paneId))
2276
+ if (!panes.includes(paneId))
1196
2277
  return;
1197
2278
  blockMs(20);
1198
2279
  }
1199
2280
  throw new Error("detached HUD topology changed before teardown");
1200
2281
  }
2282
+ export function cleanupDetachedHudPane(authority, ownerId) {
2283
+ if (!removeDetachedHudPaneIfAuthorized(authority, ownerId)) {
2284
+ // A stale, missing, or changed proof is an ordinary fail-closed preserve
2285
+ // result. Throwing here would let callers escalate a benign mismatch into
2286
+ // broader cleanup; the HUD pane and its session are left untouched.
2287
+ return false;
2288
+ }
2289
+ awaitDetachedHudPaneRemoval(authority.paneId);
2290
+ return true;
2291
+ }
2292
+ /**
2293
+ * Remove the bootstrap HUD after an authenticated finalized leader result.
2294
+ *
2295
+ * The outer launcher retained the original HUD proof while creating the pane.
2296
+ * Revalidate that proof and its owner tag at the tmux mutation sink so failure
2297
+ * cleanup stays fail-closed when a pane is replaced or taken over.
2298
+ */
2299
+ export function cleanupFinalizedDetachedHud(expected, ownerId) {
2300
+ if (!expected)
2301
+ return false;
2302
+ if (!removeDetachedHudPaneIfAuthorized(expected, ownerId))
2303
+ return false;
2304
+ awaitDetachedHudPaneRemoval(expected.paneId);
2305
+ return true;
2306
+ }
1201
2307
  function tagDetachedHudPane(leaderAuthority, authority, ownerId) {
1202
2308
  runDetachedHudMutation(leaderAuthority, authority, [
1203
2309
  "set-option", "-pq", "-t", authority.paneId, "@omx_hud_owner", ownerId,
1204
2310
  ], false);
1205
2311
  }
1206
- export function discoverDetachedHudAuthority(sessionName, ownerId) {
1207
- const rows = execTmuxFileSync([
1208
- "list-panes", "-a", "-F",
1209
- "#{pane_id}\t#{pane_dead}\t#{pane_pid}\t#{session_name}\t#{session_id}\t#{session_created}\t#{window_id}\t#{@omx_hud_owner}",
1210
- ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
1211
- const matches = rows.split("\n").map((line) => line.trim()).filter(Boolean).flatMap((line) => {
1212
- const [paneId, paneDead, panePidRaw, discoveredSessionName, sessionId, sessionCreated, windowId, owner] = line.split("\t");
1213
- const panePid = Number(panePidRaw);
1214
- if (paneDead !== "0" || discoveredSessionName !== sessionName || owner !== ownerId || !/^%\d+$/.test(paneId ?? "") || !/^\$\d+$/.test(sessionId ?? "") || !/^\d+$/.test(sessionCreated ?? "") || !/^@\d+$/.test(windowId ?? "") || !Number.isSafeInteger(panePid) || panePid <= 0)
1215
- return [];
1216
- return [{ paneId: paneId, panePid, sessionId: sessionId, sessionCreated: sessionCreated, windowId: windowId, operationMarker: randomUUID() }];
1217
- });
1218
- if (matches.length > 1)
1219
- throw new Error("multiple detached HUD ownership tags found");
1220
- return matches[0];
1221
- }
1222
2312
  function buildDeferredDetachedHudGuard(leaderAuthority, hudAuthority, command) {
1223
2313
  const deferredReceipt = detachedAuthorityReceipt();
1224
2314
  let guardedSinkCount = 0;
@@ -1281,20 +2371,20 @@ function runDetachedLeaderSplit(authority, args) {
1281
2371
  throw new Error("invalid detached HUD split receipt format");
1282
2372
  const commandIndex = splitArgs.length - 1;
1283
2373
  splitArgs[commandIndex] = `env OMX_DETACHED_HUD_OPERATION=${operationMarker} ${splitArgs[commandIndex]}`;
1284
- splitArgs[formatIndex + 1] = `#{pane_id}\t#{pane_pid}\t#{session_id}\t#{window_id}\t${receipt}`;
2374
+ splitArgs[formatIndex + 1] = `#{pane_id}\t#{pane_pid}\t#{session_name}\t#{session_id}\t#{session_created}\t#{window_id}\t${receipt}`;
1285
2375
  const output = execTmuxFileSync([
1286
2376
  "if-shell", "-F", "-t", authority.paneId, detachedLeaderAuthorityCondition(authority),
1287
2377
  splitArgs.map(quoteShellArg).join(" "), "display-message -p ''",
1288
2378
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1289
- const [paneId, panePid, sessionId, windowId, observedReceipt, ...extra] = output.split("\t");
1290
- if (!/^%[0-9]+$/.test(paneId) || !/^[1-9][0-9]*$/.test(panePid) || !/^\$[0-9]+$/.test(sessionId)
1291
- || !/^@[0-9]+$/.test(windowId) || observedReceipt !== receipt || extra.length !== 0) {
2379
+ const [paneId, panePid, sessionName, sessionId, sessionCreated, windowId, observedReceipt, ...extra] = output.split("\t");
2380
+ if (!/^%[0-9]+$/.test(paneId) || !/^[1-9][0-9]*$/.test(panePid) || !sessionName || !/^\$[0-9]+$/.test(sessionId)
2381
+ || !/^\d+$/.test(sessionCreated) || !/^@[0-9]+$/.test(windowId) || observedReceipt !== receipt || extra.length !== 0) {
1292
2382
  throw new Error("detached leader authority changed before HUD split");
1293
2383
  }
1294
2384
  const parsedPid = Number(panePid);
1295
2385
  if (!Number.isSafeInteger(parsedPid) || parsedPid <= 0)
1296
2386
  throw new Error("malformed detached HUD authority");
1297
- return { paneId, panePid: parsedPid, sessionId, windowId, operationMarker };
2387
+ return { paneId, panePid: parsedPid, sessionName, sessionId, sessionCreated, windowId, operationMarker };
1298
2388
  }
1299
2389
  function readTmuxEnvValueForTarget(targetPaneId) {
1300
2390
  if (!targetPaneId.startsWith("%"))
@@ -1418,6 +2508,34 @@ function reportOrdinaryLaunchRootConflict(error, cwd, ordinaryLaunch) {
1418
2508
  "[omx] cmd.exe: set \"OMX_ROOT=%USERPROFILE%\\.omx\\instances\\second-conversation\" && omx\n" +
1419
2509
  "[omx] The selected OMX_ROOT is used literally; OMX does not reroute or allocate one automatically.");
1420
2510
  }
2511
+ /**
2512
+ * #3578: a detached leader's session-pointer abort previously died with the
2513
+ * leader pane. The validated abort code now reaches the outer launcher; print
2514
+ * the same bounded, actionable guidance the ordinary launch path prints.
2515
+ * `cwd` selects the root that produced the conflict, mirroring the ordinary
2516
+ * path's cwd-default gating without weakening any ownership check.
2517
+ */
2518
+ export function reportDetachedSessionPointerGuidance(error, cwd = process.cwd()) {
2519
+ const candidate = findDetachedSessionPointerAbort(error);
2520
+ if (!candidate)
2521
+ return;
2522
+ if (candidate.code === "session_pointer_owner_conflict") {
2523
+ // Rebuild a well-formed SessionPointerLaunchAbort so reportOrdinaryLaunchRootConflict's
2524
+ // isSessionPointerLaunchAbort gate (name/committed/cwd/operation/code) accepts it,
2525
+ // then reuse the ordinary path's exact OMX_ROOT guidance block unchanged.
2526
+ reportOrdinaryLaunchRootConflict(Object.assign(new Error("Session pointer sess-live-owner conflicts with an active session; launch aborted"), {
2527
+ name: "SessionPointerLaunchAbort",
2528
+ committed: false,
2529
+ cwd,
2530
+ operation: "detached-leader-pre-bind",
2531
+ code: candidate.code,
2532
+ }), cwd, true);
2533
+ return;
2534
+ }
2535
+ if (candidate.code === "session_pointer_unusable") {
2536
+ console.error("[omx] the selected session pointer is unusable; run `omx doctor` for the exact pointer status and bounded recovery steps.");
2537
+ }
2538
+ }
1421
2539
  function logCliOperationFailure(error) {
1422
2540
  if (isMissingTmuxLaunchNoise(error))
1423
2541
  return;
@@ -2273,13 +3391,41 @@ export class DetachedLaunchSafetyError extends Error {
2273
3391
  cause;
2274
3392
  report;
2275
3393
  name = "DetachedLaunchSafetyError";
2276
- constructor(phase, cause, report) {
2277
- super(`detached launch safety failure during ${phase}${cause instanceof Error ? `: ${describeDetachedLeaderFailure(cause)}` : ""}`);
3394
+ /**
3395
+ * The exact bootstrap step that failed, when the failure came from a named
3396
+ * detached tmux step (#3578). `phase` stays the coarse D-state group; `step`
3397
+ * disambiguates `tag-session`, its follow-on mutations, the HUD split, and
3398
+ * every finalize step that previously collapsed into `pane-id`.
3399
+ */
3400
+ step;
3401
+ constructor(phase, cause, report, step) {
3402
+ super(`detached launch safety failure during ${phase}${step && step !== phase ? ` (${step})` : ""}${cause instanceof Error ? `: ${describeDetachedLeaderFailure(cause)}` : ""}`);
2278
3403
  this.phase = phase;
2279
3404
  this.cause = cause;
2280
3405
  this.report = report;
3406
+ if (step)
3407
+ this.step = step;
2281
3408
  }
2282
3409
  }
3410
+ export function isExactDetachedFinalization(finalization, expected) {
3411
+ return finalization !== undefined && (finalization.kind === "failed" || finalization.kind === "terminal") &&
3412
+ finalization.nonce === expected.nonce &&
3413
+ finalization.sessionId === expected.sessionId && finalization.sessionName === expected.sessionName &&
3414
+ finalization.leaderPid === expected.leaderPid;
3415
+ }
3416
+ function authenticatedDetachedFinalization(resolution) {
3417
+ if (resolution?.acknowledged !== true || !resolution.nonce || !resolution.sessionId || !resolution.sessionName ||
3418
+ typeof resolution.leaderPid !== "number" || !Number.isSafeInteger(resolution.leaderPid) ||
3419
+ (resolution.kind !== "failed" && resolution.kind !== "terminal"))
3420
+ return undefined;
3421
+ return {
3422
+ nonce: resolution.nonce,
3423
+ sessionId: resolution.sessionId,
3424
+ sessionName: resolution.sessionName,
3425
+ leaderPid: resolution.leaderPid,
3426
+ kind: resolution.kind,
3427
+ };
3428
+ }
2283
3429
  export class DetachedTransportUnavailable extends Error {
2284
3430
  reason;
2285
3431
  name = "DetachedTransportUnavailable";
@@ -2298,6 +3444,7 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
2298
3444
  let released = false;
2299
3445
  let retainedAuthority = false;
2300
3446
  let rollbackAuthorized = false;
3447
+ let finalization;
2301
3448
  try {
2302
3449
  transition("D1");
2303
3450
  binding = await deps.establish();
@@ -2347,8 +3494,8 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
2347
3494
  released = true;
2348
3495
  throw new AggregateError([releaseError, abortError], "detached release and abort publication both failed");
2349
3496
  }
2350
- rollbackAuthorized = resolution?.acknowledged === true && Boolean(resolution.nonce && resolution.sessionId && resolution.sessionName &&
2351
- Number.isSafeInteger(resolution.leaderPid) && resolution.kind);
3497
+ finalization = authenticatedDetachedFinalization(resolution);
3498
+ rollbackAuthorized = finalization !== undefined;
2352
3499
  if (!rollbackAuthorized)
2353
3500
  released = true;
2354
3501
  throw releaseError;
@@ -2362,8 +3509,8 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
2362
3509
  if (retainedAuthority && !rollbackAuthorized && !released) {
2363
3510
  try {
2364
3511
  const resolution = await deps.abortAndAwaitFinalization?.(error);
2365
- rollbackAuthorized = resolution?.rollbackAuthorized === true || (resolution?.acknowledged === true && Boolean(resolution.nonce && resolution.sessionId && resolution.sessionName &&
2366
- Number.isSafeInteger(resolution.leaderPid) && resolution.kind));
3512
+ finalization = authenticatedDetachedFinalization(resolution);
3513
+ rollbackAuthorized = resolution?.rollbackAuthorized === true || finalization !== undefined;
2367
3514
  if (!rollbackAuthorized)
2368
3515
  released = true;
2369
3516
  }
@@ -2375,7 +3522,7 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
2375
3522
  if (!released) {
2376
3523
  try {
2377
3524
  report.rollback.attempted.push("rollback");
2378
- await deps.rollback(ownedRecord, report);
3525
+ await deps.rollback(ownedRecord, report, finalization);
2379
3526
  }
2380
3527
  catch (rollbackError) {
2381
3528
  report.rollback.failures.push({ step: "rollback", status: "failed", code: detachedFailureCode(rollbackError) });
@@ -2383,7 +3530,7 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
2383
3530
  }
2384
3531
  }
2385
3532
  if (error instanceof DetachedLaunchSafetyError) {
2386
- throw new DetachedLaunchSafetyError(error.phase, error.cause, report);
3533
+ throw new DetachedLaunchSafetyError(error.phase, error.cause, report, error.step);
2387
3534
  }
2388
3535
  const phase = report.transitions.at(-1);
2389
3536
  const phaseName = phase === "D1" ? "establishment"
@@ -2475,6 +3622,7 @@ function renderDetachedLaunchSafetyReport(error) {
2475
3622
  }));
2476
3623
  return JSON.stringify({
2477
3624
  phase: error.phase,
3625
+ ...(error.step ? { step: error.step } : {}),
2478
3626
  failure: describeDetachedLeaderFailure(error.cause),
2479
3627
  transitions: error.report.transitions,
2480
3628
  ...(establishmentCleanup ? { establishmentCleanup } : {}),
@@ -2767,6 +3915,7 @@ export async function main(args) {
2767
3915
  if (err instanceof DetachedLaunchSafetyError) {
2768
3916
  console.error(`Error: ${err.message}`);
2769
3917
  console.error(`[omx] detached launch safety report: ${renderDetachedLaunchSafetyReport(err)}`);
3918
+ reportDetachedSessionPointerGuidance(err.cause);
2770
3919
  }
2771
3920
  else {
2772
3921
  console.error(`Error: ${err instanceof Error ? err.message : err}`);
@@ -3769,16 +4918,100 @@ function buildDetachedWindowsLeaderCommand(cwd, sessionName, codexCmd, sessionId
3769
4918
  const invocation = `& ${quotePowerShellArg(process.execPath)} ${quotePowerShellArg(omxBin)} __detached-session-leader ${quotePowerShellArg(payload)}`;
3770
4919
  return `powershell.exe -NoLogo -NoProfile -NonInteractive -Command ${quotePowerShellArg(invocation)}`;
3771
4920
  }
3772
- export function isDetachedReadyReportAuthorized(report, expected) {
4921
+ /**
4922
+ * #3578: forward only the bounded machine-readable abort code of a leader-side
4923
+ * session-pointer abort into the failed report. The human reason travels in
4924
+ * `error`; nothing else from the nested error object is copied, so arbitrary
4925
+ * nested errors are never dumped into the report.
4926
+ */
4927
+ function detachedSessionPointerAbortCode(failure) {
4928
+ if (isSessionPointerLaunchAbort(failure))
4929
+ return { abortCode: failure.code };
4930
+ // #3578: the leader's terminal catch wraps non-finalized failures in an
4931
+ // AggregateError (launch + lifecycle errors). The session-pointer abort can
4932
+ // only be the launch-side member, never the lifecycle error, so a bounded
4933
+ // one-level unwrap keeps the code without copying anything else.
4934
+ if (failure instanceof AggregateError) {
4935
+ for (const member of failure.errors) {
4936
+ if (isSessionPointerLaunchAbort(member))
4937
+ return { abortCode: member.code };
4938
+ }
4939
+ }
4940
+ return {};
4941
+ }
4942
+ /**
4943
+ * #3578: rebuild a leader-side session-pointer abort (code + reason only) from
4944
+ * a validated failed report so the outer launcher can present the same
4945
+ * actionable guidance the ordinary launch path prints. Non-abort failures keep
4946
+ * a plain Error carrying the leader's bounded reason.
4947
+ */
4948
+ function detachedLeaderFailureError(report) {
4949
+ const reason = report.error || "detached leader setup failed";
4950
+ if (!report.abortCode)
4951
+ return new Error(reason);
4952
+ const error = new Error(reason);
4953
+ error.code = report.abortCode;
4954
+ error.abortedSessionPointer = true;
4955
+ return error;
4956
+ }
4957
+ /** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
4958
+ export function detachedLeaderFailureErrorForTest(report) {
4959
+ return detachedLeaderFailureError({
4960
+ version: 1, kind: "failed", nonce: "test", sessionId: "test-session", sessionName: "test-name",
4961
+ ...report,
4962
+ });
4963
+ }
4964
+ /**
4965
+ * #3578: recognize the bounded abort marker set by {@link detachedLeaderFailureError}
4966
+ * directly on an error or on the launch-side member of a wrapping AggregateError.
4967
+ * The D2 completion path wraps the leader's error in an AggregateError before the
4968
+ * outer launcher sees it; without this unwrap the actionable abort code would be
4969
+ * invisible to the guidance printer.
4970
+ */
4971
+ function findDetachedSessionPointerAbort(error) {
4972
+ const candidates = [error];
4973
+ if (error instanceof AggregateError)
4974
+ candidates.push(...error.errors);
4975
+ for (const candidate of candidates) {
4976
+ const marker = candidate;
4977
+ if (marker && marker.abortedSessionPointer === true && typeof marker.code === "string") {
4978
+ return { code: marker.code };
4979
+ }
4980
+ }
4981
+ return undefined;
4982
+ }
4983
+ /** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
4984
+ export function isDetachedSessionPointerAbortCarried(error) {
4985
+ return findDetachedSessionPointerAbort(error) !== undefined;
4986
+ }
4987
+ export function isDetachedReadyReportAuthorized(report, expected, platform = process.platform) {
3773
4988
  if (report?.kind !== "ready" || report.nonce !== expected.nonce
3774
4989
  || report.sessionId !== expected.sessionId || report.sessionName !== expected.sessionName
3775
4990
  || typeof report.leaderPid !== "number" || report.leaderPid <= 0)
3776
4991
  return false;
4992
+ if (platform === "win32")
4993
+ return report.paneId === expected.leaderPaneId;
3777
4994
  if (expected.shouldAttach && expected.leaderPanePid !== null) {
3778
4995
  return report.leaderPid === expected.leaderPanePid;
3779
4996
  }
3780
4997
  return report.paneId === expected.leaderPaneId;
3781
4998
  }
4999
+ function isDetachedTerminalReportAuthorized(report, expected, platform = process.platform) {
5000
+ if (report?.kind !== "terminal" || report.finalized !== true)
5001
+ return false;
5002
+ return isDetachedReadyReportAuthorized({ ...report, kind: "ready" }, expected, platform);
5003
+ }
5004
+ function isDetachedFailedReportAuthorized(report, expected, platform = process.platform) {
5005
+ return report?.kind === "failed" && report.nonce === expected.nonce
5006
+ && report.sessionId === expected.sessionId && report.sessionName === expected.sessionName
5007
+ && report.paneId === expected.leaderPaneId
5008
+ && typeof report.leaderPid === "number" && report.leaderPid > 0
5009
+ && (platform === "win32" || expected.leaderPanePid === null || report.leaderPid === expected.leaderPanePid);
5010
+ }
5011
+ /** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
5012
+ export function isDetachedFailedReportAuthorizedForTest(report, expected, platform) {
5013
+ return isDetachedFailedReportAuthorized(report, expected, platform);
5014
+ }
3782
5015
  function writeDetachedLeaderReport(path, report) {
3783
5016
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
3784
5017
  const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -3834,6 +5067,8 @@ function readDetachedLeaderReport(path) {
3834
5067
  return undefined;
3835
5068
  if (report.exitStatus !== undefined && (!Number.isSafeInteger(report.exitStatus) || Number(report.exitStatus) < 0 || Number(report.exitStatus) > 255))
3836
5069
  return undefined;
5070
+ if (report.abortCode !== undefined && (typeof report.abortCode !== "string" || report.abortCode.length > 128 || !/^[a-z_]+$/.test(report.abortCode)))
5071
+ return undefined;
3837
5072
  return report;
3838
5073
  }
3839
5074
  catch {
@@ -5045,9 +6280,11 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
5045
6280
  let detachedHudAuthority = null;
5046
6281
  let detachedLeaderPid = null;
5047
6282
  let rollbackFromPreReportAuthority = false;
6283
+ let rollbackFromAuthenticatedFailure = false;
5048
6284
  let attachStep = null;
5049
6285
  const launchDetachedSession = async () => {
5050
6286
  let createdSession = false;
6287
+ let detachedLeaderOwnerTagInstalled = false;
5051
6288
  const bootstrapLeader = async () => {
5052
6289
  if (!nativeWindows)
5053
6290
  detachedParentEnvFilePath = writeDetachedSessionParentEnvFile(cwd, sessionId, codexEnvWithNotify);
@@ -5073,7 +6310,8 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
5073
6310
  if (!authority)
5074
6311
  throw new Error("detached leader authority missing before tmux mutation");
5075
6312
  if (step.name === "tag-session") {
5076
- runDetachedLeaderMutation(authority, step.args, false);
6313
+ runDetachedLeaderTagMutation(authority, sessionId);
6314
+ detachedLeaderOwnerTagInstalled = true;
5077
6315
  // tmux can acknowledge the session tag before the first owner-format
5078
6316
  // evaluation sees it. Retrying this idempotent first owner-guarded
5079
6317
  // mutation once gives the server a command boundary without relaxing
@@ -5111,7 +6349,7 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
5111
6349
  }
5112
6350
  }
5113
6351
  catch (error) {
5114
- throw new DetachedLaunchSafetyError(step.name === "new-session" ? "inert-session" : "pane-id", error, detachedPreflight.report);
6352
+ throw new DetachedLaunchSafetyError(step.name === "new-session" ? "inert-session" : "pane-id", error, detachedPreflight.report, step.name);
5115
6353
  }
5116
6354
  }
5117
6355
  return undefined;
@@ -5134,11 +6372,34 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
5134
6372
  return { kind: "success" };
5135
6373
  }
5136
6374
  if (report?.nonce === detachedLaunchNonce && report.sessionId === sessionId && report.sessionName === sessionName && report.leaderPid && report.kind === "failed") {
6375
+ if (!isDetachedFailedReportAuthorized(report, {
6376
+ nonce: detachedLaunchNonce,
6377
+ sessionId,
6378
+ sessionName,
6379
+ leaderPaneId: detachedLeaderPaneId,
6380
+ leaderPanePid: detachedLeaderAuthority?.panePid ?? null,
6381
+ })) {
6382
+ throw new Error("detached failed report is not authorized");
6383
+ }
5137
6384
  detachedLeaderPid = report.leaderPid;
5138
- return { kind: "failure", operation: "session-instructions", error: new Error(report.error || "detached leader setup failed") };
6385
+ // #3578: a failed leader report is terminal evidence. A leader that
6386
+ // never reached readiness cannot hold pointer authority, so the
6387
+ // outer launcher owns the pre-report tmux topology it created and
6388
+ // may roll it back through the captured authority — never through
6389
+ // the wait-for-finalization path, which only a ready leader can
6390
+ // satisfy and would otherwise stall 30s and skip rollback.
6391
+ rollbackFromPreReportAuthority = detachedLeaderAuthority !== null;
6392
+ rollbackFromAuthenticatedFailure = rollbackFromPreReportAuthority;
6393
+ return { kind: "failure", operation: "session-instructions", error: detachedLeaderFailureError(report) };
5139
6394
  }
5140
- if (Date.now() >= readyDeadline)
6395
+ if (Date.now() >= readyDeadline) {
6396
+ // A timeout still owns the created pre-report topology. Keep
6397
+ // the marker and authorize the asynchronous watcher so a
6398
+ // failed report published just after this boundary can trigger
6399
+ // cleanup after the leader exits without blocking completion.
6400
+ rollbackFromPreReportAuthority = detachedLeaderAuthority !== null;
5141
6401
  return { kind: "failure", operation: "session-instructions", error: new Error("detached leader readiness timed out") };
6402
+ }
5142
6403
  blockMs(20);
5143
6404
  }
5144
6405
  },
@@ -5188,7 +6449,13 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
5188
6449
  // The leader has the retained binding. Publishing abort is the only
5189
6450
  // outer action permitted after a D9 write failure; a mismatched or
5190
6451
  // missing report leaves every artifact and the tmux session intact.
5191
- if (!detachedLeaderPid) {
6452
+ if (!detachedLeaderPid || rollbackFromPreReportAuthority) {
6453
+ // #3578: either the leader was never observed (classic timeout /
6454
+ // bootstrap abort) or it already reported a terminal failure before
6455
+ // readiness — in both cases it never held pointer authority, so the
6456
+ // pre-report tmux topology the outer launcher created is its own to
6457
+ // roll back. Never wait for finalization here: only a leader that
6458
+ // reached readiness can acknowledge it.
5192
6459
  rollbackFromPreReportAuthority = detachedLeaderAuthority !== null;
5193
6460
  return { acknowledged: false, rollbackAuthorized: rollbackFromPreReportAuthority };
5194
6461
  }
@@ -5225,7 +6492,7 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
5225
6492
  },
5226
6493
  attachOrReturn: async () => { if (attachStep)
5227
6494
  execTmuxFileSync(attachStep.args, { stdio: "inherit" }); },
5228
- rollback: async (_ownedRecord, report) => {
6495
+ rollback: async (_ownedRecord, report, finalization) => {
5229
6496
  const attempt = async (step, operation) => {
5230
6497
  report.rollback.attempted.push(step);
5231
6498
  try {
@@ -5238,20 +6505,64 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
5238
6505
  await attempt("parent-env", async () => { if (detachedParentEnvFilePath)
5239
6506
  await rm(detachedParentEnvFilePath, { force: true }); });
5240
6507
  await attempt("runtime-codex-home", () => cleanupRuntimeCodexHome(runtimeCodexHomeForCleanup, projectLocalCodexHomeForCleanup));
5241
- await attempt("rollback", () => { rmSync(releaseMarkerPath, { force: true }); rmSync(`${releaseMarkerPath}.release`, { force: true }); rmSync(`${releaseMarkerPath}.abort`, { force: true }); });
6508
+ const removeReleaseMarkers = () => {
6509
+ rmSync(releaseMarkerPath, { force: true });
6510
+ rmSync(`${releaseMarkerPath}.release`, { force: true });
6511
+ rmSync(`${releaseMarkerPath}.abort`, { force: true });
6512
+ };
6513
+ let sessionCleanupCompleted = !rollbackFromPreReportAuthority;
5242
6514
  if (createdSession) {
6515
+ if (isExactDetachedFinalization(finalization, {
6516
+ nonce: detachedLaunchNonce,
6517
+ sessionId,
6518
+ sessionName,
6519
+ leaderPid: detachedLeaderPid,
6520
+ })) {
6521
+ if (detachedHudAuthority) {
6522
+ await attempt("session:kill-bootstrap-hud", () => {
6523
+ if (!cleanupFinalizedDetachedHud(detachedHudAuthority, sessionId)) {
6524
+ throw new Error("detached bootstrap HUD authority changed before finalized cleanup");
6525
+ }
6526
+ });
6527
+ }
6528
+ await attempt("rollback", removeReleaseMarkers);
6529
+ return;
6530
+ }
5243
6531
  for (const step of buildDetachedSessionRollbackSteps(sessionName, null, null, null)) {
5244
6532
  await attempt(`session:${step.name}`, () => {
5245
- if (!detachedLeaderAuthority)
6533
+ const leaderAuthority = detachedLeaderAuthority;
6534
+ if (!leaderAuthority)
5246
6535
  throw new Error("detached leader authority missing before rollback");
5247
6536
  if (rollbackFromPreReportAuthority && step.name === "kill-session") {
5248
- cleanupDetachedPreReportSession(detachedLeaderAuthority);
6537
+ const cleanupResult = cleanupDetachedPreReportSession(leaderAuthority, !detachedLeaderOwnerTagInstalled, () => {
6538
+ const expected = {
6539
+ nonce: detachedLaunchNonce,
6540
+ sessionId,
6541
+ sessionName,
6542
+ shouldAttach: detachedPreflight.shouldAttach,
6543
+ leaderPaneId: detachedLeaderPaneId,
6544
+ leaderPanePid: leaderAuthority.panePid,
6545
+ };
6546
+ const report = readDetachedLeaderReport(releaseMarkerPath);
6547
+ return isDetachedReadyReportAuthorized(report, expected) ||
6548
+ isDetachedTerminalReportAuthorized(report, expected);
6549
+ }, () => isDetachedFailedReportAuthorized(readDetachedLeaderReport(releaseMarkerPath), {
6550
+ nonce: detachedLaunchNonce,
6551
+ sessionId,
6552
+ sessionName,
6553
+ leaderPaneId: detachedLeaderPaneId,
6554
+ leaderPanePid: leaderAuthority.panePid,
6555
+ }), rollbackFromAuthenticatedFailure);
6556
+ sessionCleanupCompleted = cleanupResult === "cleaned";
5249
6557
  return;
5250
6558
  }
5251
- runDetachedLeaderMutation(detachedLeaderAuthority, step.args);
6559
+ runDetachedLeaderMutation(leaderAuthority, step.args);
5252
6560
  });
5253
6561
  }
5254
6562
  }
6563
+ if (sessionCleanupCompleted) {
6564
+ await attempt("rollback", removeReleaseMarkers);
6565
+ }
5255
6566
  },
5256
6567
  });
5257
6568
  if (launchTerminal.kind === "attached" && detachedLeaderPid) {
@@ -5435,13 +6746,17 @@ function parseDetachedHudAuthorityProof(value) {
5435
6746
  return undefined;
5436
6747
  if (typeof proof.panePid !== "number" || !Number.isSafeInteger(proof.panePid) || proof.panePid <= 0)
5437
6748
  return undefined;
6749
+ if (typeof proof.sessionName !== "string" || proof.sessionName.length === 0 || proof.sessionName.includes("\t"))
6750
+ return undefined;
5438
6751
  if (typeof proof.sessionId !== "string" || !/^\$[0-9]+$/.test(proof.sessionId))
5439
6752
  return undefined;
6753
+ if (typeof proof.sessionCreated !== "string" || !/^\d+$/.test(proof.sessionCreated))
6754
+ return undefined;
5440
6755
  if (typeof proof.windowId !== "string" || !/^@[0-9]+$/.test(proof.windowId))
5441
6756
  return undefined;
5442
6757
  if (typeof proof.operationMarker !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(proof.operationMarker))
5443
6758
  return undefined;
5444
- return { paneId: proof.paneId, panePid: proof.panePid, sessionId: proof.sessionId, windowId: proof.windowId, operationMarker: proof.operationMarker };
6759
+ return { paneId: proof.paneId, panePid: proof.panePid, sessionName: proof.sessionName, sessionId: proof.sessionId, sessionCreated: proof.sessionCreated, windowId: proof.windowId, operationMarker: proof.operationMarker };
5445
6760
  }
5446
6761
  function teardownDetachedOwnedHudPane(leaderPaneId, payload, hudProof) {
5447
6762
  // #3266: on a normal child exit, kill exactly the bootstrap-proven OMX HUD pane so
@@ -5450,13 +6765,13 @@ function teardownDetachedOwnedHudPane(leaderPaneId, payload, hudProof) {
5450
6765
  // Contract: only the proven HUD is removed. A pane the user added to the detached
5451
6766
  // session is deliberately preserved, which intentionally keeps that session (and any
5452
6767
  // attached client) alive; natural closure and shell return happen only when no other
5453
- // panes remain.
5454
- const discovered = discoverDetachedHudAuthority(payload.sessionName, payload.sessionId);
5455
- const proof = discovered ?? hudProof;
5456
- if (!proof)
6768
+ // panes remain. The leader pane is removed only after the proven HUD removal is
6769
+ // confirmed; a stale, missing, or changed HUD proof leaves the leader untouched too.
6770
+ if (!hudProof)
5457
6771
  return;
5458
6772
  try {
5459
- cleanupDetachedHudPane(proof, discovered ? payload.sessionId : undefined);
6773
+ if (!cleanupDetachedHudPane(hudProof, payload.sessionId))
6774
+ return;
5460
6775
  execTmuxFileSync(["kill-pane", "-t", leaderPaneId], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
5461
6776
  }
5462
6777
  catch (error) {
@@ -5464,6 +6779,43 @@ function teardownDetachedOwnedHudPane(leaderPaneId, payload, hudProof) {
5464
6779
  logCliOperationFailure(error);
5465
6780
  }
5466
6781
  }
6782
+ function cleanupDetachedLeaderSessionWithAuthority(authority, ownerId, beforeSink) {
6783
+ try {
6784
+ const owner = `#{||:#{==:#{@omx_instance_id},${ownerId}},#{&&:#{==:#{@omx_instance_id},},#{!=:#{@omx_detached_owner_tag_attempted},1}}}`;
6785
+ const condition = [
6786
+ `#{==:#{session_name},${authority.sessionName}}`,
6787
+ `#{==:#{session_id},${authority.sessionId}}`,
6788
+ `#{==:#{session_created},${authority.sessionCreated}}`,
6789
+ owner,
6790
+ ].reduce((combined, item) => `#{&&:${combined},${item}}`);
6791
+ const receipt = detachedAuthorityReceipt();
6792
+ const success = `kill-session -t ${quoteShellArg(authority.sessionName)} ; display-message -p ${quoteShellArg(receipt)}`;
6793
+ beforeSink?.();
6794
+ const result = execTmuxFileSync([
6795
+ "if-shell", "-F", "-t", authority.sessionName, condition, success, "display-message -p ''",
6796
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
6797
+ if (result !== receipt)
6798
+ throw new Error("detached failed leader session authority changed before cleanup");
6799
+ }
6800
+ catch {
6801
+ // The parent retains the report and its own identity-fenced cleanup marker.
6802
+ // A failed leader must never turn an uncertain session lookup into a kill.
6803
+ }
6804
+ }
6805
+ function cleanupDetachedLeaderSessionAfterFailure(paneId, payload) {
6806
+ try {
6807
+ const authority = captureDetachedLeaderAuthority(paneId, payload.sessionName, payload.sessionId);
6808
+ cleanupDetachedLeaderSessionWithAuthority(authority, payload.sessionId);
6809
+ }
6810
+ catch {
6811
+ // The parent retains the report and its own identity-fenced cleanup marker.
6812
+ // A failed leader must never turn an uncertain session lookup into a kill.
6813
+ }
6814
+ }
6815
+ /** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
6816
+ export function cleanupDetachedLeaderSessionAfterFailureForTest(authority, ownerId, beforeSink) {
6817
+ cleanupDetachedLeaderSessionWithAuthority(authority, ownerId, beforeSink);
6818
+ }
5467
6819
  function traceDetachedLeaderPhase(phase) {
5468
6820
  if (process.env.OMX_TEST_DETACHED_TRACE !== "1")
5469
6821
  return;
@@ -5479,32 +6831,58 @@ async function runDetachedSessionLeader(payload) {
5479
6831
  continue;
5480
6832
  process.env[key] = value;
5481
6833
  }
6834
+ const nonce = payload.readyPath?.split(".").at(-2) || payload.sessionId;
5482
6835
  let pane;
5483
6836
  const inheritedPane = process.env.TMUX_PANE?.trim();
5484
- if (payload.preLaunchOptions.shouldAttach === false) {
5485
- if (!inheritedPane || !/^%[0-9]+$/.test(inheritedPane)) {
5486
- throw new Error("detached Hermes leader has no tmux pane identity");
6837
+ if (process.platform !== "win32") {
6838
+ if (payload.preLaunchOptions.shouldAttach === false) {
6839
+ if (!inheritedPane || !/^%[0-9]+$/.test(inheritedPane))
6840
+ throw new Error("detached Hermes leader has no tmux pane identity");
6841
+ pane = inheritedPane;
6842
+ }
6843
+ else {
6844
+ const paneSnapshot = execTmuxFileSync(["list-panes", "-t", payload.sessionName, "-F", "#{pane_id}\t#{pane_dead}\t#{pane_pid}"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
6845
+ pane = parseDetachedLeaderPaneIdByPid(paneSnapshot, process.pid);
5487
6846
  }
5488
- pane = inheritedPane;
5489
6847
  }
5490
6848
  else {
5491
- const paneSnapshot = execTmuxFileSync(["list-panes", "-t", payload.sessionName, "-F", "#{pane_id}\t#{pane_dead}\t#{pane_pid}"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
5492
- pane = parseDetachedLeaderPaneIdByPid(paneSnapshot, process.pid);
6849
+ pane = resolveDetachedLeaderPaneIdentity(payload.sessionName, undefined, inheritedPane, process.platform, process.pid, payload.preLaunchOptions.shouldAttach === false, (paneId) => execTmuxFileSync(["display-message", "-p", "-t", paneId, "#{session_name}\t#{session_id}\t#{pane_id}"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(), () => execTmuxFileSync(["list-panes", "-t", payload.sessionName, "-F", "#{pane_id}\t#{pane_dead}\t#{pane_pid}"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }));
5493
6850
  }
5494
6851
  process.env.TMUX_PANE = pane;
5495
- const established = await establishPreLaunchBinding(payload.cwd, payload.sessionId, {
5496
- tmuxSessionName: payload.sessionName,
5497
- tmuxPaneId: pane,
5498
- });
6852
+ // #3578: a pre-binding failure (notably session_pointer_owner_conflict) used to
6853
+ // die with the pane — remain-on-exit is off, so this leader's stderr vanished and
6854
+ // the outer launcher could only report a readiness failure. Record the same
6855
+ // bounded failure string the in-try path records so the parent can surface the
6856
+ // actionable abort instead of a generic detached phase. Keep pane resolution
6857
+ // outside the try so the exact clean-base identity path is preserved.
6858
+ let established;
6859
+ try {
6860
+ established = await establishPreLaunchBinding(payload.cwd, payload.sessionId, {
6861
+ tmuxSessionName: payload.sessionName,
6862
+ tmuxPaneId: pane,
6863
+ });
6864
+ }
6865
+ catch (error) {
6866
+ if (payload.readyPath) {
6867
+ writeDetachedLeaderReport(payload.readyPath, {
6868
+ version: 1, kind: "failed", nonce, sessionId: payload.sessionId, sessionName: payload.sessionName,
6869
+ paneId: pane,
6870
+ leaderPid: process.pid, error: describeDetachedLeaderFailure(error),
6871
+ ...detachedSessionPointerAbortCode(error),
6872
+ });
6873
+ }
6874
+ cleanupDetachedLeaderSessionAfterFailure(pane, payload);
6875
+ throw error;
6876
+ }
5499
6877
  const binding = established.binding;
5500
6878
  let ownedRecord;
5501
6879
  let detachedHudProof;
5502
- const nonce = payload.readyPath?.split(".").at(-2) || payload.sessionId;
5503
6880
  const contextKey = process.env[OMX_MADMAX_DETACHED_CONTEXT_ENV]?.trim();
6881
+ let externalInterrupt;
5504
6882
  const activeRecordPath = contextKey
5505
6883
  ? madmaxDetachedActiveRecordPath(resolveMadmaxRunsRoot(process.env), contextKey)
5506
6884
  : join(omxRoot(payload.cwd), "state", "detached-active-record.json");
5507
- let externalInterrupt;
6885
+ let readyReportWritten = false;
5508
6886
  try {
5509
6887
  const completion = await completePreLaunchSetup(payload.cwd, payload.sessionId, payload.preLaunchOptions.notifyTempContract, payload.codexHomeOverride, payload.preLaunchOptions.enableNotifyFallbackAuthority, payload.preLaunchOptions.worktreeDirty);
5510
6888
  if (completion.kind === "failure") {
@@ -5531,6 +6909,7 @@ async function runDetachedSessionLeader(payload) {
5531
6909
  });
5532
6910
  if (payload.readyPath) {
5533
6911
  writeDetachedLeaderReport(payload.readyPath, { version: 1, kind: "ready", nonce, sessionId: payload.sessionId, sessionName: payload.sessionName, paneId: pane, leaderPid: process.pid });
6912
+ readyReportWritten = true;
5534
6913
  const releasePath = `${payload.readyPath}.release`;
5535
6914
  const abortPath = `${payload.readyPath}.abort`;
5536
6915
  const releaseDeadline = Date.now() + 30_000;
@@ -5667,8 +7046,12 @@ async function runDetachedSessionLeader(payload) {
5667
7046
  if (payload.readyPath)
5668
7047
  writeDetachedLeaderReport(payload.readyPath, {
5669
7048
  version: 1, kind: "failed", nonce, sessionId: payload.sessionId, sessionName: payload.sessionName,
7049
+ paneId: pane,
5670
7050
  leaderPid: process.pid, finalized, error: failure instanceof Error ? failure.message : String(failure),
7051
+ ...detachedSessionPointerAbortCode(failure),
5671
7052
  });
7053
+ if (!readyReportWritten)
7054
+ cleanupDetachedLeaderSessionAfterFailure(pane, payload);
5672
7055
  throw failure;
5673
7056
  }
5674
7057
  finally {
@@ -6388,6 +7771,58 @@ async function listHookVisibleRunDirStateRefs(cwd) {
6388
7771
  }
6389
7772
  return refs.sort((a, b) => a.mode.localeCompare(b.mode));
6390
7773
  }
7774
+ function detachedIdentity(stat) {
7775
+ return {
7776
+ dev: stat.dev,
7777
+ ino: stat.ino,
7778
+ mode: stat.mode,
7779
+ nlink: stat.nlink,
7780
+ kind: stat.isDirectory() ? "directory" : "file",
7781
+ size: stat.size,
7782
+ mtimeNs: stat.mtimeNs,
7783
+ ctimeNs: stat.ctimeNs,
7784
+ };
7785
+ }
7786
+ function detachedIdentityMatches(left, right) {
7787
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode
7788
+ && left.nlink === right.nlink && left.kind === right.kind && left.size === right.size
7789
+ && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
7790
+ }
7791
+ function detachedDirectoryIdentityMatches(left, right) {
7792
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode
7793
+ && left.nlink === right.nlink && left.kind === "directory" && right.kind === "directory";
7794
+ }
7795
+ async function captureDetachedDirectory(path) {
7796
+ const before = await lstat(path, { bigint: true });
7797
+ if (!before.isDirectory() || before.isSymbolicLink() || await realpath(path) !== path) {
7798
+ throw new Error(`detached authority directory is not canonical: ${path}`);
7799
+ }
7800
+ return detachedIdentity(before);
7801
+ }
7802
+ async function readDetachedPinnedFile(path) {
7803
+ const before = await lstat(path, { bigint: true });
7804
+ if (!before.isFile() || before.isSymbolicLink())
7805
+ throw new Error(`detached authority file is not regular: ${path}`);
7806
+ const handle = await open(path, fsConstants.O_RDONLY | historyNoFollowFlag());
7807
+ try {
7808
+ const opened = await handle.stat({ bigint: true });
7809
+ if (!opened.isFile() || !detachedIdentityMatches(detachedIdentity(before), detachedIdentity(opened))) {
7810
+ throw new Error(`detached authority file changed before open: ${path}`);
7811
+ }
7812
+ const content = (await handle.readFile()).toString("utf8");
7813
+ const afterHandle = await handle.stat({ bigint: true });
7814
+ const afterPath = await lstat(path, { bigint: true });
7815
+ const frozen = detachedIdentity(before);
7816
+ if (!detachedIdentityMatches(frozen, detachedIdentity(afterHandle))
7817
+ || !detachedIdentityMatches(frozen, detachedIdentity(afterPath))) {
7818
+ throw new Error(`detached authority file changed during read: ${path}`);
7819
+ }
7820
+ return { identity: frozen, content };
7821
+ }
7822
+ finally {
7823
+ await handle.close();
7824
+ }
7825
+ }
6391
7826
  function isCanonicalPathWithin(parent, child, allowEqual = false) {
6392
7827
  const rel = relative(parent, child);
6393
7828
  if (rel === "")
@@ -6425,18 +7860,80 @@ async function selectAuthorizedHookVisibleRunDirState(cwd) {
6425
7860
  if (!isCanonicalPathWithin(canonicalRunsRoot, canonicalRunDir)) {
6426
7861
  throw new Error("run directory escapes the authorized runs root");
6427
7862
  }
6428
- const stateDir = realpathSync(join(canonicalRunDir, ".omx", "state"));
7863
+ const omxDir = join(canonicalRunDir, ".omx");
7864
+ const stateDir = join(omxDir, "state");
7865
+ const sessionsDir = join(stateDir, "sessions");
7866
+ const treeDirectories = new Map();
7867
+ for (const path of [canonicalRunDir, omxDir, stateDir, sessionsDir]) {
7868
+ treeDirectories.set(path, await captureDetachedDirectory(path));
7869
+ }
6429
7870
  if (!isCanonicalPathWithin(canonicalRunDir, stateDir)) {
6430
7871
  throw new Error("state directory escapes the authorized run directory");
6431
7872
  }
6432
- const session = JSON.parse(await readFile(join(stateDir, "session.json"), "utf-8"));
7873
+ const protectedFiles = new Map();
7874
+ const pointerPath = join(stateDir, "session.json");
7875
+ const pointer = await readDetachedPinnedFile(pointerPath);
7876
+ protectedFiles.set(pointerPath, pointer);
7877
+ const session = JSON.parse(pointer.content);
6433
7878
  if (session.session_id !== record.session_id)
6434
7879
  throw new Error("run session pointer changed");
6435
- const sessionDir = realpathSync(join(stateDir, "sessions", record.session_id));
7880
+ const sessionDir = join(sessionsDir, record.session_id);
7881
+ treeDirectories.set(sessionDir, await captureDetachedDirectory(sessionDir));
6436
7882
  if (!isCanonicalPathWithin(stateDir, sessionDir)) {
6437
7883
  throw new Error("session directory escapes the authorized state directory");
6438
7884
  }
6439
- candidates.push({ sessionDir, sessionId: record.session_id, record });
7885
+ const refs = [];
7886
+ const stateFiles = await readdir(sessionDir).catch(() => []);
7887
+ for (const stateFile of stateFiles) {
7888
+ if (!isModeStateFilename(stateFile))
7889
+ continue;
7890
+ const path = join(sessionDir, stateFile);
7891
+ const snapshot = await readDetachedPinnedFile(path);
7892
+ protectedFiles.set(path, snapshot);
7893
+ refs.push({ mode: stateFile.slice(0, -"-state.json".length), path, scope: "session" });
7894
+ }
7895
+ const advisoryRootPath = join(sessionDir, "ralplan-advisory");
7896
+ let advisoryGenerationDir;
7897
+ let advisoryGenerationEntries;
7898
+ try {
7899
+ treeDirectories.set(advisoryRootPath, await captureDetachedDirectory(advisoryRootPath));
7900
+ const currentPath = join(advisoryRootPath, "current.json");
7901
+ const current = await readDetachedPinnedFile(currentPath);
7902
+ protectedFiles.set(currentPath, current);
7903
+ const generationId = JSON.parse(current.content).generation_id;
7904
+ if (typeof generationId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(generationId)) {
7905
+ throw new Error("detached Advisory current generation is invalid");
7906
+ }
7907
+ const generationPath = join(advisoryRootPath, generationId);
7908
+ advisoryGenerationDir = generationPath;
7909
+ treeDirectories.set(generationPath, await captureDetachedDirectory(generationPath));
7910
+ advisoryGenerationEntries = new Set(await readdir(generationPath));
7911
+ for (const name of ["fence.json", "closeout-journal.json"]) {
7912
+ const path = join(generationPath, name);
7913
+ try {
7914
+ protectedFiles.set(path, await readDetachedPinnedFile(path));
7915
+ }
7916
+ catch (error) {
7917
+ if (error.code !== "ENOENT")
7918
+ throw error;
7919
+ }
7920
+ }
7921
+ }
7922
+ catch (error) {
7923
+ if (error.code !== "ENOENT")
7924
+ throw error;
7925
+ }
7926
+ candidates.push({
7927
+ refs: refs.sort((a, b) => a.mode.localeCompare(b.mode)),
7928
+ sessionDir,
7929
+ sessionId: record.session_id,
7930
+ runCwd: canonicalRunDir,
7931
+ treeDirectories,
7932
+ protectedFiles,
7933
+ ...(advisoryGenerationDir ? { advisoryGenerationDir } : {}),
7934
+ ...(advisoryGenerationEntries ? { advisoryGenerationEntries } : {}),
7935
+ record,
7936
+ });
6440
7937
  }
6441
7938
  catch (err) {
6442
7939
  throw new Error(`Refusing cancellation because detached run authority is invalid: ${record.run_dir}.`, { cause: err });
@@ -6446,33 +7943,7 @@ async function selectAuthorizedHookVisibleRunDirState(cwd) {
6446
7943
  throw new Error("Refusing cancellation because multiple detached run authorities match.");
6447
7944
  if (candidates.length === 0)
6448
7945
  return null;
6449
- const [{ sessionDir, sessionId, record }] = candidates;
6450
- const refs = [];
6451
- const stateFiles = await readdir(sessionDir).catch(() => []);
6452
- for (const file of stateFiles) {
6453
- if (!isModeStateFilename(file))
6454
- continue;
6455
- const path = join(sessionDir, file);
6456
- try {
6457
- const fileStat = lstatSync(path);
6458
- if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
6459
- throw new Error(`Refusing cancellation through non-regular run state target: ${path}.`);
6460
- }
6461
- const canonicalFile = realpathSync(path);
6462
- if (!isCanonicalPathWithin(sessionDir, canonicalFile)) {
6463
- throw new Error(`Refusing cancellation outside authorized run session: ${path}.`);
6464
- }
6465
- refs.push({
6466
- mode: file.slice(0, -"-state.json".length),
6467
- path: canonicalFile,
6468
- scope: "session",
6469
- });
6470
- }
6471
- catch (err) {
6472
- throw new Error(`Refusing cancellation because detached run state authority is invalid: ${path}.`, { cause: err });
6473
- }
6474
- }
6475
- return { refs: refs.sort((a, b) => a.mode.localeCompare(b.mode)), sessionId, sessionDir, record };
7946
+ return candidates[0];
6476
7947
  }
6477
7948
  function assertCancellationAuthorityPath(baseStateDir, authorityRoot) {
6478
7949
  const resolvedBase = resolve(baseStateDir);
@@ -6614,6 +8085,9 @@ function assertCompatibleNestedSkillOwners(value, authority, path) {
6614
8085
  assertExactSkillOwner(entry, authority, entryPath);
6615
8086
  }
6616
8087
  }
8088
+ export function cancellationFileIdentityMatches(frozen, current, platform = process.platform) {
8089
+ return sessionOwnerFileIdentityMatches(frozen, current, platform);
8090
+ }
6617
8091
  async function cancelModes(args = [], options = {}) {
6618
8092
  const cwd = options.cwd ?? process.cwd();
6619
8093
  const nowIso = new Date().toISOString();
@@ -6631,23 +8105,24 @@ async function cancelModes(args = [], options = {}) {
6631
8105
  ? await resolveExactSessionCancellationAuthority(cwd, baseStateDir, writableScope.sessionId)
6632
8106
  : undefined;
6633
8107
  const expectedOwnerIds = collectCancellationOwnerIds(baseStateDir, writableScope.sessionId);
6634
- const loadStates = async (refs, authorityRoot, ownerIds = expectedOwnerIds) => {
8108
+ const loadStates = async (refs, authorityRoot, ownerIds = expectedOwnerIds, pinnedFiles) => {
6635
8109
  const loaded = new Map();
6636
8110
  if (refs.length === 0)
6637
8111
  return loaded;
6638
8112
  const canonicalAuthorityRoot = assertCancellationAuthorityPath(authorityRoot === writableScope.stateDir ? baseStateDir : authorityRoot, authorityRoot);
6639
8113
  for (const ref of refs) {
6640
- const fileStat = lstatSync(ref.path);
6641
- if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
8114
+ const pinned = pinnedFiles?.get(ref.path);
8115
+ const fileStat = pinned ? null : lstatSync(ref.path);
8116
+ if (fileStat && (!fileStat.isFile() || fileStat.isSymbolicLink())) {
6642
8117
  throw new Error(`Refusing cancellation through non-regular state target ${ref.path}.`);
6643
8118
  }
6644
- const canonicalPath = realpathSync(ref.path);
8119
+ const canonicalPath = pinned ? ref.path : realpathSync(ref.path);
6645
8120
  const canonicalParent = realpathSync(dirname(ref.path));
6646
8121
  if (!isCanonicalPathWithin(canonicalAuthorityRoot, canonicalParent, true)
6647
8122
  || !isCanonicalPathWithin(canonicalAuthorityRoot, canonicalPath)) {
6648
8123
  throw new Error(`Refusing cancellation outside authorized state root: ${ref.path}.`);
6649
8124
  }
6650
- const content = await readFile(canonicalPath, "utf-8");
8125
+ const content = pinned?.content ?? await readFile(canonicalPath, "utf-8");
6651
8126
  let parsedState;
6652
8127
  try {
6653
8128
  const parsed = JSON.parse(content);
@@ -6708,8 +8183,8 @@ async function cancelModes(args = [], options = {}) {
6708
8183
  scope: ref.scope,
6709
8184
  state: parsedState,
6710
8185
  originalContent: content,
6711
- dev: fileStat.dev,
6712
- ino: fileStat.ino,
8186
+ dev: pinned ? Number(pinned.identity.dev) : fileStat.dev,
8187
+ ino: pinned ? Number(pinned.identity.ino) : fileStat.ino,
6713
8188
  });
6714
8189
  }
6715
8190
  return loaded;
@@ -6736,14 +8211,208 @@ async function cancelModes(args = [], options = {}) {
6736
8211
  runSelection = await selectAuthorizedHookVisibleRunDirState(cwd);
6737
8212
  if (runSelection) {
6738
8213
  const runOwnerIds = new Set([runSelection.sessionId]);
6739
- const runDirStates = await loadStates(runSelection.refs, runSelection.sessionDir, runOwnerIds);
8214
+ const runDirStates = await loadStates(runSelection.refs, runSelection.sessionDir, runOwnerIds, runSelection.protectedFiles);
6740
8215
  if (hasActiveWorkflowMode(runDirStates))
6741
8216
  states = runDirStates;
6742
8217
  else
6743
8218
  runSelection = null;
6744
8219
  }
6745
8220
  }
8221
+ const assertRunAuthority = () => {
8222
+ if (runSelection && !hasMatchingMadmaxDetachedRuntimeBinding(runSelection.record)) {
8223
+ throw new Error("Refusing cancellation because detached run authority changed.");
8224
+ }
8225
+ };
6746
8226
  const currentSessionId = writableScope.sessionId ?? runSelection?.sessionId ?? "";
8227
+ const activeAdvisory = states.get("ralplan");
8228
+ if (activeAdvisory?.state.active === true && activeAdvisory.state.workflow_variant === "advisory") {
8229
+ if (!currentSessionId)
8230
+ throw new Error("Refusing Advisory cancellation without an authoritative session scope.");
8231
+ const { cancelRalplanConsensus } = await import("../ralplan/runtime.js");
8232
+ const cancellationCwd = runSelection ? runSelection.runCwd : cwd;
8233
+ let expectedModeIdentity = runSelection?.protectedFiles.get(activeAdvisory.path)?.identity;
8234
+ let expectedModeContent = activeAdvisory.originalContent;
8235
+ const detachedSessionSkillBaseline = runSelection
8236
+ ? [...runSelection.protectedFiles.entries()].find(([path]) => basename(path) === "skill-active-state.json"
8237
+ && path.split(sep).includes("sessions") && path.split(sep).includes(currentSessionId))?.[1].content
8238
+ : undefined;
8239
+ const detachedRootSkillBaseline = runSelection
8240
+ ? runSelection.protectedFiles.get(join(dirname(dirname(runSelection.sessionDir)), "skill-active-state.json"))?.content
8241
+ : undefined;
8242
+ let sessionModeMutationPending = false;
8243
+ const pendingProtectedMutations = new Set();
8244
+ const revalidateDetachedAdvisoryAuthority = runSelection
8245
+ ? async (checkpoint) => {
8246
+ if (checkpoint === "mirror_session_mode" || checkpoint === "cancel_mode_update"
8247
+ || checkpoint === "mode_update_complete"
8248
+ || checkpoint === "mode_commit_ralplan.session-state") {
8249
+ sessionModeMutationPending = true;
8250
+ }
8251
+ assertRunAuthority();
8252
+ const stateDir = dirname(dirname(runSelection.sessionDir));
8253
+ for (const [path, frozen] of runSelection.treeDirectories) {
8254
+ let current;
8255
+ try {
8256
+ current = await captureDetachedDirectory(path);
8257
+ }
8258
+ catch (error) {
8259
+ throw new Error(`Refusing cancellation because detached directory is no longer canonical: ${path}.`, { cause: error });
8260
+ }
8261
+ if (!detachedDirectoryIdentityMatches(frozen, current)) {
8262
+ const isGeneration = path === runSelection.advisoryGenerationDir;
8263
+ const stableObject = frozen.dev === current.dev && frozen.ino === current.ino
8264
+ && frozen.mode === current.mode && frozen.kind === current.kind;
8265
+ if (isGeneration && stableObject && runSelection.advisoryGenerationEntries) {
8266
+ const entries = new Set(await readdir(path));
8267
+ const allowed = [...entries].every((entry) => runSelection.advisoryGenerationEntries.has(entry)
8268
+ || entry === "generation.lock" || entry === "fence.json" || entry === "closeout-journal.json"
8269
+ || entry === "admin-event-0001.json"
8270
+ || /^fence-event-[0-9]{4}\.json$/.test(entry));
8271
+ if (allowed) {
8272
+ runSelection.advisoryGenerationEntries = entries;
8273
+ runSelection.treeDirectories.set(path, current);
8274
+ continue;
8275
+ }
8276
+ }
8277
+ if (stableObject) {
8278
+ runSelection.treeDirectories.set(path, current);
8279
+ continue;
8280
+ }
8281
+ throw new Error(`Refusing cancellation because detached directory identity changed at ${checkpoint}: ${path}.`);
8282
+ }
8283
+ }
8284
+ const pointerPath = join(stateDir, "session.json");
8285
+ const pointerSnapshot = await readDetachedPinnedFile(pointerPath);
8286
+ const frozenPointer = runSelection.protectedFiles.get(pointerPath);
8287
+ if (!frozenPointer || !detachedIdentityMatches(frozenPointer.identity, pointerSnapshot.identity)
8288
+ || frozenPointer.content !== pointerSnapshot.content) {
8289
+ throw new Error("Refusing cancellation because detached session pointer changed.");
8290
+ }
8291
+ const pointer = JSON.parse(pointerSnapshot.content);
8292
+ if (pointer.session_id !== currentSessionId) {
8293
+ throw new Error("Refusing cancellation because detached session authority changed.");
8294
+ }
8295
+ for (const [path, frozen] of [...runSelection.protectedFiles]) {
8296
+ if (path === pointerPath || path === activeAdvisory.path)
8297
+ continue;
8298
+ const snapshot = await readDetachedPinnedFile(path);
8299
+ if (!detachedIdentityMatches(frozen.identity, snapshot.identity) || frozen.content !== snapshot.content) {
8300
+ const isPendingSessionSkill = pendingProtectedMutations.has(path)
8301
+ && basename(path) === "skill-active-state.json"
8302
+ && path.split(sep).includes("sessions")
8303
+ && path.split(sep).includes(currentSessionId);
8304
+ if (isPendingSessionSkill && checkpoint !== "journal_step_session_skill")
8305
+ continue;
8306
+ if (!pendingProtectedMutations.delete(path)) {
8307
+ throw new Error(`Refusing cancellation because detached protected state changed at ${checkpoint}: ${path}.`);
8308
+ }
8309
+ if (basename(path) === "skill-active-state.json"
8310
+ && path.split(sep).includes("sessions")
8311
+ && path.split(sep).includes(currentSessionId)) {
8312
+ const journalPath = runSelection.advisoryGenerationDir
8313
+ ? join(runSelection.advisoryGenerationDir, "closeout-journal.json") : "";
8314
+ const journalSnapshot = journalPath ? runSelection.protectedFiles.get(journalPath) : undefined;
8315
+ const journal = journalSnapshot
8316
+ ? JSON.parse(journalSnapshot.content) : null;
8317
+ const terminalSkillUpdates = journal && typeof journal.terminal_skill_updates === "object"
8318
+ && journal.terminal_skill_updates !== null
8319
+ ? journal.terminal_skill_updates : null;
8320
+ const expectedTerminalState = terminalSkillUpdates?.session_skill;
8321
+ const terminalModeState = journal?.terminal_mode_updates;
8322
+ const terminalTimestamp = journal?.terminal_timestamp;
8323
+ if (!expectedTerminalState || typeof expectedTerminalState !== "object" || Array.isArray(expectedTerminalState)) {
8324
+ throw new Error("Refusing cancellation without a canonical detached Advisory session skill payload.");
8325
+ }
8326
+ if (!terminalModeState || typeof terminalModeState !== "object" || Array.isArray(terminalModeState)
8327
+ || typeof terminalTimestamp !== "string" || terminalTimestamp.length === 0) {
8328
+ throw new Error("Refusing cancellation without canonical detached Advisory terminal metadata.");
8329
+ }
8330
+ validateDetachedAdvisorySessionSkillTransition({
8331
+ baseline: JSON.parse(detachedSessionSkillBaseline ?? frozen.content),
8332
+ rootBaseline: detachedRootSkillBaseline
8333
+ ? JSON.parse(detachedRootSkillBaseline)
8334
+ : null,
8335
+ current: JSON.parse(snapshot.content),
8336
+ journalProjection: expectedTerminalState,
8337
+ terminalModeState: {
8338
+ ...activeAdvisory.state,
8339
+ ...terminalModeState,
8340
+ },
8341
+ terminalTimestamp,
8342
+ sessionId: currentSessionId,
8343
+ });
8344
+ }
8345
+ runSelection.protectedFiles.set(path, snapshot);
8346
+ }
8347
+ }
8348
+ if (checkpoint === "mode_commit_ralplan.session-skill-write"
8349
+ || checkpoint === "mirror_session_skill") {
8350
+ for (const path of runSelection.protectedFiles.keys()) {
8351
+ if (basename(path) === "skill-active-state.json"
8352
+ && path.split(sep).includes("sessions")
8353
+ && path.split(sep).includes(currentSessionId)) {
8354
+ pendingProtectedMutations.add(path);
8355
+ }
8356
+ }
8357
+ }
8358
+ if (runSelection.advisoryGenerationDir) {
8359
+ for (const name of ["fence.json", "closeout-journal.json"]) {
8360
+ const path = join(runSelection.advisoryGenerationDir, name);
8361
+ if (runSelection.protectedFiles.has(path) || !pendingProtectedMutations.has(path))
8362
+ continue;
8363
+ try {
8364
+ runSelection.protectedFiles.set(path, await readDetachedPinnedFile(path));
8365
+ pendingProtectedMutations.delete(path);
8366
+ }
8367
+ catch (error) {
8368
+ if (error.code !== "ENOENT")
8369
+ throw error;
8370
+ }
8371
+ }
8372
+ }
8373
+ const modeSnapshot = await readDetachedPinnedFile(activeAdvisory.path);
8374
+ const content = modeSnapshot.content;
8375
+ if (!expectedModeIdentity)
8376
+ throw new Error("Refusing cancellation without a pinned detached Ralplan state.");
8377
+ if (sessionModeMutationPending) {
8378
+ const stillOriginal = detachedIdentityMatches(modeSnapshot.identity, expectedModeIdentity)
8379
+ && content === expectedModeContent;
8380
+ if (!stillOriginal) {
8381
+ const parsed = JSON.parse(content);
8382
+ if (parsed.mode !== "ralplan" || parsed.workflow_variant !== "advisory"
8383
+ || parsed.advisory_generation_id !== activeAdvisory.state.advisory_generation_id
8384
+ || parsed.session_id !== currentSessionId || parsed.active !== false) {
8385
+ throw new Error("Refusing cancellation because detached Advisory mode mutation was not canonical.");
8386
+ }
8387
+ expectedModeIdentity = modeSnapshot.identity;
8388
+ expectedModeContent = content;
8389
+ sessionModeMutationPending = false;
8390
+ }
8391
+ }
8392
+ else if (!detachedIdentityMatches(modeSnapshot.identity, expectedModeIdentity) || content !== expectedModeContent) {
8393
+ throw new Error(`Refusing cancellation because detached Ralplan state changed at ${checkpoint}.`);
8394
+ }
8395
+ if (runSelection.advisoryGenerationDir) {
8396
+ const fencePath = join(runSelection.advisoryGenerationDir, "fence.json");
8397
+ const journalPath = join(runSelection.advisoryGenerationDir, "closeout-journal.json");
8398
+ if (["fence_create", "fence_recovery_required", "fence_terminal", "admin_fence_event"].includes(checkpoint)) {
8399
+ pendingProtectedMutations.add(fencePath);
8400
+ }
8401
+ if (checkpoint === "journal_prepare" || checkpoint.startsWith("journal_step_")
8402
+ || checkpoint === "journal_commit" || checkpoint === "journal_fence_terminal") {
8403
+ pendingProtectedMutations.add(journalPath);
8404
+ }
8405
+ }
8406
+ }
8407
+ : undefined;
8408
+ const cancelled = await cancelRalplanConsensus(cancellationCwd, currentSessionId, {
8409
+ revalidateAuthority: revalidateDetachedAdvisoryAuthority,
8410
+ });
8411
+ if (!cancelled)
8412
+ throw new Error("Refusing false Advisory cancellation success: the selected generation was not terminalized.");
8413
+ console.log("Cancelled: ralplan");
8414
+ return;
8415
+ }
6747
8416
  const changed = new Set();
6748
8417
  const reported = new Set();
6749
8418
  const cancelMode = (mode, phase = "cancelled", reportIfWasActive = true) => {
@@ -6849,11 +8518,6 @@ async function cancelModes(args = [], options = {}) {
6849
8518
  cancelMode(mode, "cancelled", true);
6850
8519
  }
6851
8520
  }
6852
- const assertRunAuthority = () => {
6853
- if (runSelection && !hasMatchingMadmaxDetachedRuntimeBinding(runSelection.record)) {
6854
- throw new Error("Refusing cancellation because detached run authority changed.");
6855
- }
6856
- };
6857
8521
  if (force && currentSessionId) {
6858
8522
  const stopStateEntries = [...states.entries()].filter(([mode]) => mode === "native-stop");
6859
8523
  for (const [, entry] of stopStateEntries) {
@@ -6888,7 +8552,8 @@ async function cancelModes(args = [], options = {}) {
6888
8552
  const handle = await open(change.entry.path, fsConstants.O_RDWR | fsConstants.O_NOFOLLOW);
6889
8553
  const currentStat = await handle.stat();
6890
8554
  const currentContent = await handle.readFile({ encoding: "utf-8" });
6891
- if (!currentStat.isFile() || currentStat.dev !== change.entry.dev || currentStat.ino !== change.entry.ino) {
8555
+ if (!currentStat.isFile()
8556
+ || !cancellationFileIdentityMatches(change.entry, currentStat)) {
6892
8557
  await handle.close();
6893
8558
  throw new Error(`Refusing cancellation because state identity changed: ${change.entry.path}.`);
6894
8559
  }