oh-my-codex 0.20.0 → 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 (661) 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 +27 -0
  36. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  37. package/dist/cli/__tests__/doctor-context-window-warning.test.js +83 -78
  38. package/dist/cli/__tests__/doctor-context-window-warning.test.js.map +1 -1
  39. package/dist/cli/__tests__/doctor-invalid-config.test.js +56 -4
  40. package/dist/cli/__tests__/doctor-invalid-config.test.js.map +1 -1
  41. package/dist/cli/__tests__/doctor-team.test.js.map +1 -1
  42. package/dist/cli/__tests__/doctor-warning-copy.test.js +735 -14
  43. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  44. package/dist/cli/__tests__/exec.test.js.map +1 -1
  45. package/dist/cli/__tests__/imagegen-continuation.test.js.map +1 -1
  46. package/dist/cli/__tests__/index.test.js +216 -11
  47. package/dist/cli/__tests__/index.test.js.map +1 -1
  48. package/dist/cli/__tests__/launch-fallback.test.js +49 -3
  49. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  50. package/dist/cli/__tests__/mcp-parity.test.js +84 -12
  51. package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
  52. package/dist/cli/__tests__/native-hook-claim-journal.test.d.ts +2 -0
  53. package/dist/cli/__tests__/native-hook-claim-journal.test.d.ts.map +1 -0
  54. package/dist/cli/__tests__/native-hook-claim-journal.test.js +128 -0
  55. package/dist/cli/__tests__/native-hook-claim-journal.test.js.map +1 -0
  56. package/dist/cli/__tests__/nested-help-routing.test.js.map +1 -1
  57. package/dist/cli/__tests__/question.test.js.map +1 -1
  58. package/dist/cli/__tests__/ralph-deslop-contract.test.js.map +1 -1
  59. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js +6 -0
  60. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js.map +1 -1
  61. package/dist/cli/__tests__/ralph-prd-deep-interview.test.js.map +1 -1
  62. package/dist/cli/__tests__/ralph-prd-smoke.test.js.map +1 -1
  63. package/dist/cli/__tests__/ralph.test.js +8 -3
  64. package/dist/cli/__tests__/ralph.test.js.map +1 -1
  65. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.d.ts +2 -0
  66. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.d.ts.map +1 -0
  67. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js +187 -0
  68. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -0
  69. package/dist/cli/__tests__/ralplan.test.d.ts +2 -0
  70. package/dist/cli/__tests__/ralplan.test.d.ts.map +1 -0
  71. package/dist/cli/__tests__/ralplan.test.js +129 -0
  72. package/dist/cli/__tests__/ralplan.test.js.map +1 -0
  73. package/dist/cli/__tests__/resume.test.js +6 -1
  74. package/dist/cli/__tests__/resume.test.js.map +1 -1
  75. package/dist/cli/__tests__/session-scoped-runtime.test.js +25 -0
  76. package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
  77. package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
  78. package/dist/cli/__tests__/session-search.test.js.map +1 -1
  79. package/dist/cli/__tests__/setup-agents-overwrite.test.js +79 -0
  80. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  81. package/dist/cli/__tests__/setup-gh-star.test.js.map +1 -1
  82. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js +131 -5
  83. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js.map +1 -1
  84. package/dist/cli/__tests__/setup-hooks-trust-e2e.test.d.ts +2 -0
  85. package/dist/cli/__tests__/setup-hooks-trust-e2e.test.d.ts.map +1 -0
  86. package/dist/cli/__tests__/setup-hooks-trust-e2e.test.js +285 -0
  87. package/dist/cli/__tests__/setup-hooks-trust-e2e.test.js.map +1 -0
  88. package/dist/cli/__tests__/setup-install-mode.test.js +2264 -17
  89. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  90. package/dist/cli/__tests__/setup-preferences.test.d.ts +2 -0
  91. package/dist/cli/__tests__/setup-preferences.test.d.ts.map +1 -0
  92. package/dist/cli/__tests__/setup-preferences.test.js +79 -0
  93. package/dist/cli/__tests__/setup-preferences.test.js.map +1 -0
  94. package/dist/cli/__tests__/setup-refresh.test.js +168 -5
  95. package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
  96. package/dist/cli/__tests__/setup-scope.test.js +73 -6
  97. package/dist/cli/__tests__/setup-scope.test.js.map +1 -1
  98. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  99. package/dist/cli/__tests__/state.test.js +37 -0
  100. package/dist/cli/__tests__/state.test.js.map +1 -1
  101. package/dist/cli/__tests__/team.test.js.map +1 -1
  102. package/dist/cli/__tests__/ultragoal.test.js +61 -1
  103. package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
  104. package/dist/cli/__tests__/uninstall.test.js +2067 -26
  105. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  106. package/dist/cli/__tests__/update.test.js +58 -0
  107. package/dist/cli/__tests__/update.test.js.map +1 -1
  108. package/dist/cli/__tests__/url.test.js.map +1 -1
  109. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  110. package/dist/cli/adapt.js.map +1 -1
  111. package/dist/cli/agents-init.js.map +1 -1
  112. package/dist/cli/agents.js.map +1 -1
  113. package/dist/cli/api.js.map +1 -1
  114. package/dist/cli/ask.d.ts +1 -1
  115. package/dist/cli/ask.d.ts.map +1 -1
  116. package/dist/cli/ask.js.map +1 -1
  117. package/dist/cli/auth.js.map +1 -1
  118. package/dist/cli/autoresearch-guided.js.map +1 -1
  119. package/dist/cli/autoresearch-intake.js.map +1 -1
  120. package/dist/cli/capabilities.js.map +1 -1
  121. package/dist/cli/cleanup.js.map +1 -1
  122. package/dist/cli/codex-feature-probe.js.map +1 -1
  123. package/dist/cli/codex-home.js.map +1 -1
  124. package/dist/cli/doctor.d.ts +20 -0
  125. package/dist/cli/doctor.d.ts.map +1 -1
  126. package/dist/cli/doctor.js +636 -161
  127. package/dist/cli/doctor.js.map +1 -1
  128. package/dist/cli/explore.js.map +1 -1
  129. package/dist/cli/index.d.ts +11 -3
  130. package/dist/cli/index.d.ts.map +1 -1
  131. package/dist/cli/index.js +108 -20
  132. package/dist/cli/index.js.map +1 -1
  133. package/dist/cli/mcp-serve.js.map +1 -1
  134. package/dist/cli/mission.js.map +1 -1
  135. package/dist/cli/native-assets.js.map +1 -1
  136. package/dist/cli/native-hook-claim-journal.d.ts +18 -0
  137. package/dist/cli/native-hook-claim-journal.d.ts.map +1 -0
  138. package/dist/cli/native-hook-claim-journal.js +239 -0
  139. package/dist/cli/native-hook-claim-journal.js.map +1 -0
  140. package/dist/cli/omx.js.map +1 -1
  141. package/dist/cli/plugin-marketplace.js.map +1 -1
  142. package/dist/cli/project-runtime-codex-homes.js.map +1 -1
  143. package/dist/cli/question.js.map +1 -1
  144. package/dist/cli/ralph.d.ts.map +1 -1
  145. package/dist/cli/ralph.js +3 -2
  146. package/dist/cli/ralph.js.map +1 -1
  147. package/dist/cli/ralplan.d.ts +14 -0
  148. package/dist/cli/ralplan.d.ts.map +1 -0
  149. package/dist/cli/ralplan.js +203 -0
  150. package/dist/cli/ralplan.js.map +1 -0
  151. package/dist/cli/setup-preferences.d.ts +11 -0
  152. package/dist/cli/setup-preferences.d.ts.map +1 -1
  153. package/dist/cli/setup-preferences.js +32 -0
  154. package/dist/cli/setup-preferences.js.map +1 -1
  155. package/dist/cli/setup.d.ts +53 -0
  156. package/dist/cli/setup.d.ts.map +1 -1
  157. package/dist/cli/setup.js +1403 -400
  158. package/dist/cli/setup.js.map +1 -1
  159. package/dist/cli/sparkshell.js.map +1 -1
  160. package/dist/cli/star-prompt.js.map +1 -1
  161. package/dist/cli/state.d.ts.map +1 -1
  162. package/dist/cli/state.js +2 -1
  163. package/dist/cli/state.js.map +1 -1
  164. package/dist/cli/team.js.map +1 -1
  165. package/dist/cli/ultragoal.d.ts +5 -1
  166. package/dist/cli/ultragoal.d.ts.map +1 -1
  167. package/dist/cli/ultragoal.js +34 -2
  168. package/dist/cli/ultragoal.js.map +1 -1
  169. package/dist/cli/uninstall.d.ts +8 -0
  170. package/dist/cli/uninstall.d.ts.map +1 -1
  171. package/dist/cli/uninstall.js +947 -99
  172. package/dist/cli/uninstall.js.map +1 -1
  173. package/dist/cli/update.d.ts.map +1 -1
  174. package/dist/cli/update.js +8 -1
  175. package/dist/cli/update.js.map +1 -1
  176. package/dist/cli/url.js.map +1 -1
  177. package/dist/compat/__tests__/doctor-contract.test.js +5 -1
  178. package/dist/compat/__tests__/doctor-contract.test.js.map +1 -1
  179. package/dist/compat/__tests__/rust-runtime-compat.test.js.map +1 -1
  180. package/dist/config/__tests__/codex-hooks.test.js +1287 -11
  181. package/dist/config/__tests__/codex-hooks.test.js.map +1 -1
  182. package/dist/config/__tests__/generator-idempotent.test.js +1049 -135
  183. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  184. package/dist/config/__tests__/generator-notify.test.js +28 -49
  185. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  186. package/dist/config/codex-feature-flags.d.ts.map +1 -1
  187. package/dist/config/codex-feature-flags.js.map +1 -1
  188. package/dist/config/codex-hooks.d.ts +124 -1
  189. package/dist/config/codex-hooks.d.ts.map +1 -1
  190. package/dist/config/codex-hooks.js +2142 -265
  191. package/dist/config/codex-hooks.js.map +1 -1
  192. package/dist/config/commit-lore-guard.js.map +1 -1
  193. package/dist/config/generator.d.ts +40 -26
  194. package/dist/config/generator.d.ts.map +1 -1
  195. package/dist/config/generator.js +1159 -288
  196. package/dist/config/generator.js.map +1 -1
  197. package/dist/config/mcp-registry.js.map +1 -1
  198. package/dist/config/models.d.ts +2 -2
  199. package/dist/config/models.d.ts.map +1 -1
  200. package/dist/config/models.js.map +1 -1
  201. package/dist/config/omx-first-party-mcp.js.map +1 -1
  202. package/dist/config/team-mode.d.ts.map +1 -1
  203. package/dist/document-refresh/__tests__/enforcer.test.js.map +1 -1
  204. package/dist/document-refresh/enforcer.js.map +1 -1
  205. package/dist/exec/followup.js.map +1 -1
  206. package/dist/goal-workflows/artifacts.js.map +1 -1
  207. package/dist/hooks/__tests__/agents-overlay.test.js +7 -4
  208. package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
  209. package/dist/hooks/__tests__/analyze-routing-contract.test.js.map +1 -1
  210. package/dist/hooks/__tests__/analyze-skill-contract.test.js.map +1 -1
  211. package/dist/hooks/__tests__/anti-slop-workflow.test.js.map +1 -1
  212. package/dist/hooks/__tests__/autopilot-skill-contract.test.js +9 -3
  213. package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
  214. package/dist/hooks/__tests__/code-review-skill-contract.test.js.map +1 -1
  215. package/dist/hooks/__tests__/consensus-execution-handoff.test.js.map +1 -1
  216. package/dist/hooks/__tests__/deep-interview-contract.test.js.map +1 -1
  217. package/dist/hooks/__tests__/design-skill.test.js.map +1 -1
  218. package/dist/hooks/__tests__/keyword-detector.test.js +1372 -2
  219. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  220. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +14 -0
  221. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  222. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  223. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +669 -1
  224. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  225. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +2 -0
  226. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
  227. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js +142 -1
  228. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js.map +1 -1
  229. package/dist/hooks/__tests__/notify-hook-modules.test.js.map +1 -1
  230. package/dist/hooks/__tests__/notify-hook-non-omx-guard.test.js +48 -2
  231. package/dist/hooks/__tests__/notify-hook-non-omx-guard.test.js.map +1 -1
  232. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js +61 -44
  233. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js.map +1 -1
  234. package/dist/hooks/__tests__/notify-hook-regression-205.test.js.map +1 -1
  235. package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js +3 -2
  236. package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js.map +1 -1
  237. package/dist/hooks/__tests__/notify-hook-session-scope.test.js +243 -4
  238. package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
  239. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  240. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +8 -1
  241. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  242. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
  243. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  244. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -1
  245. package/dist/hooks/__tests__/notify-hook-visual-verdict.test.js.map +1 -1
  246. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  247. package/dist/hooks/__tests__/openclaw-setup-contract.test.js.map +1 -1
  248. package/dist/hooks/__tests__/pre-context-gate-skills.test.js.map +1 -1
  249. package/dist/hooks/__tests__/prometheus-strict-contract.test.js.map +1 -1
  250. package/dist/hooks/__tests__/prompt-guidance-fragments.test.js.map +1 -1
  251. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js.map +1 -1
  252. package/dist/hooks/__tests__/prompt-session-provenance.test.d.ts +2 -0
  253. package/dist/hooks/__tests__/prompt-session-provenance.test.d.ts.map +1 -0
  254. package/dist/hooks/__tests__/prompt-session-provenance.test.js +82 -0
  255. package/dist/hooks/__tests__/prompt-session-provenance.test.js.map +1 -0
  256. package/dist/hooks/__tests__/session.test.js +532 -11
  257. package/dist/hooks/__tests__/session.test.js.map +1 -1
  258. package/dist/hooks/__tests__/skill-catalog-hygiene.test.js.map +1 -1
  259. package/dist/hooks/__tests__/team-runtime-gating-docs-contract.test.js.map +1 -1
  260. package/dist/hooks/__tests__/visual-ralph-skill.test.js.map +1 -1
  261. package/dist/hooks/__tests__/visual-verdict-loop.test.js.map +1 -1
  262. package/dist/hooks/agents-overlay.js +5 -5
  263. package/dist/hooks/agents-overlay.js.map +1 -1
  264. package/dist/hooks/code-simplifier/index.js.map +1 -1
  265. package/dist/hooks/explore-routing.js.map +1 -1
  266. package/dist/hooks/extensibility/dispatcher.js.map +1 -1
  267. package/dist/hooks/extensibility/events.js.map +1 -1
  268. package/dist/hooks/extensibility/loader.js.map +1 -1
  269. package/dist/hooks/extensibility/plugin-runner-stdin.js.map +1 -1
  270. package/dist/hooks/extensibility/sdk/logging.js.map +1 -1
  271. package/dist/hooks/keyword-detector.d.ts +33 -7
  272. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  273. package/dist/hooks/keyword-detector.js +2228 -119
  274. package/dist/hooks/keyword-detector.js.map +1 -1
  275. package/dist/hooks/keyword-registry.d.ts +12 -0
  276. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  277. package/dist/hooks/keyword-registry.js +41 -0
  278. package/dist/hooks/keyword-registry.js.map +1 -1
  279. package/dist/hooks/prompt-session-provenance.d.ts +87 -0
  280. package/dist/hooks/prompt-session-provenance.d.ts.map +1 -0
  281. package/dist/hooks/prompt-session-provenance.js +180 -0
  282. package/dist/hooks/prompt-session-provenance.js.map +1 -0
  283. package/dist/hooks/session.d.ts +139 -34
  284. package/dist/hooks/session.d.ts.map +1 -1
  285. package/dist/hooks/session.js +1239 -317
  286. package/dist/hooks/session.js.map +1 -1
  287. package/dist/hooks/task-size-detector.js.map +1 -1
  288. package/dist/hud/__tests__/state.test.js.map +1 -1
  289. package/dist/hud/authority.js.map +1 -1
  290. package/dist/hud/constants.js.map +1 -1
  291. package/dist/hud/index.js.map +1 -1
  292. package/dist/hud/reconcile.js.map +1 -1
  293. package/dist/hud/render.js.map +1 -1
  294. package/dist/hud/state.js.map +1 -1
  295. package/dist/hud/tmux.js.map +1 -1
  296. package/dist/leader/__tests__/contract.test.js +146 -2
  297. package/dist/leader/__tests__/contract.test.js.map +1 -1
  298. package/dist/leader/contract.d.ts +26 -5
  299. package/dist/leader/contract.d.ts.map +1 -1
  300. package/dist/leader/contract.js +131 -7
  301. package/dist/leader/contract.js.map +1 -1
  302. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  303. package/dist/mcp/__tests__/server-lifecycle.test.js +15 -2
  304. package/dist/mcp/__tests__/server-lifecycle.test.js.map +1 -1
  305. package/dist/mcp/__tests__/state-paths.test.js +98 -1
  306. package/dist/mcp/__tests__/state-paths.test.js.map +1 -1
  307. package/dist/mcp/bootstrap.js.map +1 -1
  308. package/dist/mcp/code-intel-server.d.ts +58 -58
  309. package/dist/mcp/code-intel-server.d.ts.map +1 -1
  310. package/dist/mcp/code-intel-server.js.map +1 -1
  311. package/dist/mcp/hermes-bridge.js.map +1 -1
  312. package/dist/mcp/hermes-server.d.ts +70 -70
  313. package/dist/mcp/hermes-server.d.ts.map +1 -1
  314. package/dist/mcp/lifecycle-telemetry.js.map +1 -1
  315. package/dist/mcp/memory-server.d.ts +34 -34
  316. package/dist/mcp/memory-server.d.ts.map +1 -1
  317. package/dist/mcp/memory-server.js.map +1 -1
  318. package/dist/mcp/state-paths.d.ts +14 -0
  319. package/dist/mcp/state-paths.d.ts.map +1 -1
  320. package/dist/mcp/state-paths.js +96 -20
  321. package/dist/mcp/state-paths.js.map +1 -1
  322. package/dist/mcp/state-server.d.ts +47 -47
  323. package/dist/mcp/state-server.d.ts.map +1 -1
  324. package/dist/mcp/trace-server.d.ts +4 -4
  325. package/dist/mcp/trace-server.d.ts.map +1 -1
  326. package/dist/mcp/trace-server.js.map +1 -1
  327. package/dist/mcp/wiki-server.d.ts +23 -23
  328. package/dist/mcp/wiki-server.d.ts.map +1 -1
  329. package/dist/mcp/wiki-server.js.map +1 -1
  330. package/dist/modes/__tests__/base-session-scope.test.js +53 -1
  331. package/dist/modes/__tests__/base-session-scope.test.js.map +1 -1
  332. package/dist/modes/base.d.ts.map +1 -1
  333. package/dist/modes/base.js +5 -5
  334. package/dist/modes/base.js.map +1 -1
  335. package/dist/notifications/__tests__/lifecycle-dedupe.test.js.map +1 -1
  336. package/dist/notifications/__tests__/reply-listener.test.js.map +1 -1
  337. package/dist/notifications/__tests__/session-idle-tail-dedupe.test.js +177 -85
  338. package/dist/notifications/__tests__/session-idle-tail-dedupe.test.js.map +1 -1
  339. package/dist/notifications/__tests__/session-registry.test.js.map +1 -1
  340. package/dist/notifications/__tests__/template-engine.test.js.map +1 -1
  341. package/dist/notifications/__tests__/verbosity.test.js.map +1 -1
  342. package/dist/notifications/http-client.js.map +1 -1
  343. package/dist/notifications/index.d.ts +4 -1
  344. package/dist/notifications/index.d.ts.map +1 -1
  345. package/dist/notifications/index.js +3 -9
  346. package/dist/notifications/index.js.map +1 -1
  347. package/dist/notifications/lifecycle-dedupe.js.map +1 -1
  348. package/dist/notifications/reply-listener.d.ts.map +1 -1
  349. package/dist/notifications/reply-listener.js.map +1 -1
  350. package/dist/notifications/session-registry.js.map +1 -1
  351. package/dist/notifications/session-status.js.map +1 -1
  352. package/dist/notifications/temp-contract.js.map +1 -1
  353. package/dist/notifications/tmux-detector.js.map +1 -1
  354. package/dist/notifications/tmux.js.map +1 -1
  355. package/dist/openclaw/config.js.map +1 -1
  356. package/dist/openclaw/dispatcher.js.map +1 -1
  357. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  358. package/dist/pipeline/__tests__/stages.test.js +8 -5
  359. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  360. package/dist/pipeline/stages/code-review.js.map +1 -1
  361. package/dist/pipeline/stages/ralph-verify.js.map +1 -1
  362. package/dist/pipeline/stages/ralplan.js.map +1 -1
  363. package/dist/pipeline/stages/team-exec.js.map +1 -1
  364. package/dist/pipeline/stages/ultragoal.js.map +1 -1
  365. package/dist/pipeline/stages/ultraqa.js.map +1 -1
  366. package/dist/planning/__tests__/approved-execution-lifecycle-matrix.test.js.map +1 -1
  367. package/dist/planning/artifact-names.js.map +1 -1
  368. package/dist/planning/artifacts.js.map +1 -1
  369. package/dist/question/__tests__/state.test.js.map +1 -1
  370. package/dist/question/__tests__/ui.test.js.map +1 -1
  371. package/dist/question/autopilot-wait.js.map +1 -1
  372. package/dist/question/client.d.ts.map +1 -1
  373. package/dist/question/client.js.map +1 -1
  374. package/dist/question/deep-interview.js.map +1 -1
  375. package/dist/question/events.js.map +1 -1
  376. package/dist/question/renderer.js.map +1 -1
  377. package/dist/question/state.d.ts.map +1 -1
  378. package/dist/question/state.js.map +1 -1
  379. package/dist/question/ui.js.map +1 -1
  380. package/dist/ralph/contract.d.ts +1 -1
  381. package/dist/ralph/contract.d.ts.map +1 -1
  382. package/dist/ralplan/__tests__/consensus-gate.test.js +403 -8
  383. package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -1
  384. package/dist/ralplan/__tests__/runtime.test.js +112 -14
  385. package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
  386. package/dist/ralplan/consensus-gate.d.ts +3 -3
  387. package/dist/ralplan/consensus-gate.d.ts.map +1 -1
  388. package/dist/ralplan/consensus-gate.js +155 -60
  389. package/dist/ralplan/consensus-gate.js.map +1 -1
  390. package/dist/ralplan/runtime.d.ts +2 -2
  391. package/dist/ralplan/runtime.d.ts.map +1 -1
  392. package/dist/ralplan/runtime.js +1 -2
  393. package/dist/ralplan/runtime.js.map +1 -1
  394. package/dist/runtime/bridge.d.ts.map +1 -1
  395. package/dist/runtime/bridge.js.map +1 -1
  396. package/dist/runtime/process-tree.js.map +1 -1
  397. package/dist/runtime/run-loop.js.map +1 -1
  398. package/dist/runtime/run-outcome.d.ts +3 -3
  399. package/dist/runtime/run-outcome.d.ts.map +1 -1
  400. package/dist/runtime/run-outcome.js.map +1 -1
  401. package/dist/runtime/run-state.js.map +1 -1
  402. package/dist/scripts/__tests__/codex-native-hook.test.js +5414 -801
  403. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  404. package/dist/scripts/__tests__/fixtures/issue-3138-installed-smoke.d.ts +2 -0
  405. package/dist/scripts/__tests__/fixtures/issue-3138-installed-smoke.d.ts.map +1 -0
  406. package/dist/scripts/__tests__/fixtures/issue-3138-installed-smoke.js +634 -0
  407. package/dist/scripts/__tests__/fixtures/issue-3138-installed-smoke.js.map +1 -0
  408. package/dist/scripts/__tests__/generate-release-body.test.js.map +1 -1
  409. package/dist/scripts/__tests__/hook-derived-watcher.test.js.map +1 -1
  410. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  411. package/dist/scripts/__tests__/notify-fallback-delivery.test.d.ts +2 -0
  412. package/dist/scripts/__tests__/notify-fallback-delivery.test.d.ts.map +1 -0
  413. package/dist/scripts/__tests__/notify-fallback-delivery.test.js +174 -0
  414. package/dist/scripts/__tests__/notify-fallback-delivery.test.js.map +1 -0
  415. package/dist/scripts/__tests__/notify-state-io.test.js +57 -1
  416. package/dist/scripts/__tests__/notify-state-io.test.js.map +1 -1
  417. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.d.ts +2 -0
  418. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.d.ts.map +1 -0
  419. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js +205 -0
  420. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js.map +1 -0
  421. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.d.ts +2 -0
  422. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.d.ts.map +1 -0
  423. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js +175 -0
  424. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -0
  425. package/dist/scripts/__tests__/run-test-files.test.js.map +1 -1
  426. package/dist/scripts/__tests__/smoke-packed-install.test.js +848 -4
  427. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  428. package/dist/scripts/__tests__/test-reply-listener-live.test.js.map +1 -1
  429. package/dist/scripts/build-api.js.map +1 -1
  430. package/dist/scripts/build-explore-harness.js.map +1 -1
  431. package/dist/scripts/build-sparkshell.js.map +1 -1
  432. package/dist/scripts/cleanup-explore-harness.js.map +1 -1
  433. package/dist/scripts/codex-execution-surface.js.map +1 -1
  434. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  435. package/dist/scripts/codex-native-hook.js +825 -172
  436. package/dist/scripts/codex-native-hook.js.map +1 -1
  437. package/dist/scripts/generate-native-release-manifest.js.map +1 -1
  438. package/dist/scripts/generate-release-body.js.map +1 -1
  439. package/dist/scripts/hook-payload-guard.d.ts.map +1 -1
  440. package/dist/scripts/hook-payload-guard.js.map +1 -1
  441. package/dist/scripts/notify-fallback-delivery.d.ts +48 -0
  442. package/dist/scripts/notify-fallback-delivery.d.ts.map +1 -0
  443. package/dist/scripts/notify-fallback-delivery.js +419 -0
  444. package/dist/scripts/notify-fallback-delivery.js.map +1 -0
  445. package/dist/scripts/notify-fallback-watcher.js +136 -30
  446. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  447. package/dist/scripts/notify-hook/auto-nudge.d.ts +18 -9
  448. package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
  449. package/dist/scripts/notify-hook/auto-nudge.js +39 -13
  450. package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
  451. package/dist/scripts/notify-hook/managed-tmux.d.ts +23 -0
  452. package/dist/scripts/notify-hook/managed-tmux.d.ts.map +1 -1
  453. package/dist/scripts/notify-hook/managed-tmux.js +122 -12
  454. package/dist/scripts/notify-hook/managed-tmux.js.map +1 -1
  455. package/dist/scripts/notify-hook/orchestration-intent.d.ts +7 -7
  456. package/dist/scripts/notify-hook/orchestration-intent.d.ts.map +1 -1
  457. package/dist/scripts/notify-hook/ralph-session-resume.d.ts +5 -2
  458. package/dist/scripts/notify-hook/ralph-session-resume.d.ts.map +1 -1
  459. package/dist/scripts/notify-hook/ralph-session-resume.js +33 -37
  460. package/dist/scripts/notify-hook/ralph-session-resume.js.map +1 -1
  461. package/dist/scripts/notify-hook/state-io.d.ts +17 -0
  462. package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
  463. package/dist/scripts/notify-hook/state-io.js +73 -11
  464. package/dist/scripts/notify-hook/state-io.js.map +1 -1
  465. package/dist/scripts/notify-hook/team-dispatch.d.ts +1 -1
  466. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  467. package/dist/scripts/notify-hook/team-leader-nudge.d.ts +2 -2
  468. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +4 -4
  469. package/dist/scripts/notify-hook/team-worker-posttooluse.js.map +1 -1
  470. package/dist/scripts/notify-hook/team-worker-stop.d.ts +1 -1
  471. package/dist/scripts/notify-hook/team-worker.d.ts +2 -2
  472. package/dist/scripts/notify-hook/tmux-injection.d.ts +8 -1
  473. package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
  474. package/dist/scripts/notify-hook/tmux-injection.js +25 -16
  475. package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
  476. package/dist/scripts/notify-hook/visual-verdict.d.ts +1 -1
  477. package/dist/scripts/notify-hook/visual-verdict.d.ts.map +1 -1
  478. package/dist/scripts/notify-hook/visual-verdict.js +15 -13
  479. package/dist/scripts/notify-hook/visual-verdict.js.map +1 -1
  480. package/dist/scripts/notify-hook.d.ts +24 -1
  481. package/dist/scripts/notify-hook.d.ts.map +1 -1
  482. package/dist/scripts/notify-hook.js +266 -90
  483. package/dist/scripts/notify-hook.js.map +1 -1
  484. package/dist/scripts/postinstall.js.map +1 -1
  485. package/dist/scripts/prompt-inventory.js.map +1 -1
  486. package/dist/scripts/smoke-packed-install.d.ts +879 -2
  487. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  488. package/dist/scripts/smoke-packed-install.js +1528 -7
  489. package/dist/scripts/smoke-packed-install.js.map +1 -1
  490. package/dist/scripts/sync-plugin-mirror.js.map +1 -1
  491. package/dist/scripts/test-reply-listener-live.js.map +1 -1
  492. package/dist/scripts/test-sparkshell.js.map +1 -1
  493. package/dist/scripts/verify-native-agents.js.map +1 -1
  494. package/dist/session-history/friction.js.map +1 -1
  495. package/dist/session-history/search.js.map +1 -1
  496. package/dist/sidecar/__tests__/boundary.test.js.map +1 -1
  497. package/dist/sidecar/collector.js.map +1 -1
  498. package/dist/sidecar/index.js.map +1 -1
  499. package/dist/sidecar/render.js.map +1 -1
  500. package/dist/sidecar/tmux.js.map +1 -1
  501. package/dist/state/__tests__/operations.test.js +64 -15
  502. package/dist/state/__tests__/operations.test.js.map +1 -1
  503. package/dist/state/__tests__/skill-active.test.js +19 -1
  504. package/dist/state/__tests__/skill-active.test.js.map +1 -1
  505. package/dist/state/__tests__/workflow-transition.test.js +61 -0
  506. package/dist/state/__tests__/workflow-transition.test.js.map +1 -1
  507. package/dist/state/mode-state-context.js.map +1 -1
  508. package/dist/state/operations.d.ts +1 -1
  509. package/dist/state/operations.d.ts.map +1 -1
  510. package/dist/state/operations.js +49 -55
  511. package/dist/state/operations.js.map +1 -1
  512. package/dist/state/paths.d.ts +1 -1
  513. package/dist/state/paths.d.ts.map +1 -1
  514. package/dist/state/paths.js +1 -1
  515. package/dist/state/paths.js.map +1 -1
  516. package/dist/state/skill-active.d.ts +13 -1
  517. package/dist/state/skill-active.d.ts.map +1 -1
  518. package/dist/state/skill-active.js +43 -4
  519. package/dist/state/skill-active.js.map +1 -1
  520. package/dist/state/terminal-normalization.js.map +1 -1
  521. package/dist/state/workflow-transition-reconcile.d.ts.map +1 -1
  522. package/dist/state/workflow-transition-reconcile.js +20 -4
  523. package/dist/state/workflow-transition-reconcile.js.map +1 -1
  524. package/dist/state/workflow-transition.d.ts +1 -1
  525. package/dist/state/workflow-transition.d.ts.map +1 -1
  526. package/dist/state/workflow-transition.js.map +1 -1
  527. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts +2 -0
  528. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts.map +1 -0
  529. package/dist/subagents/__tests__/adapted-role-binding.test.js +988 -0
  530. package/dist/subagents/__tests__/adapted-role-binding.test.js.map +1 -0
  531. package/dist/subagents/__tests__/leader-bootstrap-3181.test.d.ts +2 -0
  532. package/dist/subagents/__tests__/leader-bootstrap-3181.test.d.ts.map +1 -0
  533. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +273 -0
  534. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -0
  535. package/dist/subagents/__tests__/tracker.test.js +1550 -10
  536. package/dist/subagents/__tests__/tracker.test.js.map +1 -1
  537. package/dist/subagents/adapted-role-binding.d.ts +17 -0
  538. package/dist/subagents/adapted-role-binding.d.ts.map +1 -0
  539. package/dist/subagents/adapted-role-binding.js +75 -0
  540. package/dist/subagents/adapted-role-binding.js.map +1 -0
  541. package/dist/subagents/role-routing-marker.d.ts +10 -0
  542. package/dist/subagents/role-routing-marker.d.ts.map +1 -0
  543. package/dist/subagents/role-routing-marker.js +136 -0
  544. package/dist/subagents/role-routing-marker.js.map +1 -0
  545. package/dist/subagents/tracker.d.ts +172 -0
  546. package/dist/subagents/tracker.d.ts.map +1 -1
  547. package/dist/subagents/tracker.js +1077 -61
  548. package/dist/subagents/tracker.js.map +1 -1
  549. package/dist/team/__tests__/approved-execution.test.js.map +1 -1
  550. package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
  551. package/dist/team/__tests__/model-contract.test.js +231 -1
  552. package/dist/team/__tests__/model-contract.test.js.map +1 -1
  553. package/dist/team/__tests__/role-router.test.js.map +1 -1
  554. package/dist/team/__tests__/runtime.test.js +401 -1
  555. package/dist/team/__tests__/runtime.test.js.map +1 -1
  556. package/dist/team/__tests__/scaling.test.js +181 -0
  557. package/dist/team/__tests__/scaling.test.js.map +1 -1
  558. package/dist/team/__tests__/state-root.test.js.map +1 -1
  559. package/dist/team/__tests__/tmux-session.test.js +434 -4
  560. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  561. package/dist/team/__tests__/tmux-test-fixture.js.map +1 -1
  562. package/dist/team/api-interop.d.ts +2 -2
  563. package/dist/team/api-interop.d.ts.map +1 -1
  564. package/dist/team/api-interop.js.map +1 -1
  565. package/dist/team/commit-hygiene.js.map +1 -1
  566. package/dist/team/contracts.d.ts +5 -5
  567. package/dist/team/contracts.d.ts.map +1 -1
  568. package/dist/team/delivery-log.js.map +1 -1
  569. package/dist/team/followup-planner.js.map +1 -1
  570. package/dist/team/goal-workflow.js.map +1 -1
  571. package/dist/team/idle-nudge.d.ts.map +1 -1
  572. package/dist/team/model-contract.d.ts +20 -1
  573. package/dist/team/model-contract.d.ts.map +1 -1
  574. package/dist/team/model-contract.js +267 -30
  575. package/dist/team/model-contract.js.map +1 -1
  576. package/dist/team/orchestrator.js.map +1 -1
  577. package/dist/team/pane-status.js.map +1 -1
  578. package/dist/team/phase-controller.js.map +1 -1
  579. package/dist/team/reminder-intents.d.ts +1 -1
  580. package/dist/team/reminder-intents.d.ts.map +1 -1
  581. package/dist/team/reminder-intents.js.map +1 -1
  582. package/dist/team/runtime-cli.js.map +1 -1
  583. package/dist/team/runtime.d.ts.map +1 -1
  584. package/dist/team/runtime.js +83 -52
  585. package/dist/team/runtime.js.map +1 -1
  586. package/dist/team/scaling.d.ts +9 -7
  587. package/dist/team/scaling.d.ts.map +1 -1
  588. package/dist/team/scaling.js +69 -28
  589. package/dist/team/scaling.js.map +1 -1
  590. package/dist/team/state/dispatch-lock.js.map +1 -1
  591. package/dist/team/state/dispatch.js.map +1 -1
  592. package/dist/team/state/events.js.map +1 -1
  593. package/dist/team/state/tasks.js.map +1 -1
  594. package/dist/team/state-root.js.map +1 -1
  595. package/dist/team/state.js.map +1 -1
  596. package/dist/team/team-identity.d.ts.map +1 -1
  597. package/dist/team/team-identity.js.map +1 -1
  598. package/dist/team/tmux-session.d.ts +2 -0
  599. package/dist/team/tmux-session.d.ts.map +1 -1
  600. package/dist/team/tmux-session.js +90 -42
  601. package/dist/team/tmux-session.js.map +1 -1
  602. package/dist/team/worker-bootstrap.js.map +1 -1
  603. package/dist/team/worktree.js.map +1 -1
  604. package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
  605. package/dist/ultragoal/__tests__/docs-contract.test.js.map +1 -1
  606. package/dist/ultragoal/artifacts.d.ts.map +1 -1
  607. package/dist/ultragoal/artifacts.js +4 -1
  608. package/dist/ultragoal/artifacts.js.map +1 -1
  609. package/dist/url-reader/index.js.map +1 -1
  610. package/dist/utils/__tests__/agents-md.test.js +16 -0
  611. package/dist/utils/__tests__/agents-md.test.js.map +1 -1
  612. package/dist/utils/agents-md.d.ts.map +1 -1
  613. package/dist/utils/agents-md.js +4 -3
  614. package/dist/utils/agents-md.js.map +1 -1
  615. package/dist/utils/agents-model-table.js.map +1 -1
  616. package/dist/utils/package.js.map +1 -1
  617. package/dist/utils/paths.js.map +1 -1
  618. package/dist/utils/platform-command.js.map +1 -1
  619. package/dist/utils/repo-deps.js.map +1 -1
  620. package/dist/utils/version.js.map +1 -1
  621. package/dist/verification/__tests__/ci-rust-gates.test.js +1 -1
  622. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js.map +1 -1
  623. package/dist/verification/verifier.js.map +1 -1
  624. package/dist/visual/constants.d.ts +1 -1
  625. package/dist/visual/constants.d.ts.map +1 -1
  626. package/dist/vscode/__tests__/index.test.js.map +1 -1
  627. package/dist/vscode/index.js.map +1 -1
  628. package/dist/wiki/__tests__/cjk-tokenize.test.js.map +1 -1
  629. package/dist/wiki/__tests__/lint.test.js.map +1 -1
  630. package/dist/wiki/__tests__/query.test.js.map +1 -1
  631. package/dist/wiki/__tests__/storage.test.js.map +1 -1
  632. package/dist/wiki/__tests__/test-helpers.d.ts.map +1 -1
  633. package/dist/wiki/lint.js.map +1 -1
  634. package/dist/wiki/query.js.map +1 -1
  635. package/dist/wiki/storage.js.map +1 -1
  636. package/package.json +2 -2
  637. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  638. package/plugins/oh-my-codex/skills/omx-setup/SKILL.md +10 -5
  639. package/plugins/oh-my-codex/skills/ralph/SKILL.md +4 -2
  640. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +5 -2
  641. package/skills/omx-setup/SKILL.md +10 -5
  642. package/skills/ralph/SKILL.md +4 -2
  643. package/skills/ralplan/SKILL.md +5 -2
  644. package/src/scripts/__tests__/codex-native-hook.test.ts +20497 -12377
  645. package/src/scripts/__tests__/fixtures/issue-3138-installed-smoke.ts +734 -0
  646. package/src/scripts/__tests__/notify-fallback-delivery.test.ts +177 -0
  647. package/src/scripts/__tests__/notify-state-io.test.ts +73 -0
  648. package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +215 -0
  649. package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +193 -0
  650. package/src/scripts/__tests__/smoke-packed-install.test.ts +991 -4
  651. package/src/scripts/codex-native-hook.ts +918 -172
  652. package/src/scripts/notify-fallback-delivery.ts +416 -0
  653. package/src/scripts/notify-fallback-watcher.ts +132 -40
  654. package/src/scripts/notify-hook/auto-nudge.ts +43 -12
  655. package/src/scripts/notify-hook/managed-tmux.ts +149 -12
  656. package/src/scripts/notify-hook/ralph-session-resume.ts +37 -36
  657. package/src/scripts/notify-hook/state-io.ts +100 -13
  658. package/src/scripts/notify-hook/tmux-injection.ts +38 -21
  659. package/src/scripts/notify-hook/visual-verdict.ts +22 -12
  660. package/src/scripts/notify-hook.ts +335 -90
  661. package/src/scripts/smoke-packed-install.ts +1829 -6
