oh-my-codex 0.20.1 → 0.20.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 (654) hide show
  1. package/Cargo.lock +6 -6
  2. package/Cargo.toml +1 -1
  3. package/README.md +10 -1
  4. package/dist/adapt/__tests__/hermes.test.js.map +1 -1
  5. package/dist/adapt/contracts.d.ts.map +1 -1
  6. package/dist/agents/native-config.js.map +1 -1
  7. package/dist/auth/__tests__/hotswap.test.d.ts +2 -0
  8. package/dist/auth/__tests__/hotswap.test.d.ts.map +1 -0
  9. package/dist/auth/__tests__/hotswap.test.js +183 -0
  10. package/dist/auth/__tests__/hotswap.test.js.map +1 -0
  11. package/dist/auth/hotswap.d.ts.map +1 -1
  12. package/dist/auth/hotswap.js +17 -4
  13. package/dist/auth/hotswap.js.map +1 -1
  14. package/dist/auth/paths.js.map +1 -1
  15. package/dist/auth/storage.js.map +1 -1
  16. package/dist/autopilot/__tests__/ralplan-gate.test.js +4 -4
  17. package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
  18. package/dist/autopilot/completion-gate.js.map +1 -1
  19. package/dist/autopilot/fsm.d.ts +1 -1
  20. package/dist/autopilot/fsm.d.ts.map +1 -1
  21. package/dist/autoresearch/goal.js.map +1 -1
  22. package/dist/autoresearch/runtime.js.map +1 -1
  23. package/dist/capabilities/lockfile.js.map +1 -1
  24. package/dist/catalog/reader.js.map +1 -1
  25. package/dist/catalog/skill-mirror.js.map +1 -1
  26. package/dist/cli/__tests__/adapt-help.test.js.map +1 -1
  27. package/dist/cli/__tests__/agents-init.test.js.map +1 -1
  28. package/dist/cli/__tests__/agents.test.js.map +1 -1
  29. package/dist/cli/__tests__/api.test.js.map +1 -1
  30. package/dist/cli/__tests__/ask.test.js.map +1 -1
  31. package/dist/cli/__tests__/auth.test.js.map +1 -1
  32. package/dist/cli/__tests__/autoresearch-guided.test.js.map +1 -1
  33. package/dist/cli/__tests__/autoresearch.test.js.map +1 -1
  34. package/dist/cli/__tests__/capabilities.test.js.map +1 -1
  35. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  36. package/dist/cli/__tests__/doctor-context-window-warning.test.js.map +1 -1
  37. package/dist/cli/__tests__/doctor-invalid-config.test.js +47 -4
  38. package/dist/cli/__tests__/doctor-invalid-config.test.js.map +1 -1
  39. package/dist/cli/__tests__/doctor-team.test.js.map +1 -1
  40. package/dist/cli/__tests__/doctor-warning-copy.test.js +650 -15
  41. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  42. package/dist/cli/__tests__/exec.test.js.map +1 -1
  43. package/dist/cli/__tests__/imagegen-continuation.test.js.map +1 -1
  44. package/dist/cli/__tests__/index.test.js +216 -11
  45. package/dist/cli/__tests__/index.test.js.map +1 -1
  46. package/dist/cli/__tests__/launch-fallback.test.js +49 -3
  47. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  48. package/dist/cli/__tests__/mcp-parity.test.js +84 -12
  49. package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
  50. package/dist/cli/__tests__/native-hook-claim-journal.test.d.ts +2 -0
  51. package/dist/cli/__tests__/native-hook-claim-journal.test.d.ts.map +1 -0
  52. package/dist/cli/__tests__/native-hook-claim-journal.test.js +128 -0
  53. package/dist/cli/__tests__/native-hook-claim-journal.test.js.map +1 -0
  54. package/dist/cli/__tests__/nested-help-routing.test.js.map +1 -1
  55. package/dist/cli/__tests__/question.test.js.map +1 -1
  56. package/dist/cli/__tests__/ralph-deslop-contract.test.js.map +1 -1
  57. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js +6 -0
  58. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js.map +1 -1
  59. package/dist/cli/__tests__/ralph-prd-deep-interview.test.js.map +1 -1
  60. package/dist/cli/__tests__/ralph-prd-smoke.test.js.map +1 -1
  61. package/dist/cli/__tests__/ralph.test.js +8 -3
  62. package/dist/cli/__tests__/ralph.test.js.map +1 -1
  63. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.d.ts +2 -0
  64. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.d.ts.map +1 -0
  65. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js +187 -0
  66. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -0
  67. package/dist/cli/__tests__/ralplan.test.d.ts +2 -0
  68. package/dist/cli/__tests__/ralplan.test.d.ts.map +1 -0
  69. package/dist/cli/__tests__/ralplan.test.js +129 -0
  70. package/dist/cli/__tests__/ralplan.test.js.map +1 -0
  71. package/dist/cli/__tests__/resume.test.js +6 -1
  72. package/dist/cli/__tests__/resume.test.js.map +1 -1
  73. package/dist/cli/__tests__/session-scoped-runtime.test.js +25 -0
  74. package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
  75. package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
  76. package/dist/cli/__tests__/session-search.test.js.map +1 -1
  77. package/dist/cli/__tests__/setup-agents-overwrite.test.js +79 -0
  78. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  79. package/dist/cli/__tests__/setup-gh-star.test.js.map +1 -1
  80. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js +77 -3
  81. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js.map +1 -1
  82. package/dist/cli/__tests__/setup-hooks-trust-e2e.test.d.ts +2 -0
  83. package/dist/cli/__tests__/setup-hooks-trust-e2e.test.d.ts.map +1 -0
  84. package/dist/cli/__tests__/setup-hooks-trust-e2e.test.js +285 -0
  85. package/dist/cli/__tests__/setup-hooks-trust-e2e.test.js.map +1 -0
  86. package/dist/cli/__tests__/setup-install-mode.test.js +2261 -14
  87. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  88. package/dist/cli/__tests__/setup-preferences.test.d.ts +2 -0
  89. package/dist/cli/__tests__/setup-preferences.test.d.ts.map +1 -0
  90. package/dist/cli/__tests__/setup-preferences.test.js +79 -0
  91. package/dist/cli/__tests__/setup-preferences.test.js.map +1 -0
  92. package/dist/cli/__tests__/setup-refresh.test.js +33 -2
  93. package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
  94. package/dist/cli/__tests__/setup-scope.test.js +46 -3
  95. package/dist/cli/__tests__/setup-scope.test.js.map +1 -1
  96. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  97. package/dist/cli/__tests__/state.test.js +37 -0
  98. package/dist/cli/__tests__/state.test.js.map +1 -1
  99. package/dist/cli/__tests__/team.test.js.map +1 -1
  100. package/dist/cli/__tests__/ultragoal.test.js +61 -1
  101. package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
  102. package/dist/cli/__tests__/uninstall.test.js +1771 -24
  103. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  104. package/dist/cli/__tests__/update.test.js +58 -0
  105. package/dist/cli/__tests__/update.test.js.map +1 -1
  106. package/dist/cli/__tests__/url.test.js.map +1 -1
  107. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  108. package/dist/cli/adapt.js.map +1 -1
  109. package/dist/cli/agents-init.js.map +1 -1
  110. package/dist/cli/agents.js.map +1 -1
  111. package/dist/cli/api.js.map +1 -1
  112. package/dist/cli/ask.d.ts +1 -1
  113. package/dist/cli/ask.d.ts.map +1 -1
  114. package/dist/cli/ask.js.map +1 -1
  115. package/dist/cli/auth.js.map +1 -1
  116. package/dist/cli/autoresearch-guided.js.map +1 -1
  117. package/dist/cli/autoresearch-intake.js.map +1 -1
  118. package/dist/cli/capabilities.js.map +1 -1
  119. package/dist/cli/cleanup.js.map +1 -1
  120. package/dist/cli/codex-feature-probe.js.map +1 -1
  121. package/dist/cli/codex-home.js.map +1 -1
  122. package/dist/cli/doctor.d.ts +18 -0
  123. package/dist/cli/doctor.d.ts.map +1 -1
  124. package/dist/cli/doctor.js +594 -130
  125. package/dist/cli/doctor.js.map +1 -1
  126. package/dist/cli/explore.js.map +1 -1
  127. package/dist/cli/index.d.ts +11 -3
  128. package/dist/cli/index.d.ts.map +1 -1
  129. package/dist/cli/index.js +108 -20
  130. package/dist/cli/index.js.map +1 -1
  131. package/dist/cli/mcp-serve.js.map +1 -1
  132. package/dist/cli/mission.js.map +1 -1
  133. package/dist/cli/native-assets.js.map +1 -1
  134. package/dist/cli/native-hook-claim-journal.d.ts +18 -0
  135. package/dist/cli/native-hook-claim-journal.d.ts.map +1 -0
  136. package/dist/cli/native-hook-claim-journal.js +239 -0
  137. package/dist/cli/native-hook-claim-journal.js.map +1 -0
  138. package/dist/cli/omx.js.map +1 -1
  139. package/dist/cli/plugin-marketplace.js.map +1 -1
  140. package/dist/cli/project-runtime-codex-homes.js.map +1 -1
  141. package/dist/cli/question.js.map +1 -1
  142. package/dist/cli/ralph.d.ts.map +1 -1
  143. package/dist/cli/ralph.js +3 -2
  144. package/dist/cli/ralph.js.map +1 -1
  145. package/dist/cli/ralplan.d.ts +14 -0
  146. package/dist/cli/ralplan.d.ts.map +1 -0
  147. package/dist/cli/ralplan.js +203 -0
  148. package/dist/cli/ralplan.js.map +1 -0
  149. package/dist/cli/setup-preferences.d.ts +11 -0
  150. package/dist/cli/setup-preferences.d.ts.map +1 -1
  151. package/dist/cli/setup-preferences.js +32 -0
  152. package/dist/cli/setup-preferences.js.map +1 -1
  153. package/dist/cli/setup.d.ts +53 -0
  154. package/dist/cli/setup.d.ts.map +1 -1
  155. package/dist/cli/setup.js +1401 -398
  156. package/dist/cli/setup.js.map +1 -1
  157. package/dist/cli/sparkshell.js.map +1 -1
  158. package/dist/cli/star-prompt.js.map +1 -1
  159. package/dist/cli/state.d.ts.map +1 -1
  160. package/dist/cli/state.js +2 -1
  161. package/dist/cli/state.js.map +1 -1
  162. package/dist/cli/team.js.map +1 -1
  163. package/dist/cli/ultragoal.d.ts +5 -1
  164. package/dist/cli/ultragoal.d.ts.map +1 -1
  165. package/dist/cli/ultragoal.js +34 -2
  166. package/dist/cli/ultragoal.js.map +1 -1
  167. package/dist/cli/uninstall.d.ts +8 -0
  168. package/dist/cli/uninstall.d.ts.map +1 -1
  169. package/dist/cli/uninstall.js +944 -95
  170. package/dist/cli/uninstall.js.map +1 -1
  171. package/dist/cli/update.d.ts.map +1 -1
  172. package/dist/cli/update.js +8 -1
  173. package/dist/cli/update.js.map +1 -1
  174. package/dist/cli/url.js.map +1 -1
  175. package/dist/compat/__tests__/doctor-contract.test.js +5 -1
  176. package/dist/compat/__tests__/doctor-contract.test.js.map +1 -1
  177. package/dist/compat/__tests__/rust-runtime-compat.test.js.map +1 -1
  178. package/dist/config/__tests__/codex-hooks.test.js +1287 -11
  179. package/dist/config/__tests__/codex-hooks.test.js.map +1 -1
  180. package/dist/config/__tests__/generator-idempotent.test.js +850 -32
  181. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  182. package/dist/config/__tests__/generator-notify.test.js +6 -1
  183. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  184. package/dist/config/codex-feature-flags.d.ts.map +1 -1
  185. package/dist/config/codex-feature-flags.js.map +1 -1
  186. package/dist/config/codex-hooks.d.ts +124 -1
  187. package/dist/config/codex-hooks.d.ts.map +1 -1
  188. package/dist/config/codex-hooks.js +2142 -265
  189. package/dist/config/codex-hooks.js.map +1 -1
  190. package/dist/config/commit-lore-guard.js.map +1 -1
  191. package/dist/config/generator.d.ts +21 -16
  192. package/dist/config/generator.d.ts.map +1 -1
  193. package/dist/config/generator.js +991 -137
  194. package/dist/config/generator.js.map +1 -1
  195. package/dist/config/mcp-registry.js.map +1 -1
  196. package/dist/config/models.d.ts +2 -2
  197. package/dist/config/models.d.ts.map +1 -1
  198. package/dist/config/models.js.map +1 -1
  199. package/dist/config/omx-first-party-mcp.js.map +1 -1
  200. package/dist/config/team-mode.d.ts.map +1 -1
  201. package/dist/document-refresh/__tests__/enforcer.test.js.map +1 -1
  202. package/dist/document-refresh/enforcer.js.map +1 -1
  203. package/dist/exec/followup.js.map +1 -1
  204. package/dist/goal-workflows/artifacts.js.map +1 -1
  205. package/dist/hooks/__tests__/agents-overlay.test.js +7 -4
  206. package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
  207. package/dist/hooks/__tests__/analyze-routing-contract.test.js.map +1 -1
  208. package/dist/hooks/__tests__/analyze-skill-contract.test.js.map +1 -1
  209. package/dist/hooks/__tests__/anti-slop-workflow.test.js.map +1 -1
  210. package/dist/hooks/__tests__/autopilot-skill-contract.test.js +9 -3
  211. package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
  212. package/dist/hooks/__tests__/code-review-skill-contract.test.js.map +1 -1
  213. package/dist/hooks/__tests__/consensus-execution-handoff.test.js.map +1 -1
  214. package/dist/hooks/__tests__/deep-interview-contract.test.js.map +1 -1
  215. package/dist/hooks/__tests__/design-skill.test.js.map +1 -1
  216. package/dist/hooks/__tests__/keyword-detector.test.js +1372 -2
  217. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  218. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +14 -0
  219. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  220. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  221. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +669 -1
  222. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  223. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +2 -0
  224. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
  225. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js +142 -1
  226. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js.map +1 -1
  227. package/dist/hooks/__tests__/notify-hook-modules.test.js.map +1 -1
  228. package/dist/hooks/__tests__/notify-hook-non-omx-guard.test.js +48 -2
  229. package/dist/hooks/__tests__/notify-hook-non-omx-guard.test.js.map +1 -1
  230. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js +61 -44
  231. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js.map +1 -1
  232. package/dist/hooks/__tests__/notify-hook-regression-205.test.js.map +1 -1
  233. package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js +3 -2
  234. package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js.map +1 -1
  235. package/dist/hooks/__tests__/notify-hook-session-scope.test.js +243 -4
  236. package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
  237. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  238. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +8 -1
  239. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  240. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
  241. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  242. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -1
  243. package/dist/hooks/__tests__/notify-hook-visual-verdict.test.js.map +1 -1
  244. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  245. package/dist/hooks/__tests__/openclaw-setup-contract.test.js.map +1 -1
  246. package/dist/hooks/__tests__/pre-context-gate-skills.test.js.map +1 -1
  247. package/dist/hooks/__tests__/prometheus-strict-contract.test.js.map +1 -1
  248. package/dist/hooks/__tests__/prompt-guidance-fragments.test.js.map +1 -1
  249. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js.map +1 -1
  250. package/dist/hooks/__tests__/prompt-session-provenance.test.d.ts +2 -0
  251. package/dist/hooks/__tests__/prompt-session-provenance.test.d.ts.map +1 -0
  252. package/dist/hooks/__tests__/prompt-session-provenance.test.js +82 -0
  253. package/dist/hooks/__tests__/prompt-session-provenance.test.js.map +1 -0
  254. package/dist/hooks/__tests__/session.test.js +532 -11
  255. package/dist/hooks/__tests__/session.test.js.map +1 -1
  256. package/dist/hooks/__tests__/skill-catalog-hygiene.test.js.map +1 -1
  257. package/dist/hooks/__tests__/team-runtime-gating-docs-contract.test.js.map +1 -1
  258. package/dist/hooks/__tests__/visual-ralph-skill.test.js.map +1 -1
  259. package/dist/hooks/__tests__/visual-verdict-loop.test.js.map +1 -1
  260. package/dist/hooks/agents-overlay.js +5 -5
  261. package/dist/hooks/agents-overlay.js.map +1 -1
  262. package/dist/hooks/code-simplifier/index.js.map +1 -1
  263. package/dist/hooks/explore-routing.js.map +1 -1
  264. package/dist/hooks/extensibility/dispatcher.js.map +1 -1
  265. package/dist/hooks/extensibility/events.js.map +1 -1
  266. package/dist/hooks/extensibility/loader.js.map +1 -1
  267. package/dist/hooks/extensibility/plugin-runner-stdin.js.map +1 -1
  268. package/dist/hooks/extensibility/sdk/logging.js.map +1 -1
  269. package/dist/hooks/keyword-detector.d.ts +33 -7
  270. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  271. package/dist/hooks/keyword-detector.js +2228 -119
  272. package/dist/hooks/keyword-detector.js.map +1 -1
  273. package/dist/hooks/keyword-registry.d.ts +12 -0
  274. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  275. package/dist/hooks/keyword-registry.js +41 -0
  276. package/dist/hooks/keyword-registry.js.map +1 -1
  277. package/dist/hooks/prompt-session-provenance.d.ts +87 -0
  278. package/dist/hooks/prompt-session-provenance.d.ts.map +1 -0
  279. package/dist/hooks/prompt-session-provenance.js +180 -0
  280. package/dist/hooks/prompt-session-provenance.js.map +1 -0
  281. package/dist/hooks/session.d.ts +139 -34
  282. package/dist/hooks/session.d.ts.map +1 -1
  283. package/dist/hooks/session.js +1239 -317
  284. package/dist/hooks/session.js.map +1 -1
  285. package/dist/hooks/task-size-detector.js.map +1 -1
  286. package/dist/hud/__tests__/state.test.js.map +1 -1
  287. package/dist/hud/authority.js.map +1 -1
  288. package/dist/hud/constants.js.map +1 -1
  289. package/dist/hud/index.js.map +1 -1
  290. package/dist/hud/reconcile.js.map +1 -1
  291. package/dist/hud/render.js.map +1 -1
  292. package/dist/hud/state.js.map +1 -1
  293. package/dist/hud/tmux.js.map +1 -1
  294. package/dist/leader/__tests__/contract.test.js +103 -1
  295. package/dist/leader/__tests__/contract.test.js.map +1 -1
  296. package/dist/leader/contract.d.ts +26 -5
  297. package/dist/leader/contract.d.ts.map +1 -1
  298. package/dist/leader/contract.js +109 -3
  299. package/dist/leader/contract.js.map +1 -1
  300. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  301. package/dist/mcp/__tests__/server-lifecycle.test.js +15 -2
  302. package/dist/mcp/__tests__/server-lifecycle.test.js.map +1 -1
  303. package/dist/mcp/__tests__/state-paths.test.js +98 -1
  304. package/dist/mcp/__tests__/state-paths.test.js.map +1 -1
  305. package/dist/mcp/bootstrap.js.map +1 -1
  306. package/dist/mcp/code-intel-server.d.ts +58 -58
  307. package/dist/mcp/code-intel-server.d.ts.map +1 -1
  308. package/dist/mcp/code-intel-server.js.map +1 -1
  309. package/dist/mcp/hermes-bridge.js.map +1 -1
  310. package/dist/mcp/hermes-server.d.ts +70 -70
  311. package/dist/mcp/hermes-server.d.ts.map +1 -1
  312. package/dist/mcp/lifecycle-telemetry.js.map +1 -1
  313. package/dist/mcp/memory-server.d.ts +34 -34
  314. package/dist/mcp/memory-server.d.ts.map +1 -1
  315. package/dist/mcp/memory-server.js.map +1 -1
  316. package/dist/mcp/state-paths.d.ts +14 -0
  317. package/dist/mcp/state-paths.d.ts.map +1 -1
  318. package/dist/mcp/state-paths.js +96 -20
  319. package/dist/mcp/state-paths.js.map +1 -1
  320. package/dist/mcp/state-server.d.ts +47 -47
  321. package/dist/mcp/state-server.d.ts.map +1 -1
  322. package/dist/mcp/trace-server.d.ts +4 -4
  323. package/dist/mcp/trace-server.d.ts.map +1 -1
  324. package/dist/mcp/trace-server.js.map +1 -1
  325. package/dist/mcp/wiki-server.d.ts +23 -23
  326. package/dist/mcp/wiki-server.d.ts.map +1 -1
  327. package/dist/mcp/wiki-server.js.map +1 -1
  328. package/dist/modes/__tests__/base-session-scope.test.js +53 -1
  329. package/dist/modes/__tests__/base-session-scope.test.js.map +1 -1
  330. package/dist/modes/base.d.ts.map +1 -1
  331. package/dist/modes/base.js +5 -5
  332. package/dist/modes/base.js.map +1 -1
  333. package/dist/notifications/__tests__/lifecycle-dedupe.test.js.map +1 -1
  334. package/dist/notifications/__tests__/reply-listener.test.js.map +1 -1
  335. package/dist/notifications/__tests__/session-idle-tail-dedupe.test.js +177 -85
  336. package/dist/notifications/__tests__/session-idle-tail-dedupe.test.js.map +1 -1
  337. package/dist/notifications/__tests__/session-registry.test.js.map +1 -1
  338. package/dist/notifications/__tests__/template-engine.test.js.map +1 -1
  339. package/dist/notifications/__tests__/verbosity.test.js.map +1 -1
  340. package/dist/notifications/http-client.js.map +1 -1
  341. package/dist/notifications/index.d.ts +4 -1
  342. package/dist/notifications/index.d.ts.map +1 -1
  343. package/dist/notifications/index.js +3 -9
  344. package/dist/notifications/index.js.map +1 -1
  345. package/dist/notifications/lifecycle-dedupe.js.map +1 -1
  346. package/dist/notifications/reply-listener.d.ts.map +1 -1
  347. package/dist/notifications/reply-listener.js.map +1 -1
  348. package/dist/notifications/session-registry.js.map +1 -1
  349. package/dist/notifications/session-status.js.map +1 -1
  350. package/dist/notifications/temp-contract.js.map +1 -1
  351. package/dist/notifications/tmux-detector.js.map +1 -1
  352. package/dist/notifications/tmux.js.map +1 -1
  353. package/dist/openclaw/config.js.map +1 -1
  354. package/dist/openclaw/dispatcher.js.map +1 -1
  355. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  356. package/dist/pipeline/__tests__/stages.test.js +8 -5
  357. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  358. package/dist/pipeline/stages/code-review.js.map +1 -1
  359. package/dist/pipeline/stages/ralph-verify.js.map +1 -1
  360. package/dist/pipeline/stages/ralplan.js.map +1 -1
  361. package/dist/pipeline/stages/team-exec.js.map +1 -1
  362. package/dist/pipeline/stages/ultragoal.js.map +1 -1
  363. package/dist/pipeline/stages/ultraqa.js.map +1 -1
  364. package/dist/planning/__tests__/approved-execution-lifecycle-matrix.test.js.map +1 -1
  365. package/dist/planning/artifact-names.js.map +1 -1
  366. package/dist/planning/artifacts.js.map +1 -1
  367. package/dist/question/__tests__/state.test.js.map +1 -1
  368. package/dist/question/__tests__/ui.test.js.map +1 -1
  369. package/dist/question/autopilot-wait.js.map +1 -1
  370. package/dist/question/client.d.ts.map +1 -1
  371. package/dist/question/client.js.map +1 -1
  372. package/dist/question/deep-interview.js.map +1 -1
  373. package/dist/question/events.js.map +1 -1
  374. package/dist/question/renderer.js.map +1 -1
  375. package/dist/question/state.d.ts.map +1 -1
  376. package/dist/question/state.js.map +1 -1
  377. package/dist/question/ui.js.map +1 -1
  378. package/dist/ralph/contract.d.ts +1 -1
  379. package/dist/ralph/contract.d.ts.map +1 -1
  380. package/dist/ralplan/__tests__/consensus-gate.test.js +403 -8
  381. package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -1
  382. package/dist/ralplan/__tests__/runtime.test.js +112 -14
  383. package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
  384. package/dist/ralplan/consensus-gate.d.ts +3 -3
  385. package/dist/ralplan/consensus-gate.d.ts.map +1 -1
  386. package/dist/ralplan/consensus-gate.js +155 -60
  387. package/dist/ralplan/consensus-gate.js.map +1 -1
  388. package/dist/ralplan/runtime.d.ts +2 -2
  389. package/dist/ralplan/runtime.d.ts.map +1 -1
  390. package/dist/ralplan/runtime.js +1 -2
  391. package/dist/ralplan/runtime.js.map +1 -1
  392. package/dist/runtime/bridge.d.ts.map +1 -1
  393. package/dist/runtime/bridge.js.map +1 -1
  394. package/dist/runtime/process-tree.js.map +1 -1
  395. package/dist/runtime/run-loop.js.map +1 -1
  396. package/dist/runtime/run-outcome.d.ts +3 -3
  397. package/dist/runtime/run-outcome.d.ts.map +1 -1
  398. package/dist/runtime/run-outcome.js.map +1 -1
  399. package/dist/runtime/run-state.js.map +1 -1
  400. package/dist/scripts/__tests__/codex-native-hook.test.js +4121 -592
  401. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  402. package/dist/scripts/__tests__/fixtures/issue-3138-installed-smoke.d.ts +2 -0
  403. package/dist/scripts/__tests__/fixtures/issue-3138-installed-smoke.d.ts.map +1 -0
  404. package/dist/scripts/__tests__/fixtures/issue-3138-installed-smoke.js +634 -0
  405. package/dist/scripts/__tests__/fixtures/issue-3138-installed-smoke.js.map +1 -0
  406. package/dist/scripts/__tests__/generate-release-body.test.js.map +1 -1
  407. package/dist/scripts/__tests__/hook-derived-watcher.test.js.map +1 -1
  408. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  409. package/dist/scripts/__tests__/notify-fallback-delivery.test.d.ts +2 -0
  410. package/dist/scripts/__tests__/notify-fallback-delivery.test.d.ts.map +1 -0
  411. package/dist/scripts/__tests__/notify-fallback-delivery.test.js +174 -0
  412. package/dist/scripts/__tests__/notify-fallback-delivery.test.js.map +1 -0
  413. package/dist/scripts/__tests__/notify-state-io.test.js +57 -1
  414. package/dist/scripts/__tests__/notify-state-io.test.js.map +1 -1
  415. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.d.ts +2 -0
  416. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.d.ts.map +1 -0
  417. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js +205 -0
  418. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js.map +1 -0
  419. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.d.ts +2 -0
  420. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.d.ts.map +1 -0
  421. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js +175 -0
  422. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -0
  423. package/dist/scripts/__tests__/run-test-files.test.js.map +1 -1
  424. package/dist/scripts/__tests__/smoke-packed-install.test.js +848 -4
  425. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  426. package/dist/scripts/__tests__/test-reply-listener-live.test.js.map +1 -1
  427. package/dist/scripts/build-api.js.map +1 -1
  428. package/dist/scripts/build-explore-harness.js.map +1 -1
  429. package/dist/scripts/build-sparkshell.js.map +1 -1
  430. package/dist/scripts/cleanup-explore-harness.js.map +1 -1
  431. package/dist/scripts/codex-execution-surface.js.map +1 -1
  432. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  433. package/dist/scripts/codex-native-hook.js +689 -156
  434. package/dist/scripts/codex-native-hook.js.map +1 -1
  435. package/dist/scripts/generate-native-release-manifest.js.map +1 -1
  436. package/dist/scripts/generate-release-body.js.map +1 -1
  437. package/dist/scripts/hook-payload-guard.d.ts.map +1 -1
  438. package/dist/scripts/hook-payload-guard.js.map +1 -1
  439. package/dist/scripts/notify-fallback-delivery.d.ts +48 -0
  440. package/dist/scripts/notify-fallback-delivery.d.ts.map +1 -0
  441. package/dist/scripts/notify-fallback-delivery.js +419 -0
  442. package/dist/scripts/notify-fallback-delivery.js.map +1 -0
  443. package/dist/scripts/notify-fallback-watcher.js +136 -30
  444. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  445. package/dist/scripts/notify-hook/auto-nudge.d.ts +18 -9
  446. package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
  447. package/dist/scripts/notify-hook/auto-nudge.js +39 -13
  448. package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
  449. package/dist/scripts/notify-hook/managed-tmux.d.ts +23 -0
  450. package/dist/scripts/notify-hook/managed-tmux.d.ts.map +1 -1
  451. package/dist/scripts/notify-hook/managed-tmux.js +122 -12
  452. package/dist/scripts/notify-hook/managed-tmux.js.map +1 -1
  453. package/dist/scripts/notify-hook/orchestration-intent.d.ts +7 -7
  454. package/dist/scripts/notify-hook/orchestration-intent.d.ts.map +1 -1
  455. package/dist/scripts/notify-hook/ralph-session-resume.d.ts +5 -2
  456. package/dist/scripts/notify-hook/ralph-session-resume.d.ts.map +1 -1
  457. package/dist/scripts/notify-hook/ralph-session-resume.js +33 -37
  458. package/dist/scripts/notify-hook/ralph-session-resume.js.map +1 -1
  459. package/dist/scripts/notify-hook/state-io.d.ts +17 -0
  460. package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
  461. package/dist/scripts/notify-hook/state-io.js +73 -11
  462. package/dist/scripts/notify-hook/state-io.js.map +1 -1
  463. package/dist/scripts/notify-hook/team-dispatch.d.ts +1 -1
  464. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  465. package/dist/scripts/notify-hook/team-leader-nudge.d.ts +2 -2
  466. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +4 -4
  467. package/dist/scripts/notify-hook/team-worker-posttooluse.js.map +1 -1
  468. package/dist/scripts/notify-hook/team-worker-stop.d.ts +1 -1
  469. package/dist/scripts/notify-hook/team-worker.d.ts +2 -2
  470. package/dist/scripts/notify-hook/tmux-injection.d.ts +8 -1
  471. package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
  472. package/dist/scripts/notify-hook/tmux-injection.js +25 -16
  473. package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
  474. package/dist/scripts/notify-hook/visual-verdict.d.ts +1 -1
  475. package/dist/scripts/notify-hook/visual-verdict.d.ts.map +1 -1
  476. package/dist/scripts/notify-hook/visual-verdict.js +15 -13
  477. package/dist/scripts/notify-hook/visual-verdict.js.map +1 -1
  478. package/dist/scripts/notify-hook.d.ts +24 -1
  479. package/dist/scripts/notify-hook.d.ts.map +1 -1
  480. package/dist/scripts/notify-hook.js +266 -90
  481. package/dist/scripts/notify-hook.js.map +1 -1
  482. package/dist/scripts/postinstall.js.map +1 -1
  483. package/dist/scripts/prompt-inventory.js.map +1 -1
  484. package/dist/scripts/smoke-packed-install.d.ts +879 -2
  485. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  486. package/dist/scripts/smoke-packed-install.js +1528 -7
  487. package/dist/scripts/smoke-packed-install.js.map +1 -1
  488. package/dist/scripts/sync-plugin-mirror.js.map +1 -1
  489. package/dist/scripts/test-reply-listener-live.js.map +1 -1
  490. package/dist/scripts/test-sparkshell.js.map +1 -1
  491. package/dist/scripts/verify-native-agents.js.map +1 -1
  492. package/dist/session-history/friction.js.map +1 -1
  493. package/dist/session-history/search.js.map +1 -1
  494. package/dist/sidecar/__tests__/boundary.test.js.map +1 -1
  495. package/dist/sidecar/collector.js.map +1 -1
  496. package/dist/sidecar/index.js.map +1 -1
  497. package/dist/sidecar/render.js.map +1 -1
  498. package/dist/sidecar/tmux.js.map +1 -1
  499. package/dist/state/__tests__/operations.test.js +64 -15
  500. package/dist/state/__tests__/operations.test.js.map +1 -1
  501. package/dist/state/__tests__/skill-active.test.js +19 -1
  502. package/dist/state/__tests__/skill-active.test.js.map +1 -1
  503. package/dist/state/__tests__/workflow-transition.test.js +61 -0
  504. package/dist/state/__tests__/workflow-transition.test.js.map +1 -1
  505. package/dist/state/mode-state-context.js.map +1 -1
  506. package/dist/state/operations.d.ts +1 -1
  507. package/dist/state/operations.d.ts.map +1 -1
  508. package/dist/state/operations.js +49 -55
  509. package/dist/state/operations.js.map +1 -1
  510. package/dist/state/paths.d.ts +1 -1
  511. package/dist/state/paths.d.ts.map +1 -1
  512. package/dist/state/paths.js +1 -1
  513. package/dist/state/paths.js.map +1 -1
  514. package/dist/state/skill-active.d.ts +13 -1
  515. package/dist/state/skill-active.d.ts.map +1 -1
  516. package/dist/state/skill-active.js +43 -4
  517. package/dist/state/skill-active.js.map +1 -1
  518. package/dist/state/terminal-normalization.js.map +1 -1
  519. package/dist/state/workflow-transition-reconcile.d.ts.map +1 -1
  520. package/dist/state/workflow-transition-reconcile.js +20 -4
  521. package/dist/state/workflow-transition-reconcile.js.map +1 -1
  522. package/dist/state/workflow-transition.d.ts +1 -1
  523. package/dist/state/workflow-transition.d.ts.map +1 -1
  524. package/dist/state/workflow-transition.js.map +1 -1
  525. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts +2 -0
  526. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts.map +1 -0
  527. package/dist/subagents/__tests__/adapted-role-binding.test.js +988 -0
  528. package/dist/subagents/__tests__/adapted-role-binding.test.js.map +1 -0
  529. package/dist/subagents/__tests__/leader-bootstrap-3181.test.d.ts +2 -0
  530. package/dist/subagents/__tests__/leader-bootstrap-3181.test.d.ts.map +1 -0
  531. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +273 -0
  532. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -0
  533. package/dist/subagents/__tests__/tracker.test.js +1550 -10
  534. package/dist/subagents/__tests__/tracker.test.js.map +1 -1
  535. package/dist/subagents/adapted-role-binding.d.ts +17 -0
  536. package/dist/subagents/adapted-role-binding.d.ts.map +1 -0
  537. package/dist/subagents/adapted-role-binding.js +75 -0
  538. package/dist/subagents/adapted-role-binding.js.map +1 -0
  539. package/dist/subagents/role-routing-marker.d.ts +10 -0
  540. package/dist/subagents/role-routing-marker.d.ts.map +1 -0
  541. package/dist/subagents/role-routing-marker.js +136 -0
  542. package/dist/subagents/role-routing-marker.js.map +1 -0
  543. package/dist/subagents/tracker.d.ts +172 -0
  544. package/dist/subagents/tracker.d.ts.map +1 -1
  545. package/dist/subagents/tracker.js +1077 -61
  546. package/dist/subagents/tracker.js.map +1 -1
  547. package/dist/team/__tests__/approved-execution.test.js.map +1 -1
  548. package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
  549. package/dist/team/__tests__/model-contract.test.js +231 -1
  550. package/dist/team/__tests__/model-contract.test.js.map +1 -1
  551. package/dist/team/__tests__/role-router.test.js.map +1 -1
  552. package/dist/team/__tests__/runtime.test.js +401 -1
  553. package/dist/team/__tests__/runtime.test.js.map +1 -1
  554. package/dist/team/__tests__/scaling.test.js +181 -0
  555. package/dist/team/__tests__/scaling.test.js.map +1 -1
  556. package/dist/team/__tests__/state-root.test.js.map +1 -1
  557. package/dist/team/__tests__/tmux-session.test.js +434 -4
  558. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  559. package/dist/team/__tests__/tmux-test-fixture.js.map +1 -1
  560. package/dist/team/api-interop.d.ts +2 -2
  561. package/dist/team/api-interop.d.ts.map +1 -1
  562. package/dist/team/api-interop.js.map +1 -1
  563. package/dist/team/commit-hygiene.js.map +1 -1
  564. package/dist/team/contracts.d.ts +5 -5
  565. package/dist/team/contracts.d.ts.map +1 -1
  566. package/dist/team/delivery-log.js.map +1 -1
  567. package/dist/team/followup-planner.js.map +1 -1
  568. package/dist/team/goal-workflow.js.map +1 -1
  569. package/dist/team/idle-nudge.d.ts.map +1 -1
  570. package/dist/team/model-contract.d.ts +20 -1
  571. package/dist/team/model-contract.d.ts.map +1 -1
  572. package/dist/team/model-contract.js +267 -30
  573. package/dist/team/model-contract.js.map +1 -1
  574. package/dist/team/orchestrator.js.map +1 -1
  575. package/dist/team/pane-status.js.map +1 -1
  576. package/dist/team/phase-controller.js.map +1 -1
  577. package/dist/team/reminder-intents.d.ts +1 -1
  578. package/dist/team/reminder-intents.d.ts.map +1 -1
  579. package/dist/team/reminder-intents.js.map +1 -1
  580. package/dist/team/runtime-cli.js.map +1 -1
  581. package/dist/team/runtime.d.ts.map +1 -1
  582. package/dist/team/runtime.js +83 -52
  583. package/dist/team/runtime.js.map +1 -1
  584. package/dist/team/scaling.d.ts +9 -7
  585. package/dist/team/scaling.d.ts.map +1 -1
  586. package/dist/team/scaling.js +69 -28
  587. package/dist/team/scaling.js.map +1 -1
  588. package/dist/team/state/dispatch-lock.js.map +1 -1
  589. package/dist/team/state/dispatch.js.map +1 -1
  590. package/dist/team/state/events.js.map +1 -1
  591. package/dist/team/state/tasks.js.map +1 -1
  592. package/dist/team/state-root.js.map +1 -1
  593. package/dist/team/state.js.map +1 -1
  594. package/dist/team/team-identity.d.ts.map +1 -1
  595. package/dist/team/team-identity.js.map +1 -1
  596. package/dist/team/tmux-session.d.ts +2 -0
  597. package/dist/team/tmux-session.d.ts.map +1 -1
  598. package/dist/team/tmux-session.js +90 -42
  599. package/dist/team/tmux-session.js.map +1 -1
  600. package/dist/team/worker-bootstrap.js.map +1 -1
  601. package/dist/team/worktree.js.map +1 -1
  602. package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
  603. package/dist/ultragoal/__tests__/docs-contract.test.js.map +1 -1
  604. package/dist/ultragoal/artifacts.d.ts.map +1 -1
  605. package/dist/ultragoal/artifacts.js +4 -1
  606. package/dist/ultragoal/artifacts.js.map +1 -1
  607. package/dist/url-reader/index.js.map +1 -1
  608. package/dist/utils/agents-model-table.js.map +1 -1
  609. package/dist/utils/package.js.map +1 -1
  610. package/dist/utils/paths.js.map +1 -1
  611. package/dist/utils/platform-command.js.map +1 -1
  612. package/dist/utils/repo-deps.js.map +1 -1
  613. package/dist/utils/version.js.map +1 -1
  614. package/dist/verification/__tests__/ci-rust-gates.test.js +1 -1
  615. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js.map +1 -1
  616. package/dist/verification/verifier.js.map +1 -1
  617. package/dist/visual/constants.d.ts +1 -1
  618. package/dist/visual/constants.d.ts.map +1 -1
  619. package/dist/vscode/__tests__/index.test.js.map +1 -1
  620. package/dist/vscode/index.js.map +1 -1
  621. package/dist/wiki/__tests__/cjk-tokenize.test.js.map +1 -1
  622. package/dist/wiki/__tests__/lint.test.js.map +1 -1
  623. package/dist/wiki/__tests__/query.test.js.map +1 -1
  624. package/dist/wiki/__tests__/storage.test.js.map +1 -1
  625. package/dist/wiki/__tests__/test-helpers.d.ts.map +1 -1
  626. package/dist/wiki/lint.js.map +1 -1
  627. package/dist/wiki/query.js.map +1 -1
  628. package/dist/wiki/storage.js.map +1 -1
  629. package/package.json +2 -2
  630. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  631. package/plugins/oh-my-codex/skills/omx-setup/SKILL.md +10 -5
  632. package/plugins/oh-my-codex/skills/ralph/SKILL.md +4 -2
  633. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +5 -2
  634. package/skills/omx-setup/SKILL.md +10 -5
  635. package/skills/ralph/SKILL.md +4 -2
  636. package/skills/ralplan/SKILL.md +5 -2
  637. package/src/scripts/__tests__/codex-native-hook.test.ts +17522 -10630
  638. package/src/scripts/__tests__/fixtures/issue-3138-installed-smoke.ts +734 -0
  639. package/src/scripts/__tests__/notify-fallback-delivery.test.ts +177 -0
  640. package/src/scripts/__tests__/notify-state-io.test.ts +73 -0
  641. package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +215 -0
  642. package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +193 -0
  643. package/src/scripts/__tests__/smoke-packed-install.test.ts +991 -4
  644. package/src/scripts/codex-native-hook.ts +779 -159
  645. package/src/scripts/notify-fallback-delivery.ts +416 -0
  646. package/src/scripts/notify-fallback-watcher.ts +132 -40
  647. package/src/scripts/notify-hook/auto-nudge.ts +43 -12
  648. package/src/scripts/notify-hook/managed-tmux.ts +149 -12
  649. package/src/scripts/notify-hook/ralph-session-resume.ts +37 -36
  650. package/src/scripts/notify-hook/state-io.ts +100 -13
  651. package/src/scripts/notify-hook/tmux-injection.ts +38 -21
  652. package/src/scripts/notify-hook/visual-verdict.ts +22 -12
  653. package/src/scripts/notify-hook.ts +335 -90
  654. package/src/scripts/smoke-packed-install.ts +1829 -6