@@ -1,12 +1,14 @@
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';
11
+ import TOML from '@iarna/toml';
10
12
  function runOmx(cwd, argv, envOverrides = {}) {
11
13
  const testDir = dirname(fileURLToPath(import.meta.url));
12
14
  const repoRoot = join(testDir, '..', '..', '..');
@@ -29,7 +31,45 @@ function runOmx(cwd, argv, envOverrides = {}) {
29
31
  };
30
32
  }
31
33
  function shouldSkipForSpawnPermissions(err) {
32
- 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)), '..', '..', '..');
33
73
  }
34
74
  /** Build a realistic OMX config.toml for testing */
35
75
  function buildOmxConfig() {
@@ -186,6 +226,13 @@ function buildMixedConfig() {
186
226
  'command = "custom"',
187
227
  'args = ["--flag"]',
188
228
  '',
229
+ '[agents]',
230
+ 'max_threads = 17',
231
+ 'max_depth = 5',
232
+ '',
233
+ '[agents.custom_role]',
234
+ 'description = "keep me"',
235
+ '',
189
236
  '# ============================================================',
190
237
  '# oh-my-codex (OMX) Configuration',
191
238
  '# Managed by omx setup - manual edits preserved on next setup',
@@ -228,6 +275,38 @@ function buildMixedConfig() {
228
275
  '',
229
276
  ].join('\n');
230
277
  }
278
+ function buildAmbiguousMultiAgentConfig(marker, variant) {
279
+ return [
280
+ `sentinel = "${marker}"`,
281
+ '',
282
+ '[features]',
283
+ `multi_agent = ${variant.multiAgent}`,
284
+ 'child_agents_md = true',
285
+ 'goals = true',
286
+ 'custom_feature = true',
287
+ '',
288
+ '[agents]',
289
+ `max_threads = ${variant.maxThreads}`,
290
+ `max_depth = ${variant.maxDepth}`,
291
+ '',
292
+ '[agents.custom_role]',
293
+ `description = "${marker}"`,
294
+ '',
295
+ '# ============================================================',
296
+ '# oh-my-codex (OMX) Configuration',
297
+ '# Managed by omx setup - manual edits preserved on next setup',
298
+ '# ============================================================',
299
+ '',
300
+ '[mcp_servers.omx_state]',
301
+ 'command = "node"',
302
+ 'args = ["/path/to/state-server.js"]',
303
+ 'enabled = true',
304
+ '',
305
+ '# ============================================================',
306
+ '# End oh-my-codex',
307
+ '',
308
+ ].join('\n');
309
+ }
231
310
  describe('omx uninstall', () => {
232
311
  it('removes OMX block from config.toml with --dry-run', async () => {
233
312
  const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-'));
@@ -279,7 +358,7 @@ describe('omx uninstall', () => {
279
358
  assert.doesNotMatch(config, /notify\s*=/);
280
359
  assert.doesNotMatch(config, /model_reasoning_effort\s*=/);
281
360
  assert.doesNotMatch(config, /developer_instructions\s*=/);
282
- assert.doesNotMatch(config, /multi_agent\s*=/);
361
+ assert.match(config, /multi_agent\s*=/);
283
362
  assert.doesNotMatch(config, /child_agents_md\s*=/);
284
363
  assert.doesNotMatch(config, /^hooks\s*=/m);
285
364
  assert.equal(existsSync(join(codexDir, 'hooks.json')), false);
@@ -318,6 +397,8 @@ describe('omx uninstall', () => {
318
397
  managedBy: 'oh-my-codex',
319
398
  version: 1,
320
399
  previousNotify: ['node', staleDispatcher, '--metadata', metadataPath],
400
+ omxNotify: ['node', join(stalePkgRoot, 'dist', 'scripts', 'notify-hook.js')],
401
+ dispatcherNotify: ['node', staleDispatcher, '--metadata', metadataPath],
321
402
  }));
322
403
  const res = runOmx(wd, ['uninstall'], { HOME: home });
323
404
  if (shouldSkipForSpawnPermissions(res.error))
@@ -349,11 +430,17 @@ describe('omx uninstall', () => {
349
430
  assert.match(config, /model = "o4-mini"/);
350
431
  assert.match(config, /\[mcp_servers\.user_custom\]/);
351
432
  assert.match(config, /web_search = true/);
433
+ assert.match(config, /^multi_agent = true$/m);
434
+ assert.match(config, /^\[agents\]$/m);
435
+ assert.match(config, /^max_threads = 17$/m);
436
+ assert.match(config, /^max_depth = 5$/m);
437
+ assert.match(config, /^\[agents\.custom_role\]$/m);
438
+ assert.match(config, /^description = "keep me"$/m);
352
439
  // OMX entries removed
353
440
  assert.doesNotMatch(config, /omx_state/);
354
441
  assert.doesNotMatch(config, /omx_memory/);
355
442
  assert.doesNotMatch(config, /notify\s*=.*node/);
356
- assert.doesNotMatch(config, /multi_agent/);
443
+ assert.match(config, /multi_agent\s*=/);
357
444
  assert.doesNotMatch(config, /child_agents_md/);
358
445
  assert.doesNotMatch(config, /^hooks\s*=/m);
359
446
  }
@@ -361,41 +448,1493 @@ describe('omx uninstall', () => {
361
448
  await rm(wd, { recursive: true, force: true });
362
449
  }
363
450
  });
364
- it('preserves user hooks while removing OMX-managed wrappers', async () => {
365
- 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-'));
366
453
  try {
367
454
  const home = join(wd, 'home');
368
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';
369
467
  await mkdir(codexDir, { recursive: true });
370
- await writeFile(join(codexDir, 'config.toml'), buildOmxConfig().replace(/^hooks = true$/m, 'codex_hooks = true'));
371
- await writeFile(join(codexDir, 'hooks.json'), JSON.stringify({
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-'));
484
+ try {
485
+ const home = join(wd, 'home');
486
+ const codexDir = join(home, '.codex');
487
+ const configPath = join(codexDir, 'config.toml');
488
+ const hooksPath = join(codexDir, 'hooks.json');
489
+ await mkdir(codexDir, { recursive: true });
490
+ await writeFile(configPath, buildOmxConfig().replace(/^hooks = true$/m, 'codex_hooks = true'));
491
+ await writeFile(hooksPath, JSON.stringify({
372
492
  hooks: {
373
493
  SessionStart: [
374
- {
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',
375
557
  hooks: [
376
558
  { type: 'command', command: 'node "/repo/dist/scripts/codex-native-hook.js"' },
377
559
  { type: 'command', command: 'echo keep-me' },
378
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 }] }],
379
920
  },
380
- ],
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' },
1816
+ },
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
+ }],
381
1832
  },
382
- version: 1,
383
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);
384
1881
  const res = runOmx(wd, ['uninstall'], { HOME: home });
385
1882
  if (shouldSkipForSpawnPermissions(res.error))
386
1883
  return;
387
1884
  assert.equal(res.status, 0, res.stderr || res.stdout);
388
- assert.equal(existsSync(join(codexDir, 'hooks.json')), true);
389
- const hooks = await readFile(join(codexDir, 'hooks.json'), 'utf-8');
390
- assert.match(hooks, /echo keep-me/);
391
- assert.match(hooks, /"version": 1/);
392
- assert.doesNotMatch(hooks, /codex-native-hook\.js/);
393
- const config = await readFile(join(codexDir, 'config.toml'), 'utf-8');
394
- assert.match(config, /^hooks = true$/m, 'preserved user hooks should keep the canonical Codex hooks feature enabled');
395
- assert.doesNotMatch(config, /^codex_hooks\s*=/m, 'legacy Codex hook aliases should be normalized during uninstall preservation');
396
- assert.doesNotMatch(config, /^multi_agent\s*=/m);
397
- assert.doesNotMatch(config, /^child_agents_md\s*=/m);
398
- 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);
399
1938
  }
400
1939
  finally {
401
1940
  await rm(wd, { recursive: true, force: true });
@@ -411,10 +1950,11 @@ describe('omx uninstall', () => {
411
1950
  await writeFile(join(codexDir, 'hooks.json'), JSON.stringify({
412
1951
  hooks: {
413
1952
  SessionStart: [
1953
+ { hooks: [{ type: 'command', command: 'echo keep-me' }] },
414
1954
  {
1955
+ matcher: 'startup|resume|clear',
415
1956
  hooks: [
416
1957
  { type: 'command', command: 'node "/repo/dist/scripts/codex-native-hook.js"' },
417
- { type: 'command', command: 'echo keep-me' },
418
1958
  ],
419
1959
  },
420
1960
  ],
@@ -450,6 +1990,7 @@ describe('omx uninstall', () => {
450
1990
  assert.doesNotMatch(config, /^model_context_window = 250000$/m);
451
1991
  assert.doesNotMatch(config, /^model_auto_compact_token_limit = 200000$/m);
452
1992
  assert.doesNotMatch(config, /seeded behavioral defaults/);
1993
+ assert.match(config, /^multi_agent = true$/m);
453
1994
  assert.doesNotMatch(config, /notify\s*=/);
454
1995
  assert.doesNotMatch(config, /model_reasoning_effort\s*=/);
455
1996
  assert.doesNotMatch(config, /developer_instructions\s*=/);
@@ -475,6 +2016,7 @@ describe('omx uninstall', () => {
475
2016
  assert.match(config, /^model_context_window = 123456$/m);
476
2017
  assert.match(config, /^model_auto_compact_token_limit = 200000$/m);
477
2018
  assert.doesNotMatch(config, /seeded behavioral defaults/);
2019
+ assert.match(config, /^multi_agent = true$/m);
478
2020
  assert.doesNotMatch(config, /notify\s*=/);
479
2021
  assert.doesNotMatch(config, /model_reasoning_effort\s*=/);
480
2022
  assert.doesNotMatch(config, /developer_instructions\s*=/);
@@ -527,6 +2069,56 @@ describe('omx uninstall', () => {
527
2069
  await rm(wd, { recursive: true, force: true });
528
2070
  }
529
2071
  });
2072
+ for (const scope of ['user', 'project']) {
2073
+ for (const variant of [
2074
+ { name: 'legacy-looking', multiAgent: true, maxThreads: 6, maxDepth: 2 },
2075
+ { name: 'custom', multiAgent: false, maxThreads: 17, maxDepth: 5 },
2076
+ ]) {
2077
+ it(`preserves ambiguous multi-agent ownership in ${scope} scope (${variant.name})`, async () => {
2078
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-ownership-'));
2079
+ try {
2080
+ const home = join(wd, 'home');
2081
+ const userConfigPath = join(home, '.codex', 'config.toml');
2082
+ const projectConfigPath = join(wd, '.codex', 'config.toml');
2083
+ const setupStateDir = join(wd, '.omx');
2084
+ await mkdir(join(home, '.codex'), { recursive: true });
2085
+ await mkdir(join(wd, '.codex'), { recursive: true });
2086
+ await mkdir(setupStateDir, { recursive: true });
2087
+ const userConfig = buildAmbiguousMultiAgentConfig('user-config', variant);
2088
+ const projectConfig = buildAmbiguousMultiAgentConfig('project-config', variant);
2089
+ await writeFile(userConfigPath, userConfig);
2090
+ await writeFile(projectConfigPath, projectConfig);
2091
+ await writeFile(join(setupStateDir, 'setup-scope.json'), `${JSON.stringify({ scope })}\n`);
2092
+ const selectedPath = scope === 'user' ? userConfigPath : projectConfigPath;
2093
+ const unselectedPath = scope === 'user' ? projectConfigPath : userConfigPath;
2094
+ const unselectedBefore = await readFile(unselectedPath, 'utf-8');
2095
+ const res = runOmx(wd, ['uninstall'], { HOME: home });
2096
+ if (shouldSkipForSpawnPermissions(res.error))
2097
+ return;
2098
+ assert.equal(res.status, 0, res.stderr || res.stdout);
2099
+ assert.match(res.stdout, new RegExp(`Resolved scope: ${scope}`));
2100
+ const selectedAfter = await readFile(selectedPath, 'utf-8');
2101
+ const parsed = TOML.parse(selectedAfter);
2102
+ assert.equal(parsed.sentinel, `${scope}-config`);
2103
+ assert.equal(parsed.features?.multi_agent, variant.multiAgent);
2104
+ assert.equal(parsed.features?.custom_feature, true);
2105
+ assert.equal(parsed.features?.child_agents_md, undefined);
2106
+ assert.equal(parsed.features?.goals, undefined);
2107
+ assert.equal(parsed.agents?.max_threads, variant.maxThreads);
2108
+ assert.equal(parsed.agents?.max_depth, variant.maxDepth);
2109
+ assert.deepEqual(parsed.agents?.custom_role, {
2110
+ description: `${scope}-config`,
2111
+ });
2112
+ assert.doesNotMatch(selectedAfter, /mcp_servers\.omx_state/);
2113
+ assert.doesNotMatch(selectedAfter, /oh-my-codex \(OMX\) Configuration/);
2114
+ assert.equal(await readFile(unselectedPath, 'utf-8'), unselectedBefore, 'unselected scope config must remain byte-identical');
2115
+ }
2116
+ finally {
2117
+ await rm(wd, { recursive: true, force: true });
2118
+ }
2119
+ });
2120
+ }
2121
+ }
530
2122
  it('works with project scope', async () => {
531
2123
  const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-'));
532
2124
  try {
@@ -768,6 +2360,25 @@ describe('omx uninstall', () => {
768
2360
  await rm(wd, { recursive: true, force: true });
769
2361
  }
770
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
+ });
771
2382
  it('removes managed user-scope AGENTS.md from CODEX_HOME', async () => {
772
2383
  const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-'));
773
2384
  try {
@@ -816,6 +2427,420 @@ describe('omx uninstall', () => {
816
2427
  await rm(wd, { recursive: true, force: true });
817
2428
  }
818
2429
  });
2430
+ it('removes exact marked pairs and authorized singleton blocks during uninstall', async () => {
2431
+ const cases = [
2432
+ { lines: ['# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_context_window = 250000', 'model_auto_compact_token_limit = 200000', '# End oh-my-codex seeded behavioral defaults'], removed: ['model_context_window', 'model_auto_compact_token_limit'], preserved: {} },
2433
+ { lines: ['model_auto_compact_token_limit=777', '# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_context_window = 250000', '# End oh-my-codex seeded behavioral defaults'], removed: ['model_context_window'], preserved: { model_auto_compact_token_limit: 777 } },
2434
+ { lines: ['model_context_window = 123456', '# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_auto_compact_token_limit = 200000', '# End oh-my-codex seeded behavioral defaults'], removed: ['model_auto_compact_token_limit'], preserved: { model_context_window: 123456 } },
2435
+ ];
2436
+ for (const fixture of cases) {
2437
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-defaults-'));
2438
+ try {
2439
+ const home = join(wd, 'home');
2440
+ const configPath = join(home, '.codex', 'config.toml');
2441
+ await mkdir(dirname(configPath), { recursive: true });
2442
+ await writeFile(configPath, `${fixture.lines.join('\n')}\n`);
2443
+ const res = runOmx(wd, ['uninstall'], { HOME: home });
2444
+ if (shouldSkipForSpawnPermissions(res.error))
2445
+ return;
2446
+ assert.equal(res.status, 0, res.stderr || res.stdout);
2447
+ const config = await readFile(configPath, 'utf-8');
2448
+ const parsed = TOML.parse(config);
2449
+ assert.doesNotMatch(config, /seeded behavioral defaults/);
2450
+ for (const key of fixture.removed)
2451
+ assert.equal(parsed[key], undefined);
2452
+ for (const [key, value] of Object.entries(fixture.preserved))
2453
+ assert.equal(parsed[key], value);
2454
+ }
2455
+ finally {
2456
+ await rm(wd, { recursive: true, force: true });
2457
+ }
2458
+ }
2459
+ });
2460
+ it('preserves unmarked and edited values, retaining only ambiguous ownership markers', async () => {
2461
+ const cases = [
2462
+ {
2463
+ lines: ['model_context_window = 250000', 'model_auto_compact_token_limit = 200000', '[user_table]', 'label = "unmarked"'],
2464
+ preservedLines: ['model_context_window = 250000', 'model_auto_compact_token_limit = 200000', '[user_table]', 'label = "unmarked"'],
2465
+ markers: false,
2466
+ },
2467
+ {
2468
+ lines: ['# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_context_window = 123456', 'model_auto_compact_token_limit = 200000', '# End oh-my-codex seeded behavioral defaults', '[user_table]', 'label = "edited"'],
2469
+ preservedLines: ['model_context_window = 123456', 'model_auto_compact_token_limit = 200000', '[user_table]', 'label = "edited"'],
2470
+ markers: false,
2471
+ },
2472
+ {
2473
+ lines: ['model_auto_compact_token_limit = 1', 'model_auto_compact_token_limit = 2', '# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_context_window = 250000', '# End oh-my-codex seeded behavioral defaults', '[user_table]', 'label = "ambiguous"'],
2474
+ preservedLines: ['model_auto_compact_token_limit = 1', 'model_auto_compact_token_limit = 2', 'model_context_window = 250000', '[user_table]', 'label = "ambiguous"'],
2475
+ markers: true,
2476
+ },
2477
+ {
2478
+ lines: ['model_context_window = 999', '# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_context_window = 250000', 'model_auto_compact_token_limit = 200000', '# End oh-my-codex seeded behavioral defaults', '[user_table]', 'label = "pair-duplicate-before"'],
2479
+ preservedLines: ['model_context_window = 999', 'model_context_window = 250000', 'model_auto_compact_token_limit = 200000', '[user_table]', 'label = "pair-duplicate-before"'],
2480
+ markers: true,
2481
+ },
2482
+ {
2483
+ lines: ['# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_context_window = 250000', 'model_auto_compact_token_limit = 200000', '# End oh-my-codex seeded behavioral defaults', 'model_auto_compact_token_limit = 999', '[user_table]', 'label = "pair-duplicate-after"'],
2484
+ preservedLines: ['model_context_window = 250000', 'model_auto_compact_token_limit = 200000', 'model_auto_compact_token_limit = 999', '[user_table]', 'label = "pair-duplicate-after"'],
2485
+ markers: true,
2486
+ },
2487
+ {
2488
+ lines: ['# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_context_window = 250000', '# End oh-my-codex seeded behavioral defaults', '[user_table]', 'label = "after-table"', 'model_auto_compact_token_limit = 777'],
2489
+ preservedLines: ['model_context_window = 250000', '[user_table]', 'label = "after-table"', 'model_auto_compact_token_limit = 777'],
2490
+ markers: false,
2491
+ },
2492
+ ];
2493
+ for (const fixture of cases) {
2494
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-defaults-'));
2495
+ try {
2496
+ const home = join(wd, 'home');
2497
+ const configPath = join(home, '.codex', 'config.toml');
2498
+ await mkdir(dirname(configPath), { recursive: true });
2499
+ await writeFile(configPath, `${fixture.lines.join('\n')}\n`);
2500
+ const res = runOmx(wd, ['uninstall'], { HOME: home });
2501
+ if (shouldSkipForSpawnPermissions(res.error))
2502
+ return;
2503
+ assert.equal(res.status, 0, res.stderr || res.stdout);
2504
+ const config = await readFile(configPath, 'utf-8');
2505
+ for (const line of fixture.preservedLines)
2506
+ assert.ok(config.includes(line), `missing preserved line: ${line}`);
2507
+ fixture.markers ? assert.match(config, /seeded behavioral defaults/) : assert.doesNotMatch(config, /seeded behavioral defaults/);
2508
+ }
2509
+ finally {
2510
+ await rm(wd, { recursive: true, force: true });
2511
+ }
2512
+ }
2513
+ });
2514
+ it('does not write during dry-run and is a fixed point after legacy-default cleanup', async () => {
2515
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-defaults-'));
2516
+ try {
2517
+ const home = join(wd, 'home');
2518
+ const configPath = join(home, '.codex', 'config.toml');
2519
+ const original = ['# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_context_window = 250000', 'model_auto_compact_token_limit = 200000', '# End oh-my-codex seeded behavioral defaults', ''].join('\n');
2520
+ await mkdir(dirname(configPath), { recursive: true });
2521
+ await writeFile(configPath, original);
2522
+ const dryRun = runOmx(wd, ['uninstall', '--dry-run'], { HOME: home });
2523
+ if (shouldSkipForSpawnPermissions(dryRun.error))
2524
+ return;
2525
+ assert.equal(dryRun.status, 0, dryRun.stderr || dryRun.stdout);
2526
+ assert.equal(await readFile(configPath, 'utf-8'), original);
2527
+ const first = runOmx(wd, ['uninstall'], { HOME: home });
2528
+ assert.equal(first.status, 0, first.stderr || first.stdout);
2529
+ const cleaned = await readFile(configPath, 'utf-8');
2530
+ const second = runOmx(wd, ['uninstall'], { HOME: home });
2531
+ assert.equal(second.status, 0, second.stderr || second.stdout);
2532
+ assert.equal(await readFile(configPath, 'utf-8'), cleaned);
2533
+ }
2534
+ finally {
2535
+ await rm(wd, { recursive: true, force: true });
2536
+ }
2537
+ });
2538
+ it('keeps singleton and bounded cleanup dry-run-safe, differential, and idempotent', async () => {
2539
+ const start = '# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)';
2540
+ const end = '# End oh-my-codex seeded behavioral defaults';
2541
+ const fixtures = [
2542
+ {
2543
+ baseline: ['model_auto_compact_token_limit = 777', '[user_table]', 'label = "context-singleton"', ''].join('\n'),
2544
+ migrated: ['model_auto_compact_token_limit = 777', start, 'model_context_window = 250000', end, '[user_table]', 'label = "context-singleton"', ''].join('\n'),
2545
+ },
2546
+ {
2547
+ baseline: ['model_context_window = [', ' 123456,', ']', '[user_table]', 'label = "auto-singleton"', ''].join('\n'),
2548
+ migrated: ['model_context_window = [', ' 123456,', ']', start, 'model_auto_compact_token_limit = 200000', end, '[user_table]', 'label = "auto-singleton"', ''].join('\n'),
2549
+ },
2550
+ {
2551
+ baseline: ['before = "keep"', 'model_context_window = 123456', '# interior comment', '[user_table]', 'label = "bounded"', ''].join('\n'),
2552
+ migrated: ['before = "keep"', start, 'model_context_window = 123456', '# interior comment', end, '[user_table]', 'label = "bounded"', ''].join('\n'),
2553
+ },
2554
+ ];
2555
+ for (const fixture of fixtures) {
2556
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-defaults-'));
2557
+ try {
2558
+ const baselineHome = join(wd, 'baseline-home');
2559
+ const migratedHome = join(wd, 'migrated-home');
2560
+ const baselinePath = join(baselineHome, '.codex', 'config.toml');
2561
+ const migratedPath = join(migratedHome, '.codex', 'config.toml');
2562
+ await mkdir(dirname(baselinePath), { recursive: true });
2563
+ await mkdir(dirname(migratedPath), { recursive: true });
2564
+ await writeFile(baselinePath, fixture.baseline);
2565
+ await writeFile(migratedPath, fixture.migrated);
2566
+ const dryRun = runOmx(wd, ['uninstall', '--dry-run'], { HOME: migratedHome });
2567
+ if (shouldSkipForSpawnPermissions(dryRun.error))
2568
+ return;
2569
+ assert.equal(dryRun.status, 0, dryRun.stderr || dryRun.stdout);
2570
+ assert.equal(await readFile(migratedPath, 'utf-8'), fixture.migrated);
2571
+ const baselineRun = runOmx(wd, ['uninstall'], { HOME: baselineHome });
2572
+ assert.equal(baselineRun.status, 0, baselineRun.stderr || baselineRun.stdout);
2573
+ const migratedRun = runOmx(wd, ['uninstall'], { HOME: migratedHome });
2574
+ assert.equal(migratedRun.status, 0, migratedRun.stderr || migratedRun.stdout);
2575
+ const cleaned = await readFile(migratedPath, 'utf-8');
2576
+ assert.equal(cleaned, await readFile(baselinePath, 'utf-8'));
2577
+ const repeated = runOmx(wd, ['uninstall'], { HOME: migratedHome });
2578
+ assert.equal(repeated.status, 0, repeated.stderr || repeated.stdout);
2579
+ assert.equal(await readFile(migratedPath, 'utf-8'), cleaned);
2580
+ }
2581
+ finally {
2582
+ await rm(wd, { recursive: true, force: true });
2583
+ }
2584
+ }
2585
+ });
2586
+ it('preserves existing uninstall-pipeline semantics after removing a marked pair', async () => {
2587
+ const wd = await mkdtemp(join(tmpdir(), 'omx-uninstall-defaults-'));
2588
+ try {
2589
+ const baselineHome = join(wd, 'baseline-home');
2590
+ const migratedHome = join(wd, 'migrated-home');
2591
+ const baselinePath = join(baselineHome, '.codex', 'config.toml');
2592
+ const migratedPath = join(migratedHome, '.codex', 'config.toml');
2593
+ const baseline = buildMixedConfig();
2594
+ await mkdir(dirname(baselinePath), { recursive: true });
2595
+ await mkdir(dirname(migratedPath), { recursive: true });
2596
+ await writeFile(baselinePath, baseline);
2597
+ await writeFile(migratedPath, baseline.replace('model = "o4-mini"', ['model = "o4-mini"', '# oh-my-codex seeded behavioral defaults (uninstall removes unchanged defaults)', 'model_context_window = 250000', 'model_auto_compact_token_limit = 200000', '# End oh-my-codex seeded behavioral defaults'].join('\n')));
2598
+ const baselineRun = runOmx(wd, ['uninstall'], { HOME: baselineHome });
2599
+ if (shouldSkipForSpawnPermissions(baselineRun.error))
2600
+ return;
2601
+ assert.equal(baselineRun.status, 0, baselineRun.stderr || baselineRun.stdout);
2602
+ const migratedRun = runOmx(wd, ['uninstall'], { HOME: migratedHome });
2603
+ assert.equal(migratedRun.status, 0, migratedRun.stderr || migratedRun.stdout);
2604
+ assert.deepEqual(TOML.parse(await readFile(migratedPath, 'utf-8')), TOML.parse(await readFile(baselinePath, 'utf-8')));
2605
+ }
2606
+ finally {
2607
+ await rm(wd, { recursive: true, force: true });
2608
+ }
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
+ });
819
2844
  });
820
2845
  describe('stripOmxFeatureFlags', () => {
821
2846
  it('removes OMX feature flags and preserves user flags', async () => {
@@ -861,5 +2886,21 @@ describe('stripOmxFeatureFlags', () => {
861
2886
  const result = stripOmxFeatureFlags(config);
862
2887
  assert.equal(result, config);
863
2888
  });
2889
+ it('preserves multi_agent when explicitly requested', async () => {
2890
+ const { stripOmxFeatureFlags } = await import('../../config/generator.js');
2891
+ const config = [
2892
+ '[features]',
2893
+ 'multi_agent = false',
2894
+ 'child_agents_md = true',
2895
+ 'goals = true',
2896
+ 'web_search = true',
2897
+ '',
2898
+ ].join('\n');
2899
+ const result = stripOmxFeatureFlags(config, { preserveMultiAgent: true });
2900
+ assert.match(result, /^multi_agent = false$/m);
2901
+ assert.doesNotMatch(result, /^child_agents_md\s*=/m);
2902
+ assert.doesNotMatch(result, /^goals\s*=/m);
2903
+ assert.match(result, /^web_search = true$/m);
2904
+ });
864
2905
  });
865
2906
  //# sourceMappingURL=uninstall.test.js.map