@@ -1,12 +1,13 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
4
- import { existsSync } from 'node:fs';
5
- import { join, dirname } from 'node:path';
3
+ import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
4
+ import { existsSync, lstatSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { basename, dirname, join } from 'node:path';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { buildManagedCodexHooksConfig } from '../../config/codex-hooks.js';
9
+ import { buildManagedCodexHookTrustState, buildManagedCodexHooksConfig, buildManagedCodexNativeHookWindowsShimContent, buildManagedCodexNativeHookWindowsShimPath, } from '../../config/codex-hooks.js';
10
+ import { uninstall } from '../uninstall.js';
10
11
  import TOML from '@iarna/toml';
11
12
  function runOmx(cwd, argv, envOverrides = {}) {
12
13
  const testDir = dirname(fileURLToPath(import.meta.url));
@@ -30,7 +31,45 @@ function runOmx(cwd, argv, envOverrides = {}) {
30
31
  };
31
32
  }
32
33
  function shouldSkipForSpawnPermissions(err) {
33
- return typeof err === 'string' && /(EPERM|EACCES)/i.test(err);
34
+ return /(EPERM|EACCES)/i.test(err);
35
+ }
36
+ async function withCwd(cwd, run) {
37
+ const previousCwd = process.cwd();
38
+ process.chdir(cwd);
39
+ try {
40
+ return await run();
41
+ }
42
+ finally {
43
+ process.chdir(previousCwd);
44
+ }
45
+ }
46
+ async function withDriveQualifiedWindowsCodexHome(wd, run) {
47
+ const previousHome = process.env.HOME;
48
+ const previousCodexHome = process.env.CODEX_HOME;
49
+ const codexHomeDir = 'C:\\Users\\omx\\.codex';
50
+ process.env.HOME = wd;
51
+ process.env.CODEX_HOME = codexHomeDir;
52
+ try {
53
+ return await withCwd(wd, async () => {
54
+ await mkdir(codexHomeDir, { recursive: true });
55
+ return run(codexHomeDir);
56
+ });
57
+ }
58
+ finally {
59
+ if (typeof previousHome === 'string')
60
+ process.env.HOME = previousHome;
61
+ else
62
+ delete process.env.HOME;
63
+ if (typeof previousCodexHome === 'string') {
64
+ process.env.CODEX_HOME = previousCodexHome;
65
+ }
66
+ else {
67
+ delete process.env.CODEX_HOME;
68
+ }
69
+ }
70
+ }
71
+ function packageRoot() {
72
+ return join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
34
73
  }
35
74
  /** Build a realistic OMX config.toml for testing */
36
75
  function buildOmxConfig() {
@@ -358,6 +397,8 @@ describe('omx uninstall', () => {
358
397
  managedBy: 'oh-my-codex',
359
398
  version: 1,
360
399
  previousNotify: ['node', staleDispatcher, '--metadata', metadataPath],
400
+ omxNotify: ['node', join(stalePkgRoot, 'dist', 'scripts', 'notify-hook.js')],
401
+ dispatcherNotify: ['node', staleDispatcher, '--metadata', metadataPath],
361
402
  }));
362
403
  const res = runOmx(wd, ['uninstall'], { HOME: home });
363
404
  if (shouldSkipForSpawnPermissions(res.error))
@@ -407,41 +448,1493 @@ describe('omx uninstall', () => {
407
448
  await rm(wd, { recursive: true, force: true });
408
449
  }
409
450
  });
410
- it('preserves user hooks while removing OMX-managed wrappers', async () => {
411
- const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-'));
451
+ it('fails closed for unsupported root metadata in hooks.json', async () => {
452
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-invalid-hooks-root-'));
453
+ try {
454
+ const home = join(wd, 'home');
455
+ const codexDir = join(home, '.codex');
456
+ const configPath = join(codexDir, 'config.toml');
457
+ const hooksPath = join(codexDir, 'hooks.json');
458
+ const config = buildOmxConfig().replace(/^hooks = true$/m, 'codex_hooks = true');
459
+ const hooks = JSON.stringify({
460
+ version: 1,
461
+ hooks: {
462
+ SessionStart: [{
463
+ hooks: [{ type: 'command', command: 'node "/repo/dist/scripts/codex-native-hook.js"' }],
464
+ }],
465
+ },
466
+ }, null, 2) + '\n';
467
+ await mkdir(codexDir, { recursive: true });
468
+ await writeFile(configPath, config);
469
+ await writeFile(hooksPath, hooks);
470
+ const res = runOmx(wd, ['uninstall'], { HOME: home });
471
+ if (shouldSkipForSpawnPermissions(res.error))
472
+ return;
473
+ assert.equal(res.status, 1, res.stderr || res.stdout);
474
+ assert.match(res.stderr, /unknown root field version/);
475
+ assert.equal(await readFile(configPath, 'utf-8'), config);
476
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
477
+ }
478
+ finally {
479
+ await rm(wd, { recursive: true, force: true });
480
+ }
481
+ });
482
+ it('removes safely positioned managed wrappers while preserving foreign hooks and the native feature flag', async () => {
483
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-safe-foreign-hooks-'));
412
484
  try {
413
485
  const home = join(wd, 'home');
414
486
  const codexDir = join(home, '.codex');
487
+ const configPath = join(codexDir, 'config.toml');
488
+ const hooksPath = join(codexDir, 'hooks.json');
415
489
  await mkdir(codexDir, { recursive: true });
416
- await writeFile(join(codexDir, 'config.toml'), buildOmxConfig().replace(/^hooks = true$/m, 'codex_hooks = true'));
417
- await writeFile(join(codexDir, 'hooks.json'), JSON.stringify({
490
+ await writeFile(configPath, buildOmxConfig().replace(/^hooks = true$/m, 'codex_hooks = true'));
491
+ await writeFile(hooksPath, JSON.stringify({
418
492
  hooks: {
419
493
  SessionStart: [
420
- {
494
+ { hooks: [{ type: 'command', command: 'echo keep-me' }] },
495
+ { matcher: 'startup|resume|clear', hooks: [{ type: 'command', command: 'node "/repo/dist/scripts/codex-native-hook.js"' }] },
496
+ ],
497
+ },
498
+ }, null, 2) + '\n');
499
+ const res = runOmx(wd, ['uninstall'], { HOME: home });
500
+ if (shouldSkipForSpawnPermissions(res.error))
501
+ return;
502
+ assert.equal(res.status, 0, res.stderr || res.stdout);
503
+ const hooks = await readFile(hooksPath, 'utf-8');
504
+ assert.match(hooks, /echo keep-me/);
505
+ assert.doesNotMatch(hooks, /codex-native-hook\.js/);
506
+ const config = await readFile(configPath, 'utf-8');
507
+ assert.match(config, /^hooks = true$/m);
508
+ assert.doesNotMatch(config, /^codex_hooks\s*=/m);
509
+ }
510
+ finally {
511
+ await rm(wd, { recursive: true, force: true });
512
+ }
513
+ });
514
+ it('fails closed without removing a shell-expanding foreign command that resembles an OMX hook', async () => {
515
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-shell-expanding-hook-'));
516
+ try {
517
+ const home = join(wd, 'home');
518
+ const codexDir = join(home, '.codex');
519
+ const configPath = join(codexDir, 'config.toml');
520
+ const hooksPath = join(codexDir, 'hooks.json');
521
+ const config = buildOmxConfig();
522
+ const hooks = JSON.stringify({
523
+ hooks: {
524
+ SessionStart: [{
525
+ matcher: 'startup|resume|clear',
526
+ hooks: [{ type: 'command', command: 'node "$HOME/repo/dist/scripts/codex-native-hook.js"' }],
527
+ }],
528
+ },
529
+ }, null, 2) + '\n';
530
+ await mkdir(codexDir, { recursive: true });
531
+ await writeFile(configPath, config);
532
+ await writeFile(hooksPath, hooks);
533
+ const res = runOmx(wd, ['uninstall'], { HOME: home });
534
+ if (shouldSkipForSpawnPermissions(res.error))
535
+ return;
536
+ assert.equal(res.status, 1, res.stderr || res.stdout);
537
+ assert.match(res.stderr, /ambiguous_managed_handler|does not match the managed command grammar/i);
538
+ assert.equal(await readFile(configPath, 'utf-8'), config);
539
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
540
+ }
541
+ finally {
542
+ await rm(wd, { recursive: true, force: true });
543
+ }
544
+ });
545
+ it('fails closed without cleaning config when managed removal would shift a foreign handler', async () => {
546
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-unsafe-foreign-hooks-'));
547
+ try {
548
+ const home = join(wd, 'home');
549
+ const codexDir = join(home, '.codex');
550
+ const configPath = join(codexDir, 'config.toml');
551
+ const hooksPath = join(codexDir, 'hooks.json');
552
+ const config = buildOmxConfig();
553
+ const hooks = JSON.stringify({
554
+ hooks: {
555
+ SessionStart: [{
556
+ matcher: 'startup|resume|clear',
421
557
  hooks: [
422
558
  { type: 'command', command: 'node "/repo/dist/scripts/codex-native-hook.js"' },
423
559
  { type: 'command', command: 'echo keep-me' },
424
560
  ],
561
+ }],
562
+ },
563
+ }, null, 2) + '\n';
564
+ await mkdir(codexDir, { recursive: true });
565
+ await writeFile(configPath, config);
566
+ await writeFile(hooksPath, hooks);
567
+ const res = runOmx(wd, ['uninstall'], { HOME: home });
568
+ if (shouldSkipForSpawnPermissions(res.error))
569
+ return;
570
+ assert.equal(res.status, 1, res.stderr || res.stdout);
571
+ assert.match(res.stderr, /unsafe_managed_removal|shift a foreign coordinate/i);
572
+ assert.equal(await readFile(configPath, 'utf-8'), config);
573
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
574
+ }
575
+ finally {
576
+ await rm(wd, { recursive: true, force: true });
577
+ }
578
+ });
579
+ it('fails before uninstall writes when marker-wrapped trust state has foreign siblings', async () => {
580
+ const representations = [
581
+ {
582
+ name: 'inline',
583
+ render: (key, hash) => [
584
+ `hooks = { state = { ${JSON.stringify(key)} = { trusted_hash = ${JSON.stringify(hash)}, foreign = true } } }`,
585
+ ],
586
+ },
587
+ {
588
+ name: 'dotted',
589
+ render: (key, hash) => [
590
+ `hooks.state.${JSON.stringify(key)}.trusted_hash = ${JSON.stringify(hash)}`,
591
+ `hooks.state.${JSON.stringify(key)}.foreign = true`,
592
+ ],
593
+ },
594
+ {
595
+ name: 'table',
596
+ render: (key, hash) => [
597
+ `[hooks.state.${JSON.stringify(key)}]`,
598
+ `trusted_hash = ${JSON.stringify(hash)}`,
599
+ 'foreign = true',
600
+ ],
601
+ },
602
+ {
603
+ name: 'separate-foreign-table',
604
+ render: (key, hash) => [
605
+ `[hooks.state.${JSON.stringify(key)}]`,
606
+ `trusted_hash = ${JSON.stringify(hash)}`,
607
+ '',
608
+ '[hooks.state."foreign-key"]',
609
+ 'trusted_hash = "sha256:foreign"',
610
+ ],
611
+ },
612
+ ];
613
+ for (const representation of representations) {
614
+ const wd = await mkdtemp(join(tmpdir(), `omx-uninstall-trust-${representation.name}-`));
615
+ try {
616
+ await withCwd(wd, async () => {
617
+ const codexDir = join(wd, '.codex');
618
+ const configPath = join(codexDir, 'config.toml');
619
+ const hooksPath = join(codexDir, 'hooks.json');
620
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
621
+ const metadataPath = join(codexDir, '.omx', 'notify-dispatch.json');
622
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot(), {
623
+ platform: 'win32',
624
+ codexHomeDir: codexDir,
625
+ }), null, 2)}\n`;
626
+ const [key, trust] = Object.entries(buildManagedCodexHookTrustState(hooksPath, packageRoot(), {
627
+ platform: 'win32',
628
+ codexHomeDir: codexDir,
629
+ hooksContent: hooks,
630
+ }))[0] ?? [];
631
+ assert.ok(key);
632
+ assert.ok(trust);
633
+ const config = [
634
+ 'model = "foreign"',
635
+ '',
636
+ '# ============================================================',
637
+ '# oh-my-codex (OMX) Configuration',
638
+ '# Managed by omx setup - manual edits preserved on next setup',
639
+ '# ============================================================',
640
+ '',
641
+ ...representation.render(key, trust.trusted_hash),
642
+ '',
643
+ '# ============================================================',
644
+ '# End oh-my-codex',
645
+ '',
646
+ ].join('\n');
647
+ const metadata = Buffer.from('{"managedBy":"foreign"}\n', 'utf-8');
648
+ const shim = Buffer.from(buildManagedCodexNativeHookWindowsShimContent(packageRoot()), 'utf-8');
649
+ await mkdir(dirname(metadataPath), { recursive: true });
650
+ await writeFile(configPath, config);
651
+ await writeFile(hooksPath, hooks);
652
+ await writeFile(shimPath, shim);
653
+ await writeFile(metadataPath, metadata);
654
+ await assert.rejects(uninstall({ scope: 'project', transactionPlatform: 'win32' }), (error) => typeof error === 'object' &&
655
+ error !== null &&
656
+ 'code' in error &&
657
+ error.code === 'managed_trust_key_conflict');
658
+ assert.deepEqual(await readFile(configPath), Buffer.from(config, 'utf-8'));
659
+ assert.deepEqual(await readFile(hooksPath), Buffer.from(hooks, 'utf-8'));
660
+ assert.deepEqual(await readFile(shimPath), shim);
661
+ assert.deepEqual(await readFile(metadataPath), metadata);
662
+ assert.deepEqual((await readdir(codexDir)).filter((entry) => entry.includes('.omx-')), []);
663
+ assert.deepEqual((await readdir(wd)).filter((entry) => entry.includes('.omx-')), []);
664
+ });
665
+ }
666
+ finally {
667
+ await rm(wd, { recursive: true, force: true });
668
+ }
669
+ }
670
+ });
671
+ it('fails before uninstall writes when marker trust has absent or non-managed hooks expectations', async () => {
672
+ const fixtures = [
673
+ { name: 'absent', hooks: null },
674
+ {
675
+ name: 'non-managed',
676
+ hooks: `${JSON.stringify({
677
+ hooks: {
678
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo foreign' }] }],
679
+ },
680
+ }, null, 2)}\n`,
681
+ },
682
+ ];
683
+ for (const fixture of fixtures) {
684
+ const wd = await mkdtemp(join(tmpdir(), `omx-uninstall-marker-${fixture.name}-`));
685
+ try {
686
+ await withCwd(wd, async () => {
687
+ const codexDir = join(wd, '.codex');
688
+ const configPath = join(codexDir, 'config.toml');
689
+ const hooksPath = join(codexDir, 'hooks.json');
690
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
691
+ const metadataPath = join(codexDir, '.omx', 'notify-dispatch.json');
692
+ const config = buildOmxConfig().replace('# End oh-my-codex', '[hooks.state."foreign-key"]\ntrusted_hash = "sha256:foreign"\n\n# End oh-my-codex');
693
+ const shim = Buffer.from(buildManagedCodexNativeHookWindowsShimContent(packageRoot()), 'utf-8');
694
+ const metadata = Buffer.from('{"managedBy":"foreign"}\n', 'utf-8');
695
+ await mkdir(dirname(metadataPath), { recursive: true });
696
+ await writeFile(configPath, config);
697
+ if (fixture.hooks !== null)
698
+ await writeFile(hooksPath, fixture.hooks);
699
+ await writeFile(shimPath, shim);
700
+ await writeFile(metadataPath, metadata);
701
+ await assert.rejects(uninstall({ scope: 'project', transactionPlatform: 'win32' }), (error) => typeof error === 'object' &&
702
+ error !== null &&
703
+ 'code' in error &&
704
+ error.code === 'managed_trust_key_conflict');
705
+ assert.deepEqual(await readFile(configPath), Buffer.from(config, 'utf-8'));
706
+ assert.deepEqual(await readFile(shimPath), shim);
707
+ assert.deepEqual(await readFile(metadataPath), metadata);
708
+ if (fixture.hooks === null) {
709
+ assert.equal(existsSync(hooksPath), false);
710
+ }
711
+ else {
712
+ assert.deepEqual(await readFile(hooksPath), Buffer.from(fixture.hooks, 'utf-8'));
713
+ }
714
+ assert.deepEqual((await readdir(codexDir)).filter((entry) => entry.includes('.omx-')), []);
715
+ assert.deepEqual((await readdir(wd)).filter((entry) => entry.includes('.omx-')), []);
716
+ });
717
+ }
718
+ finally {
719
+ await rm(wd, { recursive: true, force: true });
720
+ }
721
+ }
722
+ });
723
+ it('preserves a shim referenced by a Unicode-escaped future hook event', async () => {
724
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-future-shim-'));
725
+ try {
726
+ await withCwd(wd, async () => {
727
+ const codexDir = join(wd, '.codex');
728
+ const configPath = join(codexDir, 'config.toml');
729
+ const hooksPath = join(codexDir, 'hooks.json');
730
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
731
+ const managed = buildManagedCodexHooksConfig(packageRoot(), {
732
+ platform: 'win32',
733
+ codexHomeDir: codexDir,
734
+ });
735
+ const sentinel = '__OMX_ESCAPED_SHIM_COMMAND__';
736
+ const hooks = JSON.stringify({
737
+ ...managed,
738
+ hooks: {
739
+ ...managed.hooks,
740
+ FutureEvent: [{ hooks: [{ type: 'command', command: sentinel }] }],
741
+ },
742
+ }, null, 2).replace(JSON.stringify(sentinel), JSON.stringify(`& 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File '${shimPath}'`).replace(/\\\\/g, '\\u005c')) + '\n';
743
+ await mkdir(codexDir, { recursive: true });
744
+ await writeFile(configPath, buildOmxConfig());
745
+ await writeFile(hooksPath, hooks);
746
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
747
+ await uninstall({ scope: 'project', transactionPlatform: 'win32' });
748
+ assert.equal(existsSync(shimPath), true);
749
+ const finalHooks = await readFile(hooksPath, 'utf-8');
750
+ assert.match(finalHooks, /FutureEvent/);
751
+ assert.match(finalHooks, /\\u005c/);
752
+ assert.doesNotMatch(finalHooks, /codex-native-hook\.js/);
753
+ });
754
+ }
755
+ finally {
756
+ await rm(wd, { recursive: true, force: true });
757
+ }
758
+ });
759
+ it('preserves a drive-qualified shim referenced by a drive-less future hook event', async () => {
760
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-drive-qualified-future-shim-'));
761
+ try {
762
+ await withDriveQualifiedWindowsCodexHome(wd, async (codexHomeDir) => {
763
+ const configPath = join(codexHomeDir, 'config.toml');
764
+ const hooksPath = join(codexHomeDir, 'hooks.json');
765
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexHomeDir);
766
+ const managed = buildManagedCodexHooksConfig(packageRoot(), {
767
+ platform: 'win32',
768
+ codexHomeDir,
769
+ });
770
+ const driveLessFutureShimPath = '\\Users\\omx\\.codex\\hooks\\omx-native-hook-windows-shim.ps1';
771
+ const hooks = `${JSON.stringify({
772
+ ...managed,
773
+ hooks: {
774
+ ...managed.hooks,
775
+ FutureEvent: [{
776
+ hooks: [{
777
+ type: 'command',
778
+ command: `& 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File '${driveLessFutureShimPath}'`,
779
+ }],
780
+ }],
781
+ },
782
+ }, null, 2)}\n`;
783
+ await writeFile(configPath, buildOmxConfig());
784
+ await writeFile(hooksPath, hooks);
785
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
786
+ await uninstall({ scope: 'user', transactionPlatform: 'win32' });
787
+ assert.equal(existsSync(shimPath), true);
788
+ const finalHooks = await readFile(hooksPath, 'utf-8');
789
+ assert.match(finalHooks, /FutureEvent/);
790
+ assert.doesNotMatch(finalHooks, /codex-native-hook\.js/);
791
+ });
792
+ }
793
+ finally {
794
+ await rm(wd, { recursive: true, force: true });
795
+ }
796
+ });
797
+ it('preserves a shim referenced through a normalized Windows path alias', async () => {
798
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-shim-path-alias-'));
799
+ try {
800
+ await withCwd(wd, async () => {
801
+ const codexDir = join(wd, '.codex');
802
+ const configPath = join(codexDir, 'config.toml');
803
+ const hooksPath = join(codexDir, 'hooks.json');
804
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
805
+ const shimAlias = shimPath
806
+ .replace(/[\\/]+hooks[\\/]+/i, '\\hooks\\\\.\\')
807
+ .replace(/omx-native-hook-windows-shim\.ps1$/i, 'OMX-NATIVE-HOOK-WINDOWS-SHIM.PS1')
808
+ .replace(/\\/g, '/');
809
+ const managed = buildManagedCodexHooksConfig(packageRoot(), {
810
+ platform: 'win32',
811
+ codexHomeDir: codexDir,
812
+ });
813
+ const hooks = `${JSON.stringify({
814
+ ...managed,
815
+ hooks: {
816
+ ...managed.hooks,
817
+ FutureEvent: [{
818
+ hooks: [{
819
+ type: 'command',
820
+ command: `& 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File '${shimAlias}'`,
821
+ }],
822
+ }],
823
+ },
824
+ }, null, 2)}\n`;
825
+ await mkdir(codexDir, { recursive: true });
826
+ await writeFile(configPath, buildOmxConfig());
827
+ await writeFile(hooksPath, hooks);
828
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
829
+ await uninstall({ scope: 'project', transactionPlatform: 'win32' });
830
+ assert.equal(existsSync(shimPath), true);
831
+ assert.match(await readFile(hooksPath, 'utf-8'), /FutureEvent/);
832
+ });
833
+ }
834
+ finally {
835
+ await rm(wd, { recursive: true, force: true });
836
+ }
837
+ });
838
+ it('preserves a shim for a distinct absolute -File target', async () => {
839
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-quoted-inert-shim-'));
840
+ try {
841
+ await withCwd(wd, async () => {
842
+ const codexDir = join(wd, '.codex');
843
+ const configPath = join(codexDir, 'config.toml');
844
+ const hooksPath = join(codexDir, 'hooks.json');
845
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
846
+ const managed = buildManagedCodexHooksConfig(packageRoot(), {
847
+ platform: 'win32',
848
+ codexHomeDir: codexDir,
849
+ });
850
+ const hooks = `${JSON.stringify({
851
+ ...managed,
852
+ hooks: {
853
+ ...managed.hooks,
854
+ FutureEvent: [{
855
+ hooks: [{
856
+ type: 'command',
857
+ command: "& 'C:\\%OMX_INERT%\\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File 'D:\\tools\\unrelated-$env:INERT.ps1'",
858
+ }],
859
+ }],
860
+ },
861
+ }, null, 2)}\n`;
862
+ await mkdir(codexDir, { recursive: true });
863
+ await mkdir(dirname(shimPath), { recursive: true });
864
+ await writeFile(configPath, buildOmxConfig());
865
+ await writeFile(hooksPath, hooks);
866
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
867
+ await uninstall({ scope: 'project', transactionPlatform: 'win32' });
868
+ assert.equal(existsSync(shimPath), true);
869
+ assert.match(await readFile(hooksPath, 'utf-8'), /FutureEvent/);
870
+ });
871
+ }
872
+ finally {
873
+ await rm(wd, { recursive: true, force: true });
874
+ }
875
+ });
876
+ it('treats exact and all ambiguous preserved shim references as read-only transaction preconditions', async () => {
877
+ const fixtures = [
878
+ { name: 'normalized-alias-deletion', reference: 'exact', mutation: 'delete' },
879
+ { name: 'basename-ambiguity-replacement', reference: 'basename', mutation: 'replace' },
880
+ { name: 'environment-indirection-replacement', reference: 'environment', mutation: 'replace' },
881
+ { name: 'alternate-absolute-alias-deletion', reference: 'alternate-absolute', mutation: 'delete' },
882
+ { name: 'rooted-short-name-deletion', reference: 'rooted-short-name', mutation: 'delete' },
883
+ { name: 'drive-relative-short-name-deletion', reference: 'drive-relative-short-name', mutation: 'delete' },
884
+ { name: 'distinct-basename-absolute-deletion', reference: 'distinct-basename-absolute', mutation: 'delete' },
885
+ ];
886
+ for (const fixture of fixtures) {
887
+ const wd = await mkdtemp(join(tmpdir(), `omx-uninstall-preserved-shim-${fixture.name}-`));
888
+ try {
889
+ await withCwd(wd, async () => {
890
+ const codexDir = join(wd, '.codex');
891
+ const configPath = join(codexDir, 'config.toml');
892
+ const hooksPath = join(codexDir, 'hooks.json');
893
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
894
+ const shimAlias = shimPath
895
+ .replace(/[\\/]+hooks[\\/]+/i, '\\hooks\\\\.\\')
896
+ .replace(/omx-native-hook-windows-shim\.ps1$/i, 'OMX-NATIVE-HOOK-WINDOWS-SHIM.PS1')
897
+ .replace(/\\/g, '/');
898
+ const command = fixture.reference === 'exact'
899
+ ? `& 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File '${shimAlias}'`
900
+ : fixture.reference === 'environment'
901
+ ? '& $env:OMX_SHIM'
902
+ : fixture.reference === 'alternate-absolute'
903
+ ? "& 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File 'D:\\aliases\\omx-native-hook-windows-shim.ps1'"
904
+ : fixture.reference === 'rooted-short-name'
905
+ ? "& 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File '\\Users\\ALICE~1\\.codex\\hooks\\OMX-NA~1.PS1'"
906
+ : fixture.reference === 'drive-relative-short-name'
907
+ ? "& 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File 'C:Users\\ALICE~1\\.codex\\hooks\\OMX-NA~1.PS1'"
908
+ : fixture.reference === 'distinct-basename-absolute'
909
+ ? "& 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File 'D:\\aliases\\foreign-hook-alias.ps1'"
910
+ : 'echo omx-native-hook-windows-shim.ps1';
911
+ const managed = buildManagedCodexHooksConfig(packageRoot(), {
912
+ platform: 'win32',
913
+ codexHomeDir: codexDir,
914
+ });
915
+ const hooks = `${JSON.stringify({
916
+ ...managed,
917
+ hooks: {
918
+ ...managed.hooks,
919
+ FutureEvent: [{ hooks: [{ type: 'command', command }] }],
425
920
  },
426
- ],
921
+ }, null, 2)}\n`;
922
+ const config = buildOmxConfig();
923
+ const shim = Buffer.from(buildManagedCodexNativeHookWindowsShimContent(packageRoot()), 'utf-8');
924
+ const replacement = Buffer.from('concurrent shim replacement\n', 'utf-8');
925
+ await mkdir(codexDir, { recursive: true });
926
+ await writeFile(configPath, config);
927
+ await writeFile(hooksPath, hooks);
928
+ await writeFile(shimPath, shim);
929
+ await assert.rejects(uninstall({
930
+ scope: 'project',
931
+ transactionPlatform: 'win32',
932
+ transactionFailureInjector: async (stage) => {
933
+ if (stage !== 'before-config-commit')
934
+ return;
935
+ if (fixture.mutation === 'delete') {
936
+ await rm(shimPath);
937
+ }
938
+ else {
939
+ await writeFile(shimPath, replacement);
940
+ }
941
+ },
942
+ }), /planned artifact .* changed, was created, or was removed after planning/);
943
+ assert.deepEqual(await readFile(configPath), Buffer.from(config, 'utf-8'));
944
+ assert.deepEqual(await readFile(hooksPath), Buffer.from(hooks, 'utf-8'));
945
+ if (fixture.mutation === 'delete') {
946
+ assert.equal(existsSync(shimPath), false);
947
+ }
948
+ else {
949
+ assert.deepEqual(await readFile(shimPath), replacement);
950
+ }
951
+ });
952
+ }
953
+ finally {
954
+ await rm(wd, { recursive: true, force: true });
955
+ }
956
+ }
957
+ });
958
+ it('rolls back hooks when a preserved environment-indirected shim drifts under --keep-config', async () => {
959
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-keep-config-preserved-shim-'));
960
+ try {
961
+ await withCwd(wd, async () => {
962
+ const codexDir = join(wd, '.codex');
963
+ const configPath = join(codexDir, 'config.toml');
964
+ const hooksPath = join(codexDir, 'hooks.json');
965
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
966
+ const managed = buildManagedCodexHooksConfig(packageRoot(), {
967
+ platform: 'win32',
968
+ codexHomeDir: codexDir,
969
+ });
970
+ const hooks = `${JSON.stringify({
971
+ ...managed,
972
+ hooks: {
973
+ ...managed.hooks,
974
+ FutureEvent: [{ hooks: [{ type: 'command', command: '& $env:OMX_SHIM' }] }],
975
+ },
976
+ }, null, 2)}\n`;
977
+ const config = buildOmxConfig();
978
+ const replacement = Buffer.from('concurrent preserved shim replacement\n', 'utf-8');
979
+ await mkdir(codexDir, { recursive: true });
980
+ await writeFile(configPath, config);
981
+ await writeFile(hooksPath, hooks);
982
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
983
+ let injected = false;
984
+ await assert.rejects(uninstall({
985
+ scope: 'project',
986
+ keepConfig: true,
987
+ transactionPlatform: 'win32',
988
+ transactionFailureInjector: async (stage) => {
989
+ if (stage !== 'before-rename' || injected)
990
+ return;
991
+ injected = true;
992
+ await writeFile(shimPath, replacement);
993
+ },
994
+ }), /planned artifact .* changed, was created, or was removed after planning/);
995
+ assert.equal(injected, true);
996
+ assert.equal(await readFile(configPath, 'utf-8'), config);
997
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
998
+ assert.deepEqual(await readFile(shimPath), replacement);
999
+ });
1000
+ }
1001
+ finally {
1002
+ await rm(wd, { recursive: true, force: true });
1003
+ }
1004
+ });
1005
+ it('rolls back when a preserved Windows shim drifts after staged-cleanup finalization', async () => {
1006
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-preserved-shim-finalization-'));
1007
+ try {
1008
+ await withCwd(wd, async () => {
1009
+ const codexDir = join(wd, '.codex');
1010
+ const configPath = join(codexDir, 'config.toml');
1011
+ const hooksPath = join(codexDir, 'hooks.json');
1012
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
1013
+ const managed = buildManagedCodexHooksConfig(packageRoot(), {
1014
+ platform: 'win32',
1015
+ codexHomeDir: codexDir,
1016
+ });
1017
+ const hooks = `${JSON.stringify({
1018
+ ...managed,
1019
+ hooks: {
1020
+ ...managed.hooks,
1021
+ FutureEvent: [{ hooks: [{ type: 'command', command: '& $env:OMX_SHIM' }] }],
1022
+ },
1023
+ }, null, 2)}\n`;
1024
+ const config = buildOmxConfig();
1025
+ const replacement = Buffer.from('concurrent finalization shim replacement\n', 'utf-8');
1026
+ await mkdir(codexDir, { recursive: true });
1027
+ await writeFile(configPath, config);
1028
+ await writeFile(hooksPath, hooks);
1029
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
1030
+ let injected = false;
1031
+ await assert.rejects(uninstall({
1032
+ scope: 'project',
1033
+ transactionPlatform: 'win32',
1034
+ transactionFailureInjector: async (stage) => {
1035
+ if (stage !== 'after-staged-cleanup' || injected)
1036
+ return;
1037
+ injected = true;
1038
+ await writeFile(shimPath, replacement);
1039
+ },
1040
+ }), /planned artifact .* changed, was created, or was removed after planning/);
1041
+ assert.equal(injected, true);
1042
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1043
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1044
+ assert.deepEqual(await readFile(shimPath), replacement);
1045
+ });
1046
+ }
1047
+ finally {
1048
+ await rm(wd, { recursive: true, force: true });
1049
+ }
1050
+ });
1051
+ it('rolls back hooks, historical proof-owned shim, and config when shim removal fails', async () => {
1052
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-shim-rollback-'));
1053
+ try {
1054
+ await withCwd(wd, async () => {
1055
+ const codexDir = join(wd, '.codex');
1056
+ const configPath = join(codexDir, 'config.toml');
1057
+ const hooksPath = join(codexDir, 'hooks.json');
1058
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
1059
+ const config = buildOmxConfig();
1060
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot(), {
1061
+ platform: 'win32',
1062
+ codexHomeDir: codexDir,
1063
+ }), null, 2)}\n`;
1064
+ const shim = buildManagedCodexNativeHookWindowsShimContent('C:\\Historical Install\\oh-my-codex', { nodePath: 'D:\\Historical Node\\node.exe' });
1065
+ await mkdir(codexDir, { recursive: true });
1066
+ await mkdir(dirname(shimPath), { recursive: true });
1067
+ await writeFile(configPath, config);
1068
+ await writeFile(hooksPath, hooks);
1069
+ await writeFile(shimPath, shim);
1070
+ await assert.rejects(uninstall({
1071
+ scope: 'project',
1072
+ transactionPlatform: 'win32',
1073
+ transactionFailureInjector: async (stage) => {
1074
+ if (stage !== 'before-shim-removal')
1075
+ return;
1076
+ assert.equal(existsSync(hooksPath), false, 'hooks must mutate before shim removal');
1077
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1078
+ throw new Error('simulated shim removal failure');
1079
+ },
1080
+ }), /simulated shim removal failure/);
1081
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1082
+ assert.equal(await readFile(shimPath, 'utf-8'), shim);
1083
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1084
+ });
1085
+ }
1086
+ finally {
1087
+ await rm(wd, { recursive: true, force: true });
1088
+ }
1089
+ });
1090
+ it('rejects a modified Windows shim before any uninstall artifact write', async () => {
1091
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-modified-shim-'));
1092
+ try {
1093
+ await withCwd(wd, async () => {
1094
+ const codexDir = join(wd, '.codex');
1095
+ const configPath = join(codexDir, 'config.toml');
1096
+ const hooksPath = join(codexDir, 'hooks.json');
1097
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
1098
+ const config = buildOmxConfig();
1099
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot(), {
1100
+ platform: 'win32',
1101
+ codexHomeDir: codexDir,
1102
+ }), null, 2)}\n`;
1103
+ const shim = `${buildManagedCodexNativeHookWindowsShimContent(packageRoot())}\n# user edit\n`;
1104
+ await mkdir(codexDir, { recursive: true });
1105
+ await mkdir(dirname(shimPath), { recursive: true });
1106
+ await writeFile(configPath, config);
1107
+ await writeFile(hooksPath, hooks);
1108
+ await writeFile(shimPath, shim);
1109
+ await assert.rejects(uninstall({ scope: 'project', transactionPlatform: 'win32' }), /modified native hook Windows shim/);
1110
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1111
+ assert.equal(await readFile(shimPath, 'utf-8'), shim);
1112
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1113
+ });
1114
+ }
1115
+ finally {
1116
+ await rm(wd, { recursive: true, force: true });
1117
+ }
1118
+ });
1119
+ it('rolls back hooks, shim, and config when config commit fails after hook mutation', async () => {
1120
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-config-rollback-'));
1121
+ try {
1122
+ await withCwd(wd, async () => {
1123
+ const codexDir = join(wd, '.codex');
1124
+ const configPath = join(codexDir, 'config.toml');
1125
+ const hooksPath = join(codexDir, 'hooks.json');
1126
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
1127
+ const config = buildOmxConfig();
1128
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot(), {
1129
+ platform: 'win32',
1130
+ codexHomeDir: codexDir,
1131
+ }), null, 2)}\n`;
1132
+ const shim = buildManagedCodexNativeHookWindowsShimContent(packageRoot());
1133
+ await mkdir(codexDir, { recursive: true });
1134
+ await mkdir(dirname(shimPath), { recursive: true });
1135
+ await writeFile(configPath, config);
1136
+ await writeFile(hooksPath, hooks);
1137
+ await writeFile(shimPath, shim);
1138
+ await assert.rejects(uninstall({
1139
+ scope: 'project',
1140
+ transactionPlatform: 'win32',
1141
+ transactionFailureInjector: async (stage) => {
1142
+ if (stage !== 'after-config-commit')
1143
+ return;
1144
+ assert.equal(existsSync(hooksPath), false, 'hooks must mutate before config commit');
1145
+ assert.equal(existsSync(shimPath), false, 'shim must mutate before config commit');
1146
+ assert.notEqual(await readFile(configPath, 'utf-8'), config);
1147
+ throw new Error('simulated config commit failure');
1148
+ },
1149
+ }), /simulated config commit failure/);
1150
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1151
+ assert.equal(await readFile(shimPath, 'utf-8'), shim);
1152
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1153
+ });
1154
+ }
1155
+ finally {
1156
+ await rm(wd, { recursive: true, force: true });
1157
+ }
1158
+ });
1159
+ it('rejects invalid UTF-8 hooks.json and config.toml before changing either artifact', async () => {
1160
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-invalid-utf8-'));
1161
+ try {
1162
+ await withCwd(wd, async () => {
1163
+ const codexDir = join(wd, '.codex');
1164
+ const configPath = join(codexDir, 'config.toml');
1165
+ const hooksPath = join(codexDir, 'hooks.json');
1166
+ const hooks = Buffer.from([0x7b, 0xff, 0x7d]);
1167
+ const config = Buffer.from([0x6f, 0x6d, 0x78, 0x5f, 0xff]);
1168
+ await mkdir(codexDir, { recursive: true });
1169
+ await writeFile(configPath, buildOmxConfig());
1170
+ await writeFile(hooksPath, hooks);
1171
+ await assert.rejects(uninstall({ scope: 'project' }), /not valid UTF-8/);
1172
+ assert.deepEqual(await readFile(hooksPath), hooks);
1173
+ await writeFile(hooksPath, JSON.stringify(buildManagedCodexHooksConfig(packageRoot())));
1174
+ await writeFile(configPath, config);
1175
+ await assert.rejects(uninstall({ scope: 'project' }), /not valid UTF-8/);
1176
+ assert.deepEqual(await readFile(configPath), config);
1177
+ const bomHooks = Buffer.concat([
1178
+ Buffer.from([0xef, 0xbb, 0xbf]),
1179
+ Buffer.from('{"hooks":{}}\n', 'utf-8'),
1180
+ ]);
1181
+ await writeFile(configPath, buildOmxConfig());
1182
+ await writeFile(hooksPath, bomHooks);
1183
+ await assert.rejects(uninstall({ scope: 'project' }), /must contain a JSON object/);
1184
+ assert.deepEqual(await readFile(hooksPath), bomHooks);
1185
+ assert.equal(existsSync(hooksPath), true);
1186
+ });
1187
+ }
1188
+ finally {
1189
+ await rm(wd, { recursive: true, force: true });
1190
+ }
1191
+ });
1192
+ it('rejects symlinked native hook artifacts without following their targets', async () => {
1193
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-symlink-hooks-'));
1194
+ try {
1195
+ await withCwd(wd, async () => {
1196
+ const codexDir = join(wd, '.codex');
1197
+ const configPath = join(codexDir, 'config.toml');
1198
+ const hooksPath = join(codexDir, 'hooks.json');
1199
+ const targetPath = join(wd, 'user-hooks.json');
1200
+ const config = buildOmxConfig();
1201
+ const target = '{"user":"hooks"}\n';
1202
+ await mkdir(codexDir, { recursive: true });
1203
+ await writeFile(configPath, config);
1204
+ await writeFile(targetPath, target);
1205
+ await symlink(targetPath, hooksPath);
1206
+ await assert.rejects(uninstall({ scope: 'project' }), /expected a regular file, not a symbolic link/);
1207
+ assert.equal((await lstat(hooksPath)).isSymbolicLink(), true);
1208
+ assert.equal(await readFile(targetPath, 'utf-8'), target);
1209
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1210
+ });
1211
+ }
1212
+ finally {
1213
+ await rm(wd, { recursive: true, force: true });
1214
+ }
1215
+ });
1216
+ it('aborts before the first removal when any planned artifact becomes stale', async () => {
1217
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-stale-snapshot-'));
1218
+ try {
1219
+ await withCwd(wd, async () => {
1220
+ const codexDir = join(wd, '.codex');
1221
+ const configPath = join(codexDir, 'config.toml');
1222
+ const hooksPath = join(codexDir, 'hooks.json');
1223
+ const config = buildOmxConfig();
1224
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot()), null, 2)}\n`;
1225
+ const staleConfig = `${config}# concurrent user edit\n`;
1226
+ await mkdir(codexDir, { recursive: true });
1227
+ await writeFile(configPath, config);
1228
+ await writeFile(hooksPath, hooks);
1229
+ await assert.rejects(uninstall({
1230
+ scope: 'project',
1231
+ transactionFailureInjector: async (stage) => {
1232
+ if (stage === 'before-hooks-commit')
1233
+ await writeFile(configPath, staleConfig);
1234
+ },
1235
+ }), /planned artifact .* changed, was created, or was removed after planning/);
1236
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1237
+ assert.equal(await readFile(configPath, 'utf-8'), staleConfig);
1238
+ });
1239
+ }
1240
+ finally {
1241
+ await rm(wd, { recursive: true, force: true });
1242
+ }
1243
+ });
1244
+ it('never replaces a concurrent symlink while rolling back a failed uninstall transaction', async () => {
1245
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-stale-rollback-link-'));
1246
+ try {
1247
+ await withCwd(wd, async () => {
1248
+ const codexDir = join(wd, '.codex');
1249
+ const configPath = join(codexDir, 'config.toml');
1250
+ const hooksPath = join(codexDir, 'hooks.json');
1251
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
1252
+ const replacementPath = join(wd, 'concurrent-user-hooks.json');
1253
+ const config = buildOmxConfig();
1254
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot(), {
1255
+ platform: 'win32',
1256
+ codexHomeDir: codexDir,
1257
+ }), null, 2)}\n`;
1258
+ const replacement = '{"user":"replacement"}\n';
1259
+ await mkdir(codexDir, { recursive: true });
1260
+ await mkdir(dirname(shimPath), { recursive: true });
1261
+ await writeFile(configPath, config);
1262
+ await writeFile(hooksPath, hooks);
1263
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
1264
+ await writeFile(replacementPath, replacement);
1265
+ await assert.rejects(uninstall({
1266
+ scope: 'project',
1267
+ transactionPlatform: 'win32',
1268
+ transactionFailureInjector: async (stage) => {
1269
+ if (stage !== 'before-shim-removal')
1270
+ return;
1271
+ await symlink(replacementPath, hooksPath);
1272
+ throw new Error('simulated concurrent hook replacement');
1273
+ },
1274
+ }), /Uninstall artifact rollback failed.*(?:Refusing stale rollback|expected a regular file, not a symbolic link)/);
1275
+ assert.equal((await lstat(hooksPath)).isSymbolicLink(), true);
1276
+ assert.equal(await readFile(replacementPath, 'utf-8'), replacement);
1277
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1278
+ });
1279
+ }
1280
+ finally {
1281
+ await rm(wd, { recursive: true, force: true });
1282
+ }
1283
+ });
1284
+ it('rejects a symlinked controlled ancestor before reading an escaped artifact', async () => {
1285
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-ancestor-link-'));
1286
+ try {
1287
+ await withCwd(wd, async () => {
1288
+ const codexDir = join(wd, '.codex');
1289
+ const escapedDir = join(wd, 'escaped-codex');
1290
+ const configPath = join(escapedDir, 'config.toml');
1291
+ const config = buildOmxConfig();
1292
+ await mkdir(escapedDir, { recursive: true });
1293
+ await writeFile(configPath, config);
1294
+ await symlink(escapedDir, codexDir, process.platform === 'win32' ? 'junction' : 'dir');
1295
+ await assert.rejects(uninstall({ scope: 'project' }), /controlled ancestor .* must be a non-symbolic-link directory/);
1296
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1297
+ });
1298
+ }
1299
+ finally {
1300
+ await rm(wd, { recursive: true, force: true });
1301
+ }
1302
+ });
1303
+ it('does not truncate or follow regular and symlink replacement temporary collisions', async () => {
1304
+ for (const collisionKind of ['regular', 'symlink']) {
1305
+ const wd = await mkdtemp(join(tmpdir(), `omx-uninstall-temp-${collisionKind}-`));
1306
+ try {
1307
+ await withCwd(wd, async () => {
1308
+ const codexDir = join(wd, '.codex');
1309
+ const configPath = join(codexDir, 'config.toml');
1310
+ const collisionPath = join(codexDir, '.config.toml.collision');
1311
+ const collisionTarget = join(wd, 'collision-target');
1312
+ const config = buildOmxConfig();
1313
+ const collision = 'foreign temporary collision\n';
1314
+ await mkdir(codexDir, { recursive: true });
1315
+ await writeFile(configPath, config);
1316
+ if (collisionKind === 'regular') {
1317
+ await writeFile(collisionPath, collision);
1318
+ }
1319
+ else {
1320
+ await writeFile(collisionTarget, collision);
1321
+ await symlink(collisionTarget, collisionPath);
1322
+ }
1323
+ await assert.rejects(uninstall({
1324
+ scope: 'project',
1325
+ transactionTemporaryPath: () => collisionPath,
1326
+ }), /EEXIST/);
1327
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1328
+ assert.equal(await readFile(collisionKind === 'regular' ? collisionPath : collisionTarget, 'utf-8'), collision);
1329
+ if (collisionKind === 'symlink') {
1330
+ assert.equal((await lstat(collisionPath)).isSymbolicLink(), true);
1331
+ }
1332
+ });
1333
+ }
1334
+ finally {
1335
+ await rm(wd, { recursive: true, force: true });
1336
+ }
1337
+ }
1338
+ });
1339
+ it('rejects stale snapshots immediately before forward rename and remove mutations', async () => {
1340
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-stale-forward-'));
1341
+ try {
1342
+ await withCwd(wd, async () => {
1343
+ const codexDir = join(wd, '.codex');
1344
+ const configPath = join(codexDir, 'config.toml');
1345
+ const hooksPath = join(codexDir, 'hooks.json');
1346
+ const config = buildOmxConfig();
1347
+ const concurrentConfig = `${config}# concurrent replacement\n`;
1348
+ await mkdir(codexDir, { recursive: true });
1349
+ await writeFile(configPath, config);
1350
+ await assert.rejects(uninstall({
1351
+ scope: 'project',
1352
+ transactionFailureInjector: async (stage) => {
1353
+ if (stage === 'before-rename')
1354
+ await writeFile(configPath, concurrentConfig);
1355
+ },
1356
+ }), /planned artifact .* changed, was created, or was removed after planning/);
1357
+ assert.equal(await readFile(configPath, 'utf-8'), concurrentConfig);
1358
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot()), null, 2)}\n`;
1359
+ const concurrentHooks = '{"foreign":"replacement"}\n';
1360
+ await writeFile(configPath, config);
1361
+ await writeFile(hooksPath, hooks);
1362
+ await assert.rejects(uninstall({
1363
+ scope: 'project',
1364
+ transactionFailureInjector: async (stage) => {
1365
+ if (stage === 'before-remove')
1366
+ await writeFile(hooksPath, concurrentHooks);
1367
+ },
1368
+ }), /planned artifact .* changed, was created, or was removed after planning/);
1369
+ assert.equal(await readFile(hooksPath, 'utf-8'), concurrentHooks);
1370
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1371
+ });
1372
+ }
1373
+ finally {
1374
+ await rm(wd, { recursive: true, force: true });
1375
+ }
1376
+ });
1377
+ it('fails closed for non-throwing regular and symlink write-temporary replacements', async () => {
1378
+ for (const replacementKind of ['regular', 'symlink']) {
1379
+ const wd = await mkdtemp(join(tmpdir(), `omx-uninstall-write-temp-${replacementKind}-`));
1380
+ try {
1381
+ await withCwd(wd, async () => {
1382
+ const codexDir = join(wd, '.codex');
1383
+ const configPath = join(codexDir, 'config.toml');
1384
+ const hooksPath = join(codexDir, 'hooks.json');
1385
+ const temporaryPath = join(codexDir, '.config.toml.write-temporary');
1386
+ const replacementTarget = join(wd, 'foreign-write-temporary');
1387
+ const config = Buffer.from(buildOmxConfig());
1388
+ const hooks = Buffer.from('{}\n');
1389
+ const replacement = Buffer.from('foreign write temporary replacement\n');
1390
+ await mkdir(codexDir, { recursive: true });
1391
+ await writeFile(configPath, config);
1392
+ await writeFile(hooksPath, hooks);
1393
+ await assert.rejects(uninstall({
1394
+ scope: 'project',
1395
+ transactionTemporaryPath: (_path, purpose) => purpose === 'write' ? temporaryPath : join(codexDir, '.hooks.json.staged'),
1396
+ transactionFailureInjector: async (stage) => {
1397
+ if (stage !== 'before-rename')
1398
+ return;
1399
+ await rm(temporaryPath, { force: true });
1400
+ if (replacementKind === 'regular') {
1401
+ await writeFile(temporaryPath, replacement);
1402
+ }
1403
+ else {
1404
+ await writeFile(replacementTarget, replacement);
1405
+ await symlink(replacementTarget, temporaryPath);
1406
+ }
1407
+ },
1408
+ }), (error) => {
1409
+ const message = error instanceof Error ? error.message : String(error);
1410
+ assert.match(message, /planned artifact .* changed, was created, or was removed after planning|expected a regular file, not a symbolic link/);
1411
+ assert.ok(message.includes(temporaryPath));
1412
+ assert.match(message, /preserved temporary .*manual recovery after cleanup verification failed/);
1413
+ return true;
1414
+ });
1415
+ assert.deepEqual(await readFile(configPath), config);
1416
+ assert.deepEqual(await readFile(hooksPath), hooks);
1417
+ if (replacementKind === 'regular') {
1418
+ assert.deepEqual(await readFile(temporaryPath), replacement);
1419
+ }
1420
+ else {
1421
+ assert.equal((await lstat(temporaryPath)).isSymbolicLink(), true);
1422
+ assert.deepEqual(await readFile(replacementTarget), replacement);
1423
+ }
1424
+ });
1425
+ }
1426
+ finally {
1427
+ await rm(wd, { recursive: true, force: true });
1428
+ }
1429
+ }
1430
+ });
1431
+ it('fails closed for non-throwing regular and symlink staged-tombstone replacements', async () => {
1432
+ for (const replacementKind of ['regular', 'symlink']) {
1433
+ const wd = await mkdtemp(join(tmpdir(), `omx-uninstall-staged-tombstone-${replacementKind}-`));
1434
+ try {
1435
+ await withCwd(wd, async () => {
1436
+ const codexDir = join(wd, '.codex');
1437
+ const configPath = join(codexDir, 'config.toml');
1438
+ const hooksPath = join(codexDir, 'hooks.json');
1439
+ const stagedPath = join(codexDir, '.hooks.json.staged-tombstone');
1440
+ const replacementTarget = join(wd, 'foreign-staged-tombstone');
1441
+ const config = Buffer.from(buildOmxConfig());
1442
+ const hooks = Buffer.from(`${JSON.stringify(buildManagedCodexHooksConfig(packageRoot()), null, 2)}\n`);
1443
+ const replacement = Buffer.from('foreign staged tombstone replacement\n');
1444
+ await mkdir(codexDir, { recursive: true });
1445
+ await writeFile(configPath, config);
1446
+ await writeFile(hooksPath, hooks);
1447
+ await assert.rejects(uninstall({
1448
+ scope: 'project',
1449
+ transactionTemporaryPath: (_path, purpose) => purpose === 'delete' ? stagedPath : join(codexDir, '.write-temporary'),
1450
+ transactionFailureInjector: async (stage) => {
1451
+ if (stage !== 'before-remove')
1452
+ return;
1453
+ await rm(stagedPath, { force: true });
1454
+ if (replacementKind === 'regular') {
1455
+ await writeFile(stagedPath, replacement);
1456
+ }
1457
+ else {
1458
+ await writeFile(replacementTarget, replacement);
1459
+ await symlink(replacementTarget, stagedPath);
1460
+ }
1461
+ },
1462
+ }), (error) => {
1463
+ const message = error instanceof Error ? error.message : String(error);
1464
+ assert.match(message, /planned artifact .* changed, was created, or was removed after planning|expected a regular file, not a symbolic link/);
1465
+ assert.ok(message.includes(stagedPath));
1466
+ assert.match(message, /preserved staged deletion .*manual recovery after cleanup verification failed/);
1467
+ return true;
1468
+ });
1469
+ assert.deepEqual(await readFile(configPath), config);
1470
+ assert.deepEqual(await readFile(hooksPath), hooks);
1471
+ if (replacementKind === 'regular') {
1472
+ assert.deepEqual(await readFile(stagedPath), replacement);
1473
+ }
1474
+ else {
1475
+ assert.equal((await lstat(stagedPath)).isSymbolicLink(), true);
1476
+ assert.deepEqual(await readFile(replacementTarget), replacement);
1477
+ }
1478
+ });
1479
+ }
1480
+ finally {
1481
+ await rm(wd, { recursive: true, force: true });
1482
+ }
1483
+ }
1484
+ });
1485
+ it('rejects stale snapshots immediately before rollback rename and staged-copy removal', async () => {
1486
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-stale-rollback-primitives-'));
1487
+ try {
1488
+ await withCwd(wd, async () => {
1489
+ const codexDir = join(wd, '.codex');
1490
+ const configPath = join(codexDir, 'config.toml');
1491
+ const hooksPath = join(codexDir, 'hooks.json');
1492
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot()), null, 2)}\n`;
1493
+ const config = buildOmxConfig();
1494
+ await mkdir(codexDir, { recursive: true });
1495
+ await writeFile(configPath, config);
1496
+ await writeFile(hooksPath, hooks);
1497
+ const concurrentHooks = '{"foreign":"rollback rename"}\n';
1498
+ await assert.rejects(uninstall({
1499
+ scope: 'project',
1500
+ transactionFailureInjector: async (stage) => {
1501
+ if (stage === 'before-config-commit')
1502
+ throw new Error('stop after hook deletion');
1503
+ if (stage === 'before-rollback-rename') {
1504
+ await writeFile(hooksPath, concurrentHooks);
1505
+ }
1506
+ },
1507
+ }), /Uninstall artifact rollback failed.*Refusing uninstall because planned artifact/);
1508
+ assert.equal(await readFile(hooksPath, 'utf-8'), concurrentHooks);
1509
+ await writeFile(hooksPath, hooks);
1510
+ const stagedPath = join(codexDir, '.hooks.json.rollback-stage');
1511
+ const staleStagedCopy = 'foreign staged copy\n';
1512
+ await assert.rejects(uninstall({
1513
+ scope: 'project',
1514
+ transactionTemporaryPath: (_path, purpose) => purpose === 'delete' ? stagedPath : join(codexDir, '.write-temporary'),
1515
+ transactionFailureInjector: async (stage) => {
1516
+ if (stage === 'before-config-commit')
1517
+ throw new Error('stop after hook deletion');
1518
+ if (stage === 'before-rollback-remove') {
1519
+ await writeFile(stagedPath, staleStagedCopy);
1520
+ }
1521
+ },
1522
+ }), /Uninstall artifact rollback failed.*staged deletion cleanup/);
1523
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1524
+ assert.equal(await readFile(stagedPath, 'utf-8'), staleStagedCopy);
1525
+ });
1526
+ }
1527
+ finally {
1528
+ await rm(wd, { recursive: true, force: true });
1529
+ }
1530
+ });
1531
+ it('rolls back when staged-deletion cleanup fails before any staged copy is committed', async () => {
1532
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-staged-deletion-'));
1533
+ try {
1534
+ await withCwd(wd, async () => {
1535
+ const codexDir = join(wd, '.codex');
1536
+ const configPath = join(codexDir, 'config.toml');
1537
+ const hooksPath = join(codexDir, 'hooks.json');
1538
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot()), null, 2)}\n`;
1539
+ const config = buildOmxConfig();
1540
+ const stagedPath = join(codexDir, '.hooks.json.staged');
1541
+ await mkdir(codexDir, { recursive: true });
1542
+ await writeFile(configPath, config);
1543
+ await writeFile(hooksPath, hooks);
1544
+ await uninstall({
1545
+ scope: 'project',
1546
+ transactionTemporaryPath: (_path, purpose) => purpose === 'delete' ? stagedPath : join(codexDir, '.write-temporary'),
1547
+ });
1548
+ assert.equal(existsSync(hooksPath), false);
1549
+ assert.equal(existsSync(stagedPath), false, 'successful commit leaves no staged tombstone');
1550
+ await writeFile(configPath, config);
1551
+ await writeFile(hooksPath, hooks);
1552
+ let interrupted = false;
1553
+ await assert.rejects(uninstall({
1554
+ scope: 'project',
1555
+ transactionTemporaryPath: (_path, purpose) => purpose === 'delete' ? stagedPath : join(codexDir, '.write-temporary'),
1556
+ transactionFailureInjector: (stage) => {
1557
+ if (stage === 'before-staged-cleanup' && !interrupted) {
1558
+ interrupted = true;
1559
+ throw new Error('staged deletion cleanup interrupted');
1560
+ }
1561
+ },
1562
+ }), /staged deletion cleanup interrupted/);
1563
+ assert.equal(interrupted, true);
1564
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1565
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1566
+ assert.equal(existsSync(stagedPath), false);
1567
+ });
1568
+ }
1569
+ finally {
1570
+ await rm(wd, { recursive: true, force: true });
1571
+ }
1572
+ });
1573
+ it('does not roll back when preserved metadata drifts after staged-deletion cleanup finalization', async () => {
1574
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-second-staged-drift-'));
1575
+ try {
1576
+ await withCwd(wd, async () => {
1577
+ const codexDir = join(wd, '.codex');
1578
+ const configPath = join(codexDir, 'config.toml');
1579
+ const hooksPath = join(codexDir, 'hooks.json');
1580
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
1581
+ const metadataPath = join(codexDir, '.omx', 'notify-dispatch.json');
1582
+ const dispatcherPath = join(packageRoot(), 'dist', 'scripts', 'notify-dispatcher.js');
1583
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot(), {
1584
+ platform: 'win32',
1585
+ codexHomeDir: codexDir,
1586
+ }), null, 2)}\n`;
1587
+ const metadata = {
1588
+ managedBy: 'oh-my-codex',
1589
+ version: 1,
1590
+ previousNotify: ['node', '/tmp/user-notify.js'],
1591
+ omxNotify: ['node', join(packageRoot(), 'dist', 'scripts', 'notify-hook.js')],
1592
+ dispatcherNotify: ['node', dispatcherPath, '--metadata', metadataPath],
1593
+ };
1594
+ const foreignMetadata = JSON.stringify({
1595
+ ...metadata,
1596
+ previousNotify: ['node', '/tmp/concurrent-notify.js'],
1597
+ });
1598
+ const config = buildOmxConfig().replace(/^notify = .*$/m, `notify = ${JSON.stringify(metadata.dispatcherNotify)}`);
1599
+ const stagedDeletionPath = (path, purpose) => join(dirname(path), `.${basename(path)}.cleanup-${purpose}`);
1600
+ await mkdir(dirname(metadataPath), { recursive: true });
1601
+ await mkdir(dirname(shimPath), { recursive: true });
1602
+ await writeFile(configPath, config);
1603
+ await writeFile(hooksPath, hooks);
1604
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
1605
+ await writeFile(metadataPath, JSON.stringify(metadata));
1606
+ let injected = false;
1607
+ await assert.rejects(uninstall({
1608
+ scope: 'project',
1609
+ transactionPlatform: 'win32',
1610
+ transactionTemporaryPath: stagedDeletionPath,
1611
+ transactionFailureInjector: async (stage) => {
1612
+ if (stage !== 'after-staged-cleanup' || injected)
1613
+ return;
1614
+ injected = true;
1615
+ await writeFile(metadataPath, foreignMetadata);
1616
+ },
1617
+ }), /committed but staged deletion cleanup failed/);
1618
+ assert.equal(injected, true);
1619
+ assert.equal(await readFile(metadataPath, 'utf-8'), foreignMetadata);
1620
+ assert.equal(existsSync(hooksPath), false);
1621
+ assert.equal(existsSync(shimPath), false);
1622
+ assert.notEqual(await readFile(configPath, 'utf-8'), config);
1623
+ });
1624
+ }
1625
+ finally {
1626
+ await rm(wd, { recursive: true, force: true });
1627
+ }
1628
+ });
1629
+ it('treats applied hook, shim, and config snapshots as CAS through commit finalization', async () => {
1630
+ const fixtures = [
1631
+ { name: 'later commit', stage: 'before-shim-removal', drift: 'hooks', committed: false },
1632
+ { name: 'staged cleanup', stage: 'before-staged-cleanup', drift: 'shim', committed: false },
1633
+ { name: 'finalization', stage: 'after-staged-cleanup', drift: 'config', committed: true },
1634
+ ];
1635
+ for (const fixture of fixtures) {
1636
+ const wd = await mkdtemp(join(tmpdir(), `omx-uninstall-applied-${fixture.name}-`));
1637
+ try {
1638
+ await withCwd(wd, async () => {
1639
+ const codexDir = join(wd, '.codex');
1640
+ const configPath = join(codexDir, 'config.toml');
1641
+ const hooksPath = join(codexDir, 'hooks.json');
1642
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
1643
+ const config = Buffer.from(buildOmxConfig(), 'utf-8');
1644
+ const hooks = Buffer.from(`${JSON.stringify(buildManagedCodexHooksConfig(packageRoot(), {
1645
+ platform: 'win32',
1646
+ codexHomeDir: codexDir,
1647
+ }), null, 2)}\n`, 'utf-8');
1648
+ const shim = Buffer.from(buildManagedCodexNativeHookWindowsShimContent(packageRoot()), 'utf-8');
1649
+ const paths = { config: configPath, hooks: hooksPath, shim: shimPath };
1650
+ const before = { config, hooks, shim };
1651
+ const foreign = Buffer.from(`foreign applied ${fixture.drift} ${fixture.name}\n`, 'utf-8');
1652
+ await mkdir(codexDir, { recursive: true });
1653
+ await mkdir(dirname(shimPath), { recursive: true });
1654
+ await writeFile(configPath, config);
1655
+ await writeFile(hooksPath, hooks);
1656
+ await writeFile(shimPath, shim);
1657
+ let injected = false;
1658
+ await assert.rejects(uninstall({
1659
+ scope: 'project',
1660
+ transactionPlatform: 'win32',
1661
+ transactionFailureInjector: async (stage) => {
1662
+ if (stage !== fixture.stage || injected)
1663
+ return;
1664
+ injected = true;
1665
+ await writeFile(paths[fixture.drift], foreign);
1666
+ },
1667
+ }), fixture.committed
1668
+ ? /committed but staged deletion cleanup failed during finalization/
1669
+ : /Uninstall artifact rollback failed/);
1670
+ assert.equal(injected, true, fixture.name);
1671
+ if (fixture.name === 'staged cleanup') {
1672
+ assert.equal(existsSync(hooksPath), false);
1673
+ assert.deepEqual(await readFile(shimPath), foreign);
1674
+ assert.notDeepEqual(await readFile(configPath), before.config);
1675
+ return;
1676
+ }
1677
+ if (fixture.committed) {
1678
+ assert.equal(existsSync(hooksPath), false);
1679
+ assert.equal(existsSync(shimPath), false);
1680
+ assert.deepEqual(await readFile(configPath), foreign);
1681
+ return;
1682
+ }
1683
+ assert.deepEqual(await readFile(hooksPath), fixture.drift === 'hooks' ? foreign : before.hooks);
1684
+ assert.deepEqual(await readFile(shimPath), before.shim);
1685
+ assert.deepEqual(await readFile(configPath), before.config);
1686
+ });
1687
+ }
1688
+ finally {
1689
+ await rm(wd, { recursive: true, force: true });
1690
+ }
1691
+ }
1692
+ });
1693
+ it('rolls back registered destinations when post-rename or post-remove verification fails', async () => {
1694
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-post-apply-recovery-'));
1695
+ try {
1696
+ await withCwd(wd, async () => {
1697
+ const codexDir = join(wd, '.codex');
1698
+ const configPath = join(codexDir, 'config.toml');
1699
+ const hooksPath = join(codexDir, 'hooks.json');
1700
+ const config = Buffer.from(buildOmxConfig(), 'utf-8');
1701
+ const hooks = Buffer.from(`${JSON.stringify(buildManagedCodexHooksConfig(packageRoot()), null, 2)}\n`, 'utf-8');
1702
+ await mkdir(codexDir, { recursive: true });
1703
+ await writeFile(configPath, config);
1704
+ await assert.rejects(uninstall({
1705
+ scope: 'project',
1706
+ transactionFailureInjector: (stage) => {
1707
+ if (stage === 'after-rename')
1708
+ throw new Error('post-rename verification failure');
1709
+ },
1710
+ }), /post-rename verification failure/);
1711
+ assert.deepEqual(await readFile(configPath), config);
1712
+ await writeFile(configPath, config);
1713
+ await writeFile(hooksPath, hooks);
1714
+ await assert.rejects(uninstall({
1715
+ scope: 'project',
1716
+ transactionFailureInjector: (stage) => {
1717
+ if (stage === 'after-remove')
1718
+ throw new Error('post-remove verification failure');
1719
+ },
1720
+ }), /post-remove verification failure/);
1721
+ assert.deepEqual(await readFile(hooksPath), hooks);
1722
+ assert.deepEqual(await readFile(configPath), config);
1723
+ });
1724
+ }
1725
+ finally {
1726
+ await rm(wd, { recursive: true, force: true });
1727
+ }
1728
+ });
1729
+ it('strictly validates dispatcher metadata and treats it as a stale read-only transaction precondition', async () => {
1730
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-notify-metadata-'));
1731
+ try {
1732
+ await withCwd(wd, async () => {
1733
+ const codexDir = join(wd, '.codex');
1734
+ const configPath = join(codexDir, 'config.toml');
1735
+ const metadataPath = join(codexDir, '.omx', 'notify-dispatch.json');
1736
+ const dispatcherPath = join(packageRoot(), 'dist', 'scripts', 'notify-dispatcher.js');
1737
+ const config = buildOmxConfig().replace(/^notify = .*$/m, `notify = ${JSON.stringify(['node', dispatcherPath, '--metadata', metadataPath])}`);
1738
+ const metadata = {
1739
+ managedBy: 'oh-my-codex',
1740
+ version: 1,
1741
+ previousNotify: ['node', '/tmp/user-notify.js'],
1742
+ omxNotify: ['node', join(packageRoot(), 'dist', 'scripts', 'notify-hook.js')],
1743
+ dispatcherNotify: ['node', dispatcherPath, '--metadata', metadataPath],
1744
+ };
1745
+ await mkdir(dirname(metadataPath), { recursive: true });
1746
+ await writeFile(configPath, config);
1747
+ await writeFile(metadataPath, JSON.stringify({ ...metadata, previousNotify: ['not-a-string', 7] }));
1748
+ await assert.rejects(uninstall({ scope: 'project' }), /previousNotify must be null or an array of strings/);
1749
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1750
+ await writeFile(metadataPath, Buffer.from([0x7b, 0xff, 0x7d]));
1751
+ await assert.rejects(uninstall({ scope: 'project' }), /not valid UTF-8/);
1752
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1753
+ await writeFile(metadataPath, JSON.stringify(metadata));
1754
+ await assert.rejects(uninstall({
1755
+ scope: 'project',
1756
+ transactionFailureInjector: async (stage) => {
1757
+ if (stage === 'before-config-commit') {
1758
+ await writeFile(metadataPath, JSON.stringify({ ...metadata, previousNotify: ['node', '/tmp/concurrent.js'] }));
1759
+ }
1760
+ },
1761
+ }), /planned artifact .* changed, was created, or was removed after planning/);
1762
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1763
+ });
1764
+ }
1765
+ finally {
1766
+ await rm(wd, { recursive: true, force: true });
1767
+ }
1768
+ });
1769
+ it('does not report stale rollback recovery when failure occurs before destructive removal', async () => {
1770
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-pre-destructive-'));
1771
+ try {
1772
+ await withCwd(wd, async () => {
1773
+ const codexDir = join(wd, '.codex');
1774
+ const configPath = join(codexDir, 'config.toml');
1775
+ const hooksPath = join(codexDir, 'hooks.json');
1776
+ const stagedPath = join(codexDir, '.hooks.json.pre-destructive');
1777
+ const config = buildOmxConfig();
1778
+ const hooks = `${JSON.stringify(buildManagedCodexHooksConfig(packageRoot()), null, 2)}\n`;
1779
+ await mkdir(codexDir, { recursive: true });
1780
+ await writeFile(configPath, config);
1781
+ await writeFile(hooksPath, hooks);
1782
+ await assert.rejects(uninstall({
1783
+ scope: 'project',
1784
+ transactionTemporaryPath: (_path, purpose) => purpose === 'delete' ? stagedPath : join(codexDir, '.write-temporary'),
1785
+ transactionFailureInjector: (stage) => {
1786
+ if (stage === 'before-remove')
1787
+ throw new Error('pre-destructive removal failure');
1788
+ },
1789
+ }), (error) => {
1790
+ const message = error instanceof Error ? error.message : String(error);
1791
+ assert.match(message, /pre-destructive removal failure/);
1792
+ assert.doesNotMatch(message, /manual recovery|stale rollback/i);
1793
+ return true;
1794
+ });
1795
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1796
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1797
+ assert.equal(existsSync(stagedPath), false);
1798
+ });
1799
+ }
1800
+ finally {
1801
+ await rm(wd, { recursive: true, force: true });
1802
+ }
1803
+ });
1804
+ it('removes exact historical root trust state but preserves nonmatching nested state', async () => {
1805
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-legacy-hook-state-'));
1806
+ try {
1807
+ const home = join(wd, 'home');
1808
+ const codexDir = join(home, '.codex');
1809
+ const configPath = join(codexDir, 'config.toml');
1810
+ const hooksPath = join(codexDir, 'hooks.json');
1811
+ await mkdir(codexDir, { recursive: true });
1812
+ await writeFile(configPath, buildOmxConfig());
1813
+ await writeFile(hooksPath, JSON.stringify({
1814
+ state: {
1815
+ [`${hooksPath}:stop:0:0`]: { trusted_hash: 'sha256:historical' },
427
1816
  },
428
- version: 1,
429
1817
  }, null, 2) + '\n');
1818
+ const rootStateCleanup = runOmx(wd, ['uninstall'], { HOME: home });
1819
+ if (shouldSkipForSpawnPermissions(rootStateCleanup.error))
1820
+ return;
1821
+ assert.equal(rootStateCleanup.status, 0, rootStateCleanup.stderr || rootStateCleanup.stdout);
1822
+ assert.deepEqual(JSON.parse(await readFile(hooksPath, 'utf-8')), {});
1823
+ const nestedState = {
1824
+ retained: { custom: true, trusted_hash: 'sha256:not-omx' },
1825
+ };
1826
+ await writeFile(hooksPath, JSON.stringify({
1827
+ hooks: {
1828
+ state: nestedState,
1829
+ SessionStart: [{
1830
+ hooks: [{ type: 'command', command: 'node "/repo/dist/scripts/codex-native-hook.js"' }],
1831
+ }],
1832
+ },
1833
+ }, null, 2) + '\n');
1834
+ const nestedStateCleanup = runOmx(wd, ['uninstall', '--keep-config'], { HOME: home });
1835
+ assert.equal(nestedStateCleanup.status, 0, nestedStateCleanup.stderr || nestedStateCleanup.stdout);
1836
+ const after = JSON.parse(await readFile(hooksPath, 'utf-8'));
1837
+ assert.deepEqual(after.hooks?.state, nestedState);
1838
+ assert.equal(after.hooks?.SessionStart, undefined);
1839
+ }
1840
+ finally {
1841
+ await rm(wd, { recursive: true, force: true });
1842
+ }
1843
+ });
1844
+ it('removes only trust state at the managed hook coordinates planned from hooks.json', async () => {
1845
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-hook-trust-coordinates-'));
1846
+ try {
1847
+ const home = join(wd, 'home');
1848
+ const codexDir = join(home, '.codex');
1849
+ const configPath = join(codexDir, 'config.toml');
1850
+ const hooksPath = join(codexDir, 'hooks.json');
1851
+ const hooks = JSON.stringify({
1852
+ hooks: {
1853
+ SessionStart: [{
1854
+ hooks: [{ type: 'command', command: 'node "/repo/dist/scripts/codex-native-hook.js"' }],
1855
+ }],
1856
+ },
1857
+ }, null, 2) + '\n';
1858
+ const trustState = buildManagedCodexHookTrustState(hooksPath, wd, {
1859
+ hooksContent: hooks,
1860
+ });
1861
+ const [actualKey, actualTrust] = Object.entries(trustState)[0] ?? [];
1862
+ if (!actualKey || !actualTrust) {
1863
+ assert.fail('expected SessionStart managed trust state');
1864
+ }
1865
+ const staleCoordinate = `${hooksPath}:session_start:9:0`;
1866
+ const actualHeader = `[hooks.state.${JSON.stringify(actualKey)}]`;
1867
+ const staleHeader = `[hooks.state.${JSON.stringify(staleCoordinate)}]`;
1868
+ const config = [
1869
+ buildOmxConfig().trimEnd(),
1870
+ '',
1871
+ actualHeader,
1872
+ `trusted_hash = "${actualTrust.trusted_hash}"`,
1873
+ '',
1874
+ staleHeader,
1875
+ `trusted_hash = "${actualTrust.trusted_hash}"`,
1876
+ '',
1877
+ ].join('\n');
1878
+ await mkdir(codexDir, { recursive: true });
1879
+ await writeFile(configPath, config);
1880
+ await writeFile(hooksPath, hooks);
430
1881
  const res = runOmx(wd, ['uninstall'], { HOME: home });
431
1882
  if (shouldSkipForSpawnPermissions(res.error))
432
1883
  return;
433
1884
  assert.equal(res.status, 0, res.stderr || res.stdout);
434
- assert.equal(existsSync(join(codexDir, 'hooks.json')), true);
435
- const hooks = await readFile(join(codexDir, 'hooks.json'), 'utf-8');
436
- assert.match(hooks, /echo keep-me/);
437
- assert.match(hooks, /"version": 1/);
438
- assert.doesNotMatch(hooks, /codex-native-hook\.js/);
439
- const config = await readFile(join(codexDir, 'config.toml'), 'utf-8');
440
- assert.match(config, /^hooks = true$/m, 'preserved user hooks should keep the canonical Codex hooks feature enabled');
441
- assert.doesNotMatch(config, /^codex_hooks\s*=/m, 'legacy Codex hook aliases should be normalized during uninstall preservation');
442
- assert.match(config, /^multi_agent\s*=/m);
443
- assert.doesNotMatch(config, /^child_agents_md\s*=/m);
444
- assert.doesNotMatch(config, /^goals\s*=/m);
1885
+ const cleaned = await readFile(configPath, 'utf-8');
1886
+ assert.equal(cleaned.includes(actualHeader), false);
1887
+ assert.equal(cleaned.includes(staleHeader), true);
1888
+ }
1889
+ finally {
1890
+ await rm(wd, { recursive: true, force: true });
1891
+ }
1892
+ });
1893
+ it('keeps empty hooks.json byte-identical during dry-run and no-op uninstall', async () => {
1894
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-empty-hooks-'));
1895
+ try {
1896
+ const home = join(wd, 'home');
1897
+ const codexDir = join(home, '.codex');
1898
+ const configPath = join(codexDir, 'config.toml');
1899
+ const hooksPath = join(codexDir, 'hooks.json');
1900
+ const config = buildOmxConfig();
1901
+ const hooks = '{}\n';
1902
+ await mkdir(codexDir, { recursive: true });
1903
+ await writeFile(configPath, config);
1904
+ await writeFile(hooksPath, hooks);
1905
+ const dryRun = runOmx(wd, ['uninstall', '--dry-run'], { HOME: home });
1906
+ if (shouldSkipForSpawnPermissions(dryRun.error))
1907
+ return;
1908
+ assert.equal(dryRun.status, 0, dryRun.stderr || dryRun.stdout);
1909
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1910
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1911
+ const uninstall = runOmx(wd, ['uninstall'], { HOME: home });
1912
+ assert.equal(uninstall.status, 0, uninstall.stderr || uninstall.stdout);
1913
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
1914
+ }
1915
+ finally {
1916
+ await rm(wd, { recursive: true, force: true });
1917
+ }
1918
+ });
1919
+ it('fails closed for partial hooks.json corruption before config cleanup', async () => {
1920
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-corrupt-hooks-'));
1921
+ try {
1922
+ const home = join(wd, 'home');
1923
+ const codexDir = join(home, '.codex');
1924
+ const configPath = join(codexDir, 'config.toml');
1925
+ const hooksPath = join(codexDir, 'hooks.json');
1926
+ const config = buildOmxConfig();
1927
+ const hooks = '{\n "hooks": { "SessionStart": {} }\n}\n';
1928
+ await mkdir(codexDir, { recursive: true });
1929
+ await writeFile(configPath, config);
1930
+ await writeFile(hooksPath, hooks);
1931
+ const res = runOmx(wd, ['uninstall'], { HOME: home });
1932
+ if (shouldSkipForSpawnPermissions(res.error))
1933
+ return;
1934
+ assert.equal(res.status, 1, res.stderr || res.stdout);
1935
+ assert.match(res.stderr, /SessionStart must be an array/);
1936
+ assert.equal(await readFile(configPath, 'utf-8'), config);
1937
+ assert.equal(await readFile(hooksPath, 'utf-8'), hooks);
445
1938
  }
446
1939
  finally {
447
1940
  await rm(wd, { recursive: true, force: true });
@@ -457,10 +1950,11 @@ describe('omx uninstall', () => {
457
1950
  await writeFile(join(codexDir, 'hooks.json'), JSON.stringify({
458
1951
  hooks: {
459
1952
  SessionStart: [
1953
+ { hooks: [{ type: 'command', command: 'echo keep-me' }] },
460
1954
  {
1955
+ matcher: 'startup|resume|clear',
461
1956
  hooks: [
462
1957
  { type: 'command', command: 'node "/repo/dist/scripts/codex-native-hook.js"' },
463
- { type: 'command', command: 'echo keep-me' },
464
1958
  ],
465
1959
  },
466
1960
  ],
@@ -866,6 +2360,25 @@ describe('omx uninstall', () => {
866
2360
  await rm(wd, { recursive: true, force: true });
867
2361
  }
868
2362
  });
2363
+ it('removes OMX-managed AGENTS sections while preserving project guidance', async () => {
2364
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-merged-agents-'));
2365
+ try {
2366
+ const home = join(wd, 'home');
2367
+ await mkdir(home, { recursive: true });
2368
+ await mkdir(join(wd, '.omx'), { recursive: true });
2369
+ await writeFile(join(wd, '.omx', 'setup-scope.json'), JSON.stringify({ scope: 'project' }));
2370
+ const userGuidance = '# User project instructions\n\nPreserve this guidance.\n';
2371
+ await writeFile(join(wd, 'AGENTS.md'), `${userGuidance}\n<!-- OMX:AGENTS:START -->\n<!-- omx:generated:agents-md -->\n# oh-my-codex - Intelligent Multi-Agent Orchestration\n<!-- OMX:AGENTS:END -->\n`);
2372
+ const res = runOmx(wd, ['uninstall', '--keep-config'], { HOME: home });
2373
+ if (shouldSkipForSpawnPermissions(res.error))
2374
+ return;
2375
+ assert.equal(res.status, 0, res.stderr || res.stdout);
2376
+ assert.equal(await readFile(join(wd, 'AGENTS.md'), 'utf-8'), userGuidance);
2377
+ }
2378
+ finally {
2379
+ await rm(wd, { recursive: true, force: true });
2380
+ }
2381
+ });
869
2382
  it('removes managed user-scope AGENTS.md from CODEX_HOME', async () => {
870
2383
  const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-'));
871
2384
  try {
@@ -1094,6 +2607,240 @@ describe('omx uninstall', () => {
1094
2607
  await rm(wd, { recursive: true, force: true });
1095
2608
  }
1096
2609
  });
2610
+ it('preserves a same-byte foreign replacement made immediately after an uninstall rename', async () => {
2611
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-immediate-post-rename-'));
2612
+ try {
2613
+ await withCwd(wd, async () => {
2614
+ const codexDir = join(wd, '.codex');
2615
+ const configPath = join(codexDir, 'config.toml');
2616
+ const foreignPath = join(codexDir, '.foreign-config-same-byte');
2617
+ const config = Buffer.from(buildOmxConfig(), 'utf-8');
2618
+ let replacement;
2619
+ let replacedInode;
2620
+ await mkdir(codexDir, { recursive: true });
2621
+ await writeFile(configPath, config);
2622
+ await assert.rejects(uninstall({
2623
+ scope: 'project',
2624
+ transactionFailureInjector: async (stage) => {
2625
+ if (stage !== 'after-rename')
2626
+ return;
2627
+ replacement = readFileSync(configPath);
2628
+ const ownedInode = lstatSync(configPath).ino;
2629
+ await writeFile(foreignPath, replacement);
2630
+ assert.notEqual(lstatSync(foreignPath).ino, ownedInode);
2631
+ rmSync(configPath);
2632
+ renameSync(foreignPath, configPath);
2633
+ replacedInode = lstatSync(configPath).ino;
2634
+ },
2635
+ }), /Uninstall artifact rollback failed.*recovery preflight/);
2636
+ assert.ok(replacement);
2637
+ assert.ok(replacedInode);
2638
+ assert.deepEqual(await readFile(configPath), replacement);
2639
+ });
2640
+ }
2641
+ finally {
2642
+ await rm(wd, { recursive: true, force: true });
2643
+ }
2644
+ });
2645
+ it('preflights every staged uninstall recovery copy before rollback', async () => {
2646
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-rollback-recovery-preflight-'));
2647
+ try {
2648
+ await withCwd(wd, async () => {
2649
+ const codexDir = join(wd, '.codex');
2650
+ const configPath = join(codexDir, 'config.toml');
2651
+ const hooksPath = join(codexDir, 'hooks.json');
2652
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
2653
+ const stagedShimPath = join(dirname(shimPath), '.shim-preflight-recovery');
2654
+ const config = Buffer.from(buildOmxConfig(), 'utf-8');
2655
+ const hooks = Buffer.from(`${JSON.stringify(buildManagedCodexHooksConfig(packageRoot(), {
2656
+ platform: 'win32',
2657
+ codexHomeDir: codexDir,
2658
+ }), null, 2)}\n`, 'utf-8');
2659
+ await mkdir(codexDir, { recursive: true });
2660
+ await mkdir(dirname(shimPath), { recursive: true });
2661
+ await writeFile(configPath, config);
2662
+ await writeFile(hooksPath, hooks);
2663
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
2664
+ await assert.rejects(uninstall({
2665
+ scope: 'project',
2666
+ transactionPlatform: 'win32',
2667
+ transactionTemporaryPath: (path, purpose) => path === shimPath && purpose === 'delete'
2668
+ ? stagedShimPath
2669
+ : join(dirname(path), `.${basename(path)}.${purpose}-preflight`),
2670
+ transactionFailureInjector: async (stage) => {
2671
+ if (stage !== 'after-config-commit')
2672
+ return;
2673
+ await rm(stagedShimPath);
2674
+ throw new Error('injected missing staged recovery copy');
2675
+ },
2676
+ }), /Uninstall artifact rollback failed.*recovery preflight/);
2677
+ assert.equal(existsSync(hooksPath), false);
2678
+ assert.equal(existsSync(shimPath), false);
2679
+ assert.notDeepEqual(await readFile(configPath), config);
2680
+ assert.equal(existsSync(stagedShimPath), false);
2681
+ });
2682
+ }
2683
+ finally {
2684
+ await rm(wd, { recursive: true, force: true });
2685
+ }
2686
+ });
2687
+ it('stops later uninstall rollback restores when the first restored artifact drifts', async () => {
2688
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-rollback-restored-drift-'));
2689
+ try {
2690
+ await withCwd(wd, async () => {
2691
+ const codexDir = join(wd, '.codex');
2692
+ const configPath = join(codexDir, 'config.toml');
2693
+ const hooksPath = join(codexDir, 'hooks.json');
2694
+ const shimPath = buildManagedCodexNativeHookWindowsShimPath(codexDir);
2695
+ const foreignConfigPath = join(codexDir, '.foreign-restored-config');
2696
+ const config = Buffer.from(buildOmxConfig(), 'utf-8');
2697
+ const hooks = Buffer.from(`${JSON.stringify(buildManagedCodexHooksConfig(packageRoot(), {
2698
+ platform: 'win32',
2699
+ codexHomeDir: codexDir,
2700
+ }), null, 2)}\n`, 'utf-8');
2701
+ await mkdir(codexDir, { recursive: true });
2702
+ await mkdir(dirname(shimPath), { recursive: true });
2703
+ await writeFile(configPath, config);
2704
+ await writeFile(hooksPath, hooks);
2705
+ await writeFile(shimPath, buildManagedCodexNativeHookWindowsShimContent(packageRoot()));
2706
+ let rollbackRenameCount = 0;
2707
+ let drifted = false;
2708
+ await assert.rejects(uninstall({
2709
+ scope: 'project',
2710
+ transactionPlatform: 'win32',
2711
+ transactionFailureInjector: async (stage) => {
2712
+ if (stage === 'after-config-commit') {
2713
+ throw new Error('injected rollback start failure');
2714
+ }
2715
+ if (stage !== 'before-rollback-rename')
2716
+ return;
2717
+ rollbackRenameCount += 1;
2718
+ if (rollbackRenameCount !== 2)
2719
+ return;
2720
+ const restoredInode = lstatSync(configPath).ino;
2721
+ await writeFile(foreignConfigPath, config);
2722
+ assert.notEqual(lstatSync(foreignConfigPath).ino, restoredInode);
2723
+ rmSync(configPath);
2724
+ renameSync(foreignConfigPath, configPath);
2725
+ drifted = lstatSync(configPath).ino !== restoredInode;
2726
+ },
2727
+ }), /Uninstall artifact rollback failed/);
2728
+ assert.equal(rollbackRenameCount, 2);
2729
+ assert.equal(drifted, true);
2730
+ assert.deepEqual(await readFile(configPath), config);
2731
+ assert.equal(existsSync(shimPath), false);
2732
+ assert.equal(existsSync(hooksPath), false);
2733
+ });
2734
+ }
2735
+ finally {
2736
+ await rm(wd, { recursive: true, force: true });
2737
+ }
2738
+ });
2739
+ it('preserves a foreign inode injected after final uninstall rename validation', async () => {
2740
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-final-rename-claim-'));
2741
+ try {
2742
+ await withCwd(wd, async () => {
2743
+ const codexDir = join(wd, '.codex');
2744
+ const configPath = join(codexDir, 'config.toml');
2745
+ const config = Buffer.from(buildOmxConfig(), 'utf-8');
2746
+ const foreignConfig = Buffer.from('model = "foreign-final-rename"\n', 'utf-8');
2747
+ await mkdir(codexDir, { recursive: true });
2748
+ await writeFile(configPath, config);
2749
+ let foreignInode;
2750
+ await assert.rejects(uninstall({
2751
+ scope: 'project',
2752
+ transactionFailureInjector: (stage) => {
2753
+ if (stage !== 'after-final-rename-validation')
2754
+ return;
2755
+ const foreignPath = join(codexDir, '.foreign-final-rename-config');
2756
+ writeFileSync(foreignPath, foreignConfig);
2757
+ foreignInode = lstatSync(foreignPath).ino;
2758
+ rmSync(configPath);
2759
+ renameSync(foreignPath, configPath);
2760
+ },
2761
+ }), /(?:replacement claim.*not the planned artifact|planned artifact .* changed)/);
2762
+ assert.ok(foreignInode);
2763
+ assert.equal(lstatSync(configPath).ino, foreignInode);
2764
+ assert.deepEqual(await readFile(configPath), foreignConfig);
2765
+ });
2766
+ }
2767
+ finally {
2768
+ await rm(wd, { recursive: true, force: true });
2769
+ }
2770
+ });
2771
+ it('preserves a foreign inode injected after final uninstall removal validation', async () => {
2772
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-final-remove-claim-'));
2773
+ try {
2774
+ await withCwd(wd, async () => {
2775
+ const codexDir = join(wd, '.codex');
2776
+ const configPath = join(codexDir, 'config.toml');
2777
+ const hooksPath = join(codexDir, 'hooks.json');
2778
+ const config = Buffer.from(buildOmxConfig(), 'utf-8');
2779
+ const hooks = Buffer.from(`${JSON.stringify(buildManagedCodexHooksConfig(packageRoot()), null, 2)}\n`, 'utf-8');
2780
+ const foreignHooks = Buffer.from('{"hooks":{"Stop":[]}}\n', 'utf-8');
2781
+ await mkdir(codexDir, { recursive: true });
2782
+ await writeFile(configPath, config);
2783
+ await writeFile(hooksPath, hooks);
2784
+ let foreignInode;
2785
+ await assert.rejects(uninstall({
2786
+ scope: 'project',
2787
+ transactionFailureInjector: (stage) => {
2788
+ if (stage !== 'after-final-remove-validation')
2789
+ return;
2790
+ const foreignPath = join(codexDir, '.foreign-final-remove-hooks');
2791
+ writeFileSync(foreignPath, foreignHooks);
2792
+ foreignInode = lstatSync(foreignPath).ino;
2793
+ rmSync(hooksPath);
2794
+ renameSync(foreignPath, hooksPath);
2795
+ },
2796
+ }), /removal claim.*not the planned artifact/);
2797
+ assert.ok(foreignInode);
2798
+ assert.equal(lstatSync(hooksPath).ino, foreignInode);
2799
+ assert.deepEqual(await readFile(hooksPath), foreignHooks);
2800
+ assert.deepEqual(await readFile(configPath), config);
2801
+ });
2802
+ }
2803
+ finally {
2804
+ await rm(wd, { recursive: true, force: true });
2805
+ }
2806
+ });
2807
+ it('preserves a foreign inode injected after final uninstall rollback validation', async () => {
2808
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-final-restore-claim-'));
2809
+ try {
2810
+ await withCwd(wd, async () => {
2811
+ const codexDir = join(wd, '.codex');
2812
+ const configPath = join(codexDir, 'config.toml');
2813
+ const hooksPath = join(codexDir, 'hooks.json');
2814
+ const config = Buffer.from(buildOmxConfig(), 'utf-8');
2815
+ const hooks = Buffer.from(`${JSON.stringify(buildManagedCodexHooksConfig(packageRoot()), null, 2)}\n`, 'utf-8');
2816
+ const foreignHooks = Buffer.from('{"hooks":{"Stop":[]}}\n', 'utf-8');
2817
+ await mkdir(codexDir, { recursive: true });
2818
+ await writeFile(configPath, config);
2819
+ await writeFile(hooksPath, hooks);
2820
+ let foreignInode;
2821
+ await assert.rejects(uninstall({
2822
+ scope: 'project',
2823
+ transactionFailureInjector: (stage) => {
2824
+ if (stage === 'before-config-commit')
2825
+ throw new Error('injected rollback start');
2826
+ if (stage !== 'after-final-restore-validation')
2827
+ return;
2828
+ const foreignPath = join(codexDir, '.foreign-final-restore-hooks');
2829
+ writeFileSync(foreignPath, foreignHooks);
2830
+ foreignInode = lstatSync(foreignPath).ino;
2831
+ renameSync(foreignPath, hooksPath);
2832
+ },
2833
+ }), /Uninstall artifact rollback failed/);
2834
+ assert.ok(foreignInode);
2835
+ assert.equal(lstatSync(hooksPath).ino, foreignInode);
2836
+ assert.deepEqual(await readFile(hooksPath), foreignHooks);
2837
+ assert.deepEqual(await readFile(configPath), config);
2838
+ });
2839
+ }
2840
+ finally {
2841
+ await rm(wd, { recursive: true, force: true });
2842
+ }
2843
+ });
1097
2844
  });
1098
2845
  describe('stripOmxFeatureFlags', () => {
1099
2846
  it('removes OMX feature flags and preserves user flags', async () => {