@raidiant/notifai 11.7.1 → 12.0.0-beta.13

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 (409) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/NOTICE +16 -0
  3. package/THIRD_PARTY_NOTICES +725 -0
  4. package/bin/notifai.mjs +11420 -0
  5. package/data/install.ps1 +318 -0
  6. package/data/release.json +1 -0
  7. package/inventory.json +1 -0
  8. package/npm-adapter-files.json +52 -0
  9. package/package.json +14 -44
  10. package/README.md +0 -168
  11. package/dist/agent-update-notice.d.ts +0 -7
  12. package/dist/agent-update-notice.js +0 -77
  13. package/dist/agent-update-notice.js.map +0 -1
  14. package/dist/atomic-file.d.ts +0 -30
  15. package/dist/atomic-file.js +0 -148
  16. package/dist/atomic-file.js.map +0 -1
  17. package/dist/attendant-update.d.ts +0 -15
  18. package/dist/attendant-update.js +0 -96
  19. package/dist/attendant-update.js.map +0 -1
  20. package/dist/claude-wake.d.ts +0 -108
  21. package/dist/claude-wake.js +0 -369
  22. package/dist/claude-wake.js.map +0 -1
  23. package/dist/cli-bin.d.ts +0 -26
  24. package/dist/cli-bin.js +0 -220
  25. package/dist/cli-bin.js.map +0 -1
  26. package/dist/cli-contract.d.ts +0 -16
  27. package/dist/cli-contract.js +0 -27
  28. package/dist/cli-contract.js.map +0 -1
  29. package/dist/cli-release.d.ts +0 -36
  30. package/dist/cli-release.js +0 -85
  31. package/dist/cli-release.js.map +0 -1
  32. package/dist/client.d.ts +0 -109
  33. package/dist/client.js +0 -217
  34. package/dist/client.js.map +0 -1
  35. package/dist/codex-answer-control.d.ts +0 -14
  36. package/dist/codex-answer-control.js +0 -41
  37. package/dist/codex-answer-control.js.map +0 -1
  38. package/dist/codex-answer-presentation.d.ts +0 -18
  39. package/dist/codex-answer-presentation.js +0 -113
  40. package/dist/codex-answer-presentation.js.map +0 -1
  41. package/dist/codex-input-lifecycle.d.ts +0 -10
  42. package/dist/codex-input-lifecycle.js +0 -63
  43. package/dist/codex-input-lifecycle.js.map +0 -1
  44. package/dist/codex-native-control.d.ts +0 -13
  45. package/dist/codex-native-control.js +0 -120
  46. package/dist/codex-native-control.js.map +0 -1
  47. package/dist/codex-native-turn.d.ts +0 -37
  48. package/dist/codex-native-turn.js +0 -155
  49. package/dist/codex-native-turn.js.map +0 -1
  50. package/dist/codex-question-bindings.d.ts +0 -76
  51. package/dist/codex-question-bindings.js +0 -151
  52. package/dist/codex-question-bindings.js.map +0 -1
  53. package/dist/codex-queue-control.d.ts +0 -21
  54. package/dist/codex-queue-control.js +0 -59
  55. package/dist/codex-queue-control.js.map +0 -1
  56. package/dist/codex-tool-messages.d.ts +0 -7
  57. package/dist/codex-tool-messages.js +0 -126
  58. package/dist/codex-tool-messages.js.map +0 -1
  59. package/dist/codex-wake.d.ts +0 -58
  60. package/dist/codex-wake.js +0 -135
  61. package/dist/codex-wake.js.map +0 -1
  62. package/dist/command-session.d.ts +0 -14
  63. package/dist/command-session.js +0 -31
  64. package/dist/command-session.js.map +0 -1
  65. package/dist/commands-acknowledge.d.ts +0 -13
  66. package/dist/commands-acknowledge.js +0 -116
  67. package/dist/commands-acknowledge.js.map +0 -1
  68. package/dist/commands-agent-sessions.d.ts +0 -6
  69. package/dist/commands-agent-sessions.js +0 -51
  70. package/dist/commands-agent-sessions.js.map +0 -1
  71. package/dist/commands-ask.d.ts +0 -62
  72. package/dist/commands-ask.js +0 -503
  73. package/dist/commands-ask.js.map +0 -1
  74. package/dist/commands-auth.d.ts +0 -76
  75. package/dist/commands-auth.js +0 -421
  76. package/dist/commands-auth.js.map +0 -1
  77. package/dist/commands-close.d.ts +0 -6
  78. package/dist/commands-close.js +0 -273
  79. package/dist/commands-close.js.map +0 -1
  80. package/dist/commands-config.d.ts +0 -31
  81. package/dist/commands-config.js +0 -272
  82. package/dist/commands-config.js.map +0 -1
  83. package/dist/commands-core.d.ts +0 -145
  84. package/dist/commands-core.js +0 -190
  85. package/dist/commands-core.js.map +0 -1
  86. package/dist/commands-devices.d.ts +0 -41
  87. package/dist/commands-devices.js +0 -144
  88. package/dist/commands-devices.js.map +0 -1
  89. package/dist/commands-doctor.d.ts +0 -25
  90. package/dist/commands-doctor.js +0 -1553
  91. package/dist/commands-doctor.js.map +0 -1
  92. package/dist/commands-guidance.d.ts +0 -27
  93. package/dist/commands-guidance.js +0 -150
  94. package/dist/commands-guidance.js.map +0 -1
  95. package/dist/commands-harness-context.d.ts +0 -57
  96. package/dist/commands-harness-context.js +0 -102
  97. package/dist/commands-harness-context.js.map +0 -1
  98. package/dist/commands-hook-attend.d.ts +0 -70
  99. package/dist/commands-hook-attend.js +0 -655
  100. package/dist/commands-hook-attend.js.map +0 -1
  101. package/dist/commands-hook-diagnostics.d.ts +0 -37
  102. package/dist/commands-hook-diagnostics.js +0 -136
  103. package/dist/commands-hook-diagnostics.js.map +0 -1
  104. package/dist/commands-hook-install.d.ts +0 -26
  105. package/dist/commands-hook-install.js +0 -784
  106. package/dist/commands-hook-install.js.map +0 -1
  107. package/dist/commands-hook-run.d.ts +0 -29
  108. package/dist/commands-hook-run.js +0 -933
  109. package/dist/commands-hook-run.js.map +0 -1
  110. package/dist/commands-hook-shape.d.ts +0 -19
  111. package/dist/commands-hook-shape.js +0 -48
  112. package/dist/commands-hook-shape.js.map +0 -1
  113. package/dist/commands-init.d.ts +0 -42
  114. package/dist/commands-init.js +0 -901
  115. package/dist/commands-init.js.map +0 -1
  116. package/dist/commands-io.d.ts +0 -2
  117. package/dist/commands-io.js +0 -117
  118. package/dist/commands-io.js.map +0 -1
  119. package/dist/commands-logs.d.ts +0 -30
  120. package/dist/commands-logs.js +0 -201
  121. package/dist/commands-logs.js.map +0 -1
  122. package/dist/commands-native-acknowledge.d.ts +0 -8
  123. package/dist/commands-native-acknowledge.js +0 -114
  124. package/dist/commands-native-acknowledge.js.map +0 -1
  125. package/dist/commands-project.d.ts +0 -4
  126. package/dist/commands-project.js +0 -41
  127. package/dist/commands-project.js.map +0 -1
  128. package/dist/commands-receive.d.ts +0 -3
  129. package/dist/commands-receive.js +0 -59
  130. package/dist/commands-receive.js.map +0 -1
  131. package/dist/commands-send-support.d.ts +0 -59
  132. package/dist/commands-send-support.js +0 -258
  133. package/dist/commands-send-support.js.map +0 -1
  134. package/dist/commands-send.d.ts +0 -34
  135. package/dist/commands-send.js +0 -900
  136. package/dist/commands-send.js.map +0 -1
  137. package/dist/commands-setup-proof.d.ts +0 -43
  138. package/dist/commands-setup-proof.js +0 -134
  139. package/dist/commands-setup-proof.js.map +0 -1
  140. package/dist/commands-skill.d.ts +0 -21
  141. package/dist/commands-skill.js +0 -199
  142. package/dist/commands-skill.js.map +0 -1
  143. package/dist/commands-sounds.d.ts +0 -12
  144. package/dist/commands-sounds.js +0 -35
  145. package/dist/commands-sounds.js.map +0 -1
  146. package/dist/commands-update-check.d.ts +0 -6
  147. package/dist/commands-update-check.js +0 -102
  148. package/dist/commands-update-check.js.map +0 -1
  149. package/dist/commands-update-resume.d.ts +0 -7
  150. package/dist/commands-update-resume.js +0 -143
  151. package/dist/commands-update-resume.js.map +0 -1
  152. package/dist/commands-update-skill.d.ts +0 -5
  153. package/dist/commands-update-skill.js +0 -36
  154. package/dist/commands-update-skill.js.map +0 -1
  155. package/dist/commands-update.d.ts +0 -11
  156. package/dist/commands-update.js +0 -286
  157. package/dist/commands-update.js.map +0 -1
  158. package/dist/commands.d.ts +0 -26
  159. package/dist/commands.js +0 -27
  160. package/dist/commands.js.map +0 -1
  161. package/dist/config-schema.d.ts +0 -62
  162. package/dist/config-schema.js +0 -302
  163. package/dist/config-schema.js.map +0 -1
  164. package/dist/config.d.ts +0 -181
  165. package/dist/config.js +0 -357
  166. package/dist/config.js.map +0 -1
  167. package/dist/credentials.d.ts +0 -96
  168. package/dist/credentials.js +0 -342
  169. package/dist/credentials.js.map +0 -1
  170. package/dist/file-lock.d.ts +0 -34
  171. package/dist/file-lock.js +0 -367
  172. package/dist/file-lock.js.map +0 -1
  173. package/dist/generated-session-label.d.ts +0 -2
  174. package/dist/generated-session-label.js +0 -152
  175. package/dist/generated-session-label.js.map +0 -1
  176. package/dist/guidance-content.d.ts +0 -53
  177. package/dist/guidance-content.js +0 -248
  178. package/dist/guidance-content.js.map +0 -1
  179. package/dist/guidance-render.d.ts +0 -25
  180. package/dist/guidance-render.js +0 -47
  181. package/dist/guidance-render.js.map +0 -1
  182. package/dist/guidance.d.ts +0 -69
  183. package/dist/guidance.js +0 -173
  184. package/dist/guidance.js.map +0 -1
  185. package/dist/harness-session-title.d.ts +0 -22
  186. package/dist/harness-session-title.js +0 -74
  187. package/dist/harness-session-title.js.map +0 -1
  188. package/dist/harnesses.d.ts +0 -65
  189. package/dist/harnesses.js +0 -159
  190. package/dist/harnesses.js.map +0 -1
  191. package/dist/hermes-attendant.d.ts +0 -38
  192. package/dist/hermes-attendant.js +0 -337
  193. package/dist/hermes-attendant.js.map +0 -1
  194. package/dist/hermes-plugin.d.ts +0 -11
  195. package/dist/hermes-plugin.js +0 -299
  196. package/dist/hermes-plugin.js.map +0 -1
  197. package/dist/hook-acknowledgements.d.ts +0 -70
  198. package/dist/hook-acknowledgements.js +0 -490
  199. package/dist/hook-acknowledgements.js.map +0 -1
  200. package/dist/hook-adapter.d.ts +0 -48
  201. package/dist/hook-adapter.js +0 -419
  202. package/dist/hook-adapter.js.map +0 -1
  203. package/dist/hook-events.d.ts +0 -105
  204. package/dist/hook-events.js +0 -130
  205. package/dist/hook-events.js.map +0 -1
  206. package/dist/hook-gates.d.ts +0 -7
  207. package/dist/hook-gates.js +0 -20
  208. package/dist/hook-gates.js.map +0 -1
  209. package/dist/hook-input.d.ts +0 -10
  210. package/dist/hook-input.js +0 -64
  211. package/dist/hook-input.js.map +0 -1
  212. package/dist/hook-lifecycle.d.ts +0 -112
  213. package/dist/hook-lifecycle.js +0 -2031
  214. package/dist/hook-lifecycle.js.map +0 -1
  215. package/dist/hook-project-sessions.d.ts +0 -28
  216. package/dist/hook-project-sessions.js +0 -141
  217. package/dist/hook-project-sessions.js.map +0 -1
  218. package/dist/hook-question-lock.d.ts +0 -29
  219. package/dist/hook-question-lock.js +0 -217
  220. package/dist/hook-question-lock.js.map +0 -1
  221. package/dist/hook-question-retirement.d.ts +0 -74
  222. package/dist/hook-question-retirement.js +0 -314
  223. package/dist/hook-question-retirement.js.map +0 -1
  224. package/dist/hook-question-state.d.ts +0 -58
  225. package/dist/hook-question-state.js +0 -239
  226. package/dist/hook-question-state.js.map +0 -1
  227. package/dist/hook-session-state.d.ts +0 -127
  228. package/dist/hook-session-state.js +0 -488
  229. package/dist/hook-session-state.js.map +0 -1
  230. package/dist/hook-types.d.ts +0 -476
  231. package/dist/hook-types.js +0 -4
  232. package/dist/hook-types.js.map +0 -1
  233. package/dist/injection-render.d.ts +0 -36
  234. package/dist/injection-render.js +0 -61
  235. package/dist/injection-render.js.map +0 -1
  236. package/dist/install-hooks.d.ts +0 -470
  237. package/dist/install-hooks.js +0 -1622
  238. package/dist/install-hooks.js.map +0 -1
  239. package/dist/integration-health.d.ts +0 -26
  240. package/dist/integration-health.js +0 -133
  241. package/dist/integration-health.js.map +0 -1
  242. package/dist/interactive.d.ts +0 -22
  243. package/dist/interactive.js +0 -644
  244. package/dist/interactive.js.map +0 -1
  245. package/dist/invocation-context.d.ts +0 -47
  246. package/dist/invocation-context.js +0 -164
  247. package/dist/invocation-context.js.map +0 -1
  248. package/dist/local-path.d.ts +0 -7
  249. package/dist/local-path.js +0 -27
  250. package/dist/local-path.js.map +0 -1
  251. package/dist/logging.d.ts +0 -157
  252. package/dist/logging.js +0 -599
  253. package/dist/logging.js.map +0 -1
  254. package/dist/main-run.d.ts +0 -1
  255. package/dist/main-run.js +0 -42
  256. package/dist/main-run.js.map +0 -1
  257. package/dist/main.d.ts +0 -2
  258. package/dist/main.js +0 -12
  259. package/dist/main.js.map +0 -1
  260. package/dist/native-answer-operation.d.ts +0 -70
  261. package/dist/native-answer-operation.js +0 -247
  262. package/dist/native-answer-operation.js.map +0 -1
  263. package/dist/native-skills.d.ts +0 -64
  264. package/dist/native-skills.js +0 -167
  265. package/dist/native-skills.js.map +0 -1
  266. package/dist/node-floor.d.ts +0 -19
  267. package/dist/node-floor.js +0 -32
  268. package/dist/node-floor.js.map +0 -1
  269. package/dist/npm-invocation.d.ts +0 -34
  270. package/dist/npm-invocation.js +0 -53
  271. package/dist/npm-invocation.js.map +0 -1
  272. package/dist/openclaw-continuation-bridge.d.ts +0 -7
  273. package/dist/openclaw-continuation-bridge.js +0 -64
  274. package/dist/openclaw-continuation-bridge.js.map +0 -1
  275. package/dist/openclaw-gateway-readiness.d.ts +0 -1
  276. package/dist/openclaw-gateway-readiness.js +0 -24
  277. package/dist/openclaw-gateway-readiness.js.map +0 -1
  278. package/dist/openclaw-generation.d.ts +0 -21
  279. package/dist/openclaw-generation.js +0 -115
  280. package/dist/openclaw-generation.js.map +0 -1
  281. package/dist/openclaw-message-bridge.d.ts +0 -8
  282. package/dist/openclaw-message-bridge.js +0 -93
  283. package/dist/openclaw-message-bridge.js.map +0 -1
  284. package/dist/openclaw-pending.d.ts +0 -8
  285. package/dist/openclaw-pending.js +0 -47
  286. package/dist/openclaw-pending.js.map +0 -1
  287. package/dist/openclaw-plugin.d.ts +0 -75
  288. package/dist/openclaw-plugin.js +0 -1353
  289. package/dist/openclaw-plugin.js.map +0 -1
  290. package/dist/openclaw-session-access.d.ts +0 -12
  291. package/dist/openclaw-session-access.js +0 -79
  292. package/dist/openclaw-session-access.js.map +0 -1
  293. package/dist/opencode-plugin.d.ts +0 -88
  294. package/dist/opencode-plugin.js +0 -286
  295. package/dist/opencode-plugin.js.map +0 -1
  296. package/dist/orca-session-title.d.ts +0 -11
  297. package/dist/orca-session-title.js +0 -111
  298. package/dist/orca-session-title.js.map +0 -1
  299. package/dist/pack-feedback-slice.d.ts +0 -35
  300. package/dist/pack-feedback-slice.js +0 -126
  301. package/dist/pack-feedback-slice.js.map +0 -1
  302. package/dist/pairing-qr.d.ts +0 -5
  303. package/dist/pairing-qr.js +0 -24
  304. package/dist/pairing-qr.js.map +0 -1
  305. package/dist/pending-pairing.d.ts +0 -38
  306. package/dist/pending-pairing.js +0 -70
  307. package/dist/pending-pairing.js.map +0 -1
  308. package/dist/platform.d.ts +0 -67
  309. package/dist/platform.js +0 -140
  310. package/dist/platform.js.map +0 -1
  311. package/dist/process-identity.d.ts +0 -26
  312. package/dist/process-identity.js +0 -82
  313. package/dist/process-identity.js.map +0 -1
  314. package/dist/program.d.ts +0 -59
  315. package/dist/program.js +0 -691
  316. package/dist/program.js.map +0 -1
  317. package/dist/project-enablement.d.ts +0 -21
  318. package/dist/project-enablement.js +0 -71
  319. package/dist/project-enablement.js.map +0 -1
  320. package/dist/question-settlement-process.d.ts +0 -17
  321. package/dist/question-settlement-process.js +0 -35
  322. package/dist/question-settlement-process.js.map +0 -1
  323. package/dist/question-timing.d.ts +0 -46
  324. package/dist/question-timing.js +0 -48
  325. package/dist/question-timing.js.map +0 -1
  326. package/dist/readiness.d.ts +0 -153
  327. package/dist/readiness.js +0 -149
  328. package/dist/readiness.js.map +0 -1
  329. package/dist/release.d.ts +0 -31
  330. package/dist/release.js +0 -52
  331. package/dist/release.js.map +0 -1
  332. package/dist/send-attempts.d.ts +0 -26
  333. package/dist/send-attempts.js +0 -139
  334. package/dist/send-attempts.js.map +0 -1
  335. package/dist/send.d.ts +0 -115
  336. package/dist/send.js +0 -421
  337. package/dist/send.js.map +0 -1
  338. package/dist/session-activation.d.ts +0 -13
  339. package/dist/session-activation.js +0 -64
  340. package/dist/session-activation.js.map +0 -1
  341. package/dist/session-attendant-probe.d.ts +0 -85
  342. package/dist/session-attendant-probe.js +0 -173
  343. package/dist/session-attendant-probe.js.map +0 -1
  344. package/dist/session-attendant-state.d.ts +0 -70
  345. package/dist/session-attendant-state.js +0 -256
  346. package/dist/session-attendant-state.js.map +0 -1
  347. package/dist/session-attendant.d.ts +0 -143
  348. package/dist/session-attendant.js +0 -520
  349. package/dist/session-attendant.js.map +0 -1
  350. package/dist/session-delivery.d.ts +0 -249
  351. package/dist/session-delivery.js +0 -576
  352. package/dist/session-delivery.js.map +0 -1
  353. package/dist/session-handoff.d.ts +0 -98
  354. package/dist/session-handoff.js +0 -118
  355. package/dist/session-handoff.js.map +0 -1
  356. package/dist/session-input-wakes.d.ts +0 -78
  357. package/dist/session-input-wakes.js +0 -239
  358. package/dist/session-input-wakes.js.map +0 -1
  359. package/dist/session-inputs.d.ts +0 -57
  360. package/dist/session-inputs.js +0 -434
  361. package/dist/session-inputs.js.map +0 -1
  362. package/dist/session-labels.d.ts +0 -56
  363. package/dist/session-labels.js +0 -396
  364. package/dist/session-labels.js.map +0 -1
  365. package/dist/session-message-handoff.d.ts +0 -39
  366. package/dist/session-message-handoff.js +0 -107
  367. package/dist/session-message-handoff.js.map +0 -1
  368. package/dist/setup-destinations.d.ts +0 -23
  369. package/dist/setup-destinations.js +0 -44
  370. package/dist/setup-destinations.js.map +0 -1
  371. package/dist/skill-integrity.d.ts +0 -54
  372. package/dist/skill-integrity.js +0 -164
  373. package/dist/skill-integrity.js.map +0 -1
  374. package/dist/skill-source/manifest.json +0 -40
  375. package/dist/skill-source/notifai/SKILL.md +0 -350
  376. package/dist/skill-source/notifai/references/diagnostics.md +0 -36
  377. package/dist/skill-source/notifai/references/harness-setup.md +0 -384
  378. package/dist/skill-source/notifai/references/native-questions.md +0 -51
  379. package/dist/skill-source/notifai/references/notes-and-edits.md +0 -54
  380. package/dist/skill-source/notifai/references/send-details.md +0 -14
  381. package/dist/skill-source/notifai/references/updates.md +0 -116
  382. package/dist/skill-source/notifai/references/writing-examples.md +0 -56
  383. package/dist/sound-ref.d.ts +0 -23
  384. package/dist/sound-ref.js +0 -28
  385. package/dist/sound-ref.js.map +0 -1
  386. package/dist/ui/banner.d.ts +0 -22
  387. package/dist/ui/banner.js +0 -87
  388. package/dist/ui/banner.js.map +0 -1
  389. package/dist/ui/config-view.d.ts +0 -30
  390. package/dist/ui/config-view.js +0 -108
  391. package/dist/ui/config-view.js.map +0 -1
  392. package/dist/ui/help.d.ts +0 -44
  393. package/dist/ui/help.js +0 -107
  394. package/dist/ui/help.js.map +0 -1
  395. package/dist/ui/theme.d.ts +0 -90
  396. package/dist/ui/theme.js +0 -244
  397. package/dist/ui/theme.js.map +0 -1
  398. package/dist/update-handoff.d.ts +0 -29
  399. package/dist/update-handoff.js +0 -69
  400. package/dist/update-handoff.js.map +0 -1
  401. package/dist/url-policy.d.ts +0 -85
  402. package/dist/url-policy.js +0 -369
  403. package/dist/url-policy.js.map +0 -1
  404. package/dist/version.d.ts +0 -40
  405. package/dist/version.js +0 -132
  406. package/dist/version.js.map +0 -1
  407. package/dist/wake-support.d.ts +0 -40
  408. package/dist/wake-support.js +0 -95
  409. package/dist/wake-support.js.map +0 -1
@@ -1,1622 +0,0 @@
1
- import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, } from 'node:fs';
2
- import { createHash } from 'node:crypto';
3
- import path from 'node:path';
4
- import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
5
- import { atomicWriteFileSync } from './atomic-file.js';
6
- import { withTargetFileLock } from './file-lock.js';
7
- import { hookAdapterPath, hookHostPlatform, inspectHookAdapter, } from './hook-adapter.js';
8
- import { OPENCODE_PLUGIN_FILENAME, opencodeConfigDir, opencodePluginPath, opencodePluginTarget, } from './opencode-plugin.js';
9
- import { legacyOpenclawProjectPluginPath, openclawHasGlobalEvidence, openclawPluginPath, openclawPluginTarget, } from './openclaw-plugin.js';
10
- import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js';
11
- import { hermesPluginCurrent, hermesPluginDir, isOurHermesPlugin } from './hermes-plugin.js';
12
- import { attendDocumentEvents, HOOK_EVENT_COMMAND_RE, HOOK_EVENT_PATTERN, HOOK_EVENT_TABLE, OPENCLAW_EVENTS, OPENCODE_EVENTS, installsSessionAttendant, } from './hook-events.js';
13
- import { accountHome } from './platform.js';
14
- import { sameLocalPath } from './local-path.js';
15
- import { NON_ROUTING_STOP_TIMEOUT_SECONDS, QUESTION_STOP_TIMEOUT_SECONDS, } from './question-timing.js';
16
- /**
17
- * The command each hook runs. Harness definitions know only the stable
18
- * user-level adapter. Mutable Node, package-manager, version, and checkout
19
- * paths live behind that seam and never enter a trusted hook identity —
20
- * except on Windows, where CreateProcess cannot run the adapter without an
21
- * explicit Node executable, so the registered interpreter is named first.
22
- */
23
- export function hookCommand(adapterPath, event, harness, options = {}) {
24
- return (`${hookCommandPrefix(adapterPath, options)}hook ${event} ${OWNER_MARKER}` +
25
- (harness === undefined ? '' : ` --harness ${harness}`));
26
- }
27
- /** The leading argv that every Notifai handler must start with. */
28
- export function hookCommandPrefix(adapterPath, options = {}) {
29
- const host = hookHostPlatform(options.platform);
30
- if (host === 'win32') {
31
- const nodePath = options.nodePath ?? process.execPath;
32
- return `${quoteWindowsArg(nodePath)} ${quoteWindowsArg(adapterPath)} `;
33
- }
34
- return `${quote(adapterPath)} `;
35
- }
36
- /**
37
- * Says "Notifai installed this" without saying which checkout did.
38
- *
39
- * Ownership used to be matched on the absolute script path, so installing from
40
- * a second checkout did not recognise the first one's handlers as ours. Both
41
- * stayed, the harness ran both, and one question produced two notifications;
42
- * uninstalling the second silently left the first running. A marker that every
43
- * Notifai build writes and no build's path appears in fixes that by making
44
- * ownership a property of the handler rather than of the machine it was
45
- * installed from.
46
- *
47
- * It is a real (ignored) CLI flag rather than a comment because not every
48
- * harness runs the command through a shell.
49
- */
50
- export const OWNER_MARKER = '--owner notifai';
51
- /**
52
- * POSIX single-quoting. Double quotes were wrong: they still expand `$(...)`
53
- * and backticks, so a checkout path containing shell syntax became command
54
- * execution on every hook event. Single quotes suppress all expansion; the
55
- * only escape needed is for a literal single quote.
56
- */
57
- function quote(value) {
58
- return `'${value.replaceAll("'", `'\\''`)}'`;
59
- }
60
- /**
61
- * Quote one Windows argv element so CommandLineToArgvW / CreateProcess keep
62
- * it as a single argument. Always used for the Node and adapter paths, which
63
- * routinely contain spaces (`Program Files`) and must not use POSIX quotes
64
- * inside JSON or TOML harness documents.
65
- */
66
- export function quoteWindowsArg(value) {
67
- let out = '"';
68
- let slashes = 0;
69
- for (const ch of value) {
70
- if (ch === '\\') {
71
- slashes += 1;
72
- continue;
73
- }
74
- if (ch === '"') {
75
- out += '\\'.repeat(slashes * 2 + 1) + '"';
76
- slashes = 0;
77
- continue;
78
- }
79
- out += '\\'.repeat(slashes) + ch;
80
- slashes = 0;
81
- }
82
- return `${out}${'\\'.repeat(slashes * 2)}"`;
83
- }
84
- /**
85
- * Harnesses without asynchronous Question Routing use this short Stop budget.
86
- * Claude Code and Codex use `QUESTION_STOP_TIMEOUT_SECONDS` instead.
87
- */
88
- export const NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS = NON_ROUTING_STOP_TIMEOUT_SECONDS;
89
- export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js';
90
- /**
91
- * Codex's Session Attendant lifetime. Codex kills an async hook at its
92
- * declared timeout, so the attendant declares the same complete window a
93
- * question owner does; the next prompt or turn end starts another.
94
- */
95
- export const CODEX_ATTEND_TIMEOUT_SECONDS = QUESTION_STOP_TIMEOUT_SECONDS;
96
- /** Codex clamps an Interrupt hook to three seconds; declaring it keeps the definition honest. */
97
- export const CODEX_INTERRUPT_TIMEOUT_SECONDS = 3;
98
- /**
99
- * Whether this harness's Stop handler runs detached from the turn.
100
- *
101
- * The installer and doctor share this predicate so they agree which harnesses
102
- * run out of band. Owner lifetime is route-neutral and lives in
103
- * `question-timing.ts`.
104
- *
105
- * Codex is detached on every platform because its delivery route is a write to
106
- * the thread's own durable inbox rather than this hook's stdout: there is no
107
- * continuation to keep a turn open for, and the queue has no platform
108
- * dependency to gate on. Claude Code still needs its turn held on Windows,
109
- * where no inbox socket exists.
110
- */
111
- export function stopHandlerIsDetached(harness, platform) {
112
- if (harness === 'codex')
113
- return true;
114
- return harness === 'claude-code' && hookHostPlatform(platform) === 'posix';
115
- }
116
- /**
117
- * The turn-end handler, whose shape is the whole per-harness difference.
118
- *
119
- * Claude Code takes the answer over its own inbox socket and Codex takes it
120
- * through its thread's durable inbox, so both Stop hooks are `async: true`:
121
- * they return immediately, the terminal is never held, and the waiter finishes
122
- * out of band. Only Claude on Windows still blocks and prints a continuation to
123
- * stdout, because no inbox socket exists there. Every Question Routing owner
124
- * declares the same complete-window timeout, because the detached waiter must
125
- * outlive the answer window in every case; what changed for Codex is that a
126
- * short or missing timeout no longer truncates a held *turn*. Blocking hosts
127
- * also set `statusMessage` so the held turn is not mistaken for a hang.
128
- */
129
- export const BLOCKING_STOP_STATUS_MESSAGE = 'Notifai: waiting for your answer';
130
- function stopHandler(adapterPath, harness, options) {
131
- const command = hookCommand(adapterPath, 'stop', harness, options);
132
- if (stopHandlerIsDetached(harness, options.platform)) {
133
- return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS, async: true };
134
- }
135
- if (harness === 'grok') {
136
- return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS };
137
- }
138
- if (harness === 'codex' || harness === 'claude-code') {
139
- return {
140
- type: 'command',
141
- command,
142
- timeout: QUESTION_STOP_TIMEOUT_SECONDS,
143
- statusMessage: BLOCKING_STOP_STATUS_MESSAGE,
144
- };
145
- }
146
- return { type: 'command', command, timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS };
147
- }
148
- function commandOptionsFrom(options) {
149
- return {
150
- ...(options.platform === undefined ? {} : { platform: options.platform }),
151
- ...(options.nodePath === undefined ? {} : { nodePath: options.nodePath }),
152
- };
153
- }
154
- export function buildHookConfig(options) {
155
- const { adapterPath } = options;
156
- const commandOptions = commandOptionsFrom(options);
157
- const hooks = Object.create(null);
158
- for (const row of HOOK_EVENT_TABLE) {
159
- if (row.document === null)
160
- continue;
161
- if (row.notifai === 'post-tool-use' &&
162
- (options.harness !== 'codex' || !installsSessionAttendant(options.harness, options.platform)))
163
- continue;
164
- if (row.notifai === 'stop') {
165
- hooks[row.document] = [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }];
166
- continue;
167
- }
168
- hooks[row.document] = [
169
- {
170
- hooks: [
171
- {
172
- type: 'command',
173
- command: hookCommand(adapterPath, row.notifai, options.harness, commandOptions),
174
- timeout: row.timeoutSeconds,
175
- },
176
- ],
177
- },
178
- ];
179
- }
180
- if (installsSessionAttendant(options.harness, options.platform)) {
181
- // A second handler in the same group, never folded into the first: the
182
- // harness runs the activation handler's context back promptly and does
183
- // not wait for an async handler. Claude Code does not time out a
184
- // backgrounded hook, so its attendant declares none and ends itself with
185
- // its session. Codex enforces every async timeout, and its 600-second
186
- // default would kill the attendant: it declares the complete answer window
187
- // and a later prompt or turn end re-arms it after that. Codex caps
188
- // Interrupt at three seconds; that copy only records the turn's end.
189
- for (const event of attendDocumentEvents(options.harness)) {
190
- const attend = {
191
- type: 'command',
192
- command: hookCommand(adapterPath, 'attend', options.harness, commandOptions),
193
- async: true,
194
- ...(options.harness !== 'codex'
195
- ? {}
196
- : { timeout: event === 'Interrupt' ? CODEX_INTERRUPT_TIMEOUT_SECONDS : CODEX_ATTEND_TIMEOUT_SECONDS }),
197
- };
198
- const group = hooks[event]?.[0];
199
- if (group === undefined)
200
- hooks[event] = [{ hooks: [attend] }];
201
- else
202
- group.hooks.push(attend);
203
- }
204
- }
205
- return hooks;
206
- }
207
- /** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
208
- export function buildCursorHookConfig(options) {
209
- const commandOptions = commandOptionsFrom(options);
210
- const hooks = Object.create(null);
211
- for (const row of HOOK_EVENT_TABLE) {
212
- if (row.cursor === null)
213
- continue;
214
- const handler = {
215
- command: hookCommand(options.adapterPath, row.notifai, 'cursor', commandOptions),
216
- timeout: row.notifai === 'stop' ? NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS : row.timeoutSeconds,
217
- };
218
- if ('cursorLoopLimit' in row)
219
- handler.loop_limit = row.cursorLoopLimit;
220
- hooks[row.cursor] = [...(hooks[row.cursor] ?? []), handler];
221
- }
222
- return hooks;
223
- }
224
- /**
225
- * The one file each harness reads Notifai's lifecycle wiring from: the current
226
- * User's account, in that harness's active home.
227
- */
228
- export function settingsFile(harness, env = process.env, platform = process.platform) {
229
- switch (harness) {
230
- // OpenCode has no settings document to merge into — its adapter is a
231
- // generated plugin module, so it owns a whole file instead.
232
- case 'opencode':
233
- return opencodePluginPath(env, platform);
234
- case 'openclaw':
235
- return openclawPluginPath(env, platform);
236
- case 'hermes':
237
- return path.join(hermesPluginDir(env), '__init__.py');
238
- case 'cursor':
239
- return path.join(harnessAccountHome(env, platform), '.cursor', 'hooks.json');
240
- case 'claude-code':
241
- return path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json');
242
- case 'grok':
243
- return path.join(configHome(env, 'GROK_HOME', '.grok', platform), 'hooks', 'notifai.json');
244
- case 'codex':
245
- return inspectCodexLayer(codexMachineLayerPaths(env, platform)).writeTarget;
246
- default:
247
- return assertNeverHarness(harness);
248
- }
249
- }
250
- /**
251
- * Files an older build may have left inside this Project, which Notifai now
252
- * only ever reads in order to remove.
253
- *
254
- * Nothing installs here any more. Every path is still enumerated because a
255
- * leftover Project-scoped handler keeps firing beside the Machine one, and
256
- * silently leaving it would be exactly the duplicate-routing defect dropping
257
- * the second scope was meant to end.
258
- */
259
- export function legacyProjectHookFiles(harness, cwd) {
260
- switch (harness) {
261
- case 'opencode':
262
- return [path.join(cwd, '.opencode', 'plugins', OPENCODE_PLUGIN_FILENAME)];
263
- case 'openclaw':
264
- return [legacyOpenclawProjectPluginPath(cwd)];
265
- case 'cursor':
266
- return [path.join(cwd, '.cursor', 'hooks.json')];
267
- case 'claude-code':
268
- return [path.join(cwd, '.claude', 'settings.local.json')];
269
- case 'grok':
270
- return [];
271
- case 'hermes':
272
- return [];
273
- case 'codex':
274
- return codexLegacyProjectLayers(cwd).flatMap((paths) => [paths.hooksJson, paths.configToml]);
275
- default:
276
- return assertNeverHarness(harness);
277
- }
278
- }
279
- /**
280
- * The account home harness config roots are resolved against.
281
- *
282
- * Delegates to the shared process/path `accountHome` so Cursor, OpenCode,
283
- * Claude, and Codex follow the same Windows USERPROFILE / MSYS HOME rules.
284
- */
285
- export function harnessAccountHome(env = process.env, platform = process.platform) {
286
- return accountHome(env, hookHostPlatform(platform) === 'win32' ? 'win32' : 'linux');
287
- }
288
- function assertNeverHarness(harness) {
289
- throw new Error(`Unsupported harness: ${String(harness)}`);
290
- }
291
- /**
292
- * Where Codex looks for a project's hooks — the **main** repository, not the
293
- * working directory.
294
- *
295
- * Run from a linked git worktree, Codex reads `<main repo>/.codex/config.toml`
296
- * (or a leftover `hooks.json`) and never looks at the worktree's own. Proven 2026-08-03 in an isolated
297
- * `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
298
- * fired and an identical one at the worktree root did not; removing the main
299
- * one left nothing firing at all. Writing to cwd therefore produces a silent
300
- * no-op for anyone whose agent runs in a worktree, which is the normal case
301
- * under tooling like Orca.
302
- *
303
- * Claude Code does not share this behaviour — it reads the worktree's own
304
- * `.claude/settings.local.json` — so this deliberately applies to Codex only.
305
- */
306
- export function codexProjectRoot(cwd) {
307
- const entry = findGitEntry(cwd);
308
- if (entry === null)
309
- return cwd;
310
- try {
311
- if (statSync(entry).isDirectory())
312
- return path.dirname(entry);
313
- return linkedWorktreeMainRoot(entry) ?? cwd;
314
- }
315
- catch {
316
- // An unreadable or exotic checkout is not worth failing an install over;
317
- // cwd is what we used to do and is right for every non-worktree case.
318
- return cwd;
319
- }
320
- }
321
- /** The nearest `.git` at or above `from`, or null outside a repository. */
322
- function findGitEntry(from) {
323
- let dir = path.resolve(from);
324
- for (;;) {
325
- const entry = path.join(dir, '.git');
326
- if (existsSync(entry))
327
- return entry;
328
- const parent = path.dirname(dir);
329
- if (parent === dir)
330
- return null;
331
- dir = parent;
332
- }
333
- }
334
- /**
335
- * Resolve the main checkout only from Git's reciprocal linked-worktree
336
- * metadata. A repository can contain an arbitrary `.git` file, so its one-way
337
- * `gitdir`/`commondir` claims are not authority to write outside the Project.
338
- */
339
- function linkedWorktreeMainRoot(gitFile) {
340
- if (!isOwnedRegularFile(gitFile))
341
- return null;
342
- const gitdirClaim = readPathMarker(gitFile, /^gitdir:[ \t]+([^\r\n]+)\r?\n?$/);
343
- if (gitdirClaim === null)
344
- return null;
345
- const gitDir = path.resolve(path.dirname(gitFile), gitdirClaim);
346
- if (!isOwnedDirectory(gitDir))
347
- return null;
348
- const commonClaim = readPathMarker(path.join(gitDir, 'commondir'), /^([^\r\n]+)\r?\n?$/);
349
- const backlinkClaim = readPathMarker(path.join(gitDir, 'gitdir'), /^([^\r\n]+)\r?\n?$/);
350
- if (commonClaim === null || backlinkClaim === null)
351
- return null;
352
- const commonDir = path.resolve(gitDir, commonClaim);
353
- const backlink = path.resolve(gitDir, backlinkClaim);
354
- const worktreesDir = path.join(commonDir, 'worktrees');
355
- if (path.basename(commonDir).toLowerCase() !== '.git' ||
356
- !isOwnedDirectory(commonDir) ||
357
- !isOwnedDirectory(worktreesDir) ||
358
- !sameLocalPath(path.dirname(realpathSync(gitDir)), realpathSync(worktreesDir)) ||
359
- !sameLocalPath(backlink, gitFile)) {
360
- return null;
361
- }
362
- const mainRoot = path.dirname(commonDir);
363
- return isOwnedDirectory(mainRoot) ? mainRoot : null;
364
- }
365
- function readPathMarker(file, pattern) {
366
- if (!isOwnedRegularFile(file))
367
- return null;
368
- const match = pattern.exec(readFileSync(file, 'utf8'));
369
- const claim = match?.[1]?.trim();
370
- return claim === undefined || claim === '' ? null : claim;
371
- }
372
- function isOwnedRegularFile(file) {
373
- return isOwnedEntry(file, 'file');
374
- }
375
- function isOwnedDirectory(file) {
376
- return isOwnedEntry(file, 'directory');
377
- }
378
- function isOwnedEntry(file, kind) {
379
- const metadata = lstatSync(file);
380
- if (metadata.isSymbolicLink())
381
- return false;
382
- if (kind === 'file' ? !metadata.isFile() : !metadata.isDirectory())
383
- return false;
384
- const uid = process.getuid?.();
385
- return uid === undefined || metadata.uid === uid;
386
- }
387
- /**
388
- * Harness-specific home variables relocate the whole active harness home.
389
- * This matters under session managers that give each Codex account its own
390
- * `CODEX_HOME`: writing to the OS account's ~/.codex would configure a
391
- * different Codex installation than the one running the command.
392
- */
393
- export function configHome(env, variable, fallback, platform = process.platform) {
394
- const override = env[variable];
395
- if (override !== undefined && override !== '')
396
- return override;
397
- const home = harnessAccountHome(env, platform);
398
- return path.join(home, fallback);
399
- }
400
- /**
401
- * User-global Codex config directory for the *running* Codex installation.
402
- *
403
- * `CODEX_HOME` replaces the home; it does not shadow it. `codex doctor` with
404
- * the variable set reports that directory for every path it resolves and never
405
- * consults `~/.codex` at all. So a session manager that points Codex at its own
406
- * home makes `~/.codex` inert for those sessions, and installing there would
407
- * write hooks the running agent never reads while reporting success.
408
- *
409
- * Following the variable is therefore correct even though it means install and
410
- * doctor resolve different homes when run from different shells. Doctor names
411
- * the home it inspected for exactly that reason — see `codexHomeNote`.
412
- */
413
- export function codexGlobalDir(env = process.env, platform = process.platform) {
414
- return configHome(env, 'CODEX_HOME', '.codex', platform);
415
- }
416
- function codexLayerPathsIn(dir) {
417
- return {
418
- dir,
419
- hooksJson: path.join(dir, 'hooks.json'),
420
- configToml: path.join(dir, 'config.toml'),
421
- };
422
- }
423
- /** The two files Codex will look at in the Machine layer Notifai installs into. */
424
- export function codexMachineLayerPaths(env = process.env, platform = process.platform) {
425
- return codexLayerPathsIn(codexGlobalDir(env, platform));
426
- }
427
- /**
428
- * Project layers an older build may have written into, for removal only.
429
- *
430
- * Codex resolves a project hook file against the main repository, so a linked
431
- * worktree has two candidate directories: the main checkout's `.codex`, which
432
- * Codex reads, and the worktree's own, which older builds sometimes created and
433
- * Codex never reads. Both are legacy residue and both are enumerated here.
434
- */
435
- export function codexLegacyProjectLayers(cwd) {
436
- const dirs = [path.join(codexProjectRoot(cwd), '.codex')];
437
- const entry = findGitEntry(cwd);
438
- if (entry !== null) {
439
- const worktreeLayer = path.join(path.dirname(entry), '.codex');
440
- if (!dirs.some((dir) => path.resolve(dir) === path.resolve(worktreeLayer))) {
441
- dirs.push(worktreeLayer);
442
- }
443
- }
444
- return dirs.map(codexLayerPathsIn);
445
- }
446
- /**
447
- * Where Notifai writes in this Codex layer.
448
- *
449
- * `config.toml` is the User's whole Codex configuration and Codex rewrites it
450
- * itself for `[hooks.state]`; keeping Notifai out of it bounds the blast radius
451
- * to hooks, removes a second writer racing those trust records, and replaces
452
- * TOML splicing with the same JSON merge Claude Code uses. Joining an inline
453
- * layer that already has the User's handlers is the one exception, and it
454
- * exists only so Codex never prints its dual-representation warning.
455
- *
456
- * A fresh installation uses `hooks.json` unless foreign inline handlers make
457
- * `config.toml` the layer's established representation. Notifai-owned inline
458
- * handlers with no foreign inline neighbours are moved to `hooks.json`. Codex
459
- * keys approval by source path, so that move is a User-owned `/hooks` review,
460
- * not a silent identity-preserving refresh. `[hooks.state]` is the trust store
461
- * rather than a hook definition, so it never counts as a representation and is
462
- * preserved through every write.
463
- */
464
- export function inspectCodexLayer(paths) {
465
- const jsonDocument = tryLoadSettings(paths.hooksJson);
466
- const tomlDocument = tryLoadSettings(paths.configToml);
467
- const ourJsonEvents = ourHandlerEvents(jsonDocument);
468
- const ourTomlEvents = ourHandlerEvents(tomlDocument);
469
- const foreignTomlHooks = tomlDocument !== null &&
470
- locateAllHandlers(tomlDocument).some((handler) => !isNotifaiCommand(handler.command));
471
- return {
472
- paths,
473
- jsonEvents: hookEventNames(jsonDocument?.hooks),
474
- tomlEvents: hookEventNames(tomlDocument?.hooks),
475
- ourJsonEvents,
476
- ourTomlEvents,
477
- foreignTomlHooks,
478
- writeTarget: foreignTomlHooks && !(ourJsonEvents.length > 0 && ourTomlEvents.length === 0)
479
- ? paths.configToml
480
- : paths.hooksJson,
481
- };
482
- }
483
- /**
484
- * Serialize one Notifai transaction across both representations in a Codex
485
- * layer, then inspect that layer only after acquiring the shared anchor.
486
- */
487
- export function withCodexLayerTransaction(paths, action) {
488
- return withTargetFileLock(paths.configToml, () => action(inspectCodexLayer(paths)));
489
- }
490
- /** Every Machine-layer file this harness might already be reading hooks from. */
491
- export function machineHookFiles(harness, env = process.env, platform = process.platform) {
492
- if (harness === 'grok') {
493
- const target = settingsFile('grok', env, platform);
494
- const directory = path.dirname(target);
495
- let names;
496
- try {
497
- names = readdirSync(directory).filter((name) => name.endsWith('.json')).sort();
498
- }
499
- catch {
500
- return [target];
501
- }
502
- return [target, ...names.map((name) => path.join(directory, name)).filter((file) => file !== target)];
503
- }
504
- if (harness !== 'codex')
505
- return [settingsFile(harness, env, platform)];
506
- const paths = codexMachineLayerPaths(env, platform);
507
- return [paths.hooksJson, paths.configToml];
508
- }
509
- /**
510
- * The Machine layer with *Notifai* installed in both Codex representations.
511
- *
512
- * A layer that uses `hooks.json` and inline `[hooks]` at once is not by itself
513
- * a Notifai fault: Codex supports both, loads both, runs every matching
514
- * handler, and already prints its own "prefer a single representation" warning
515
- * at startup. Foreign handlers in the other file are the user's — often written
516
- * by another tool that manages that file — and repeating Codex's warning as a
517
- * Notifai failure demanded an edit to configuration Notifai does not own, for a
518
- * condition where every handler still fires exactly once.
519
- *
520
- * What is ours to diagnose is Notifai in both files. Then one event really does
521
- * send two Notification Requests per turn, and the copy outside the write
522
- * target keeps firing an older definition after the next install refreshes only
523
- * the other one. Uninstall strips Notifai from both files, so the remedy is
524
- * exact — and it never touches a foreign handler.
525
- */
526
- export function codexRepresentationProblems(env = process.env, platform = process.platform) {
527
- const layer = inspectCodexLayer(codexMachineLayerPaths(env, platform));
528
- if (layer.ourJsonEvents.length === 0 || layer.ourTomlEvents.length === 0)
529
- return [];
530
- const doubled = layer.ourJsonEvents.filter((event) => layer.ourTomlEvents.includes(event));
531
- const consequence = doubled.length > 0
532
- ? `Codex runs every matching handler, so this layer notifies twice per turn for ${doubled.join(', ')}`
533
- : `Codex runs both files, so this layer's Notifai handlers are split between them (${[...new Set([...layer.ourJsonEvents, ...layer.ourTomlEvents])].join(', ')}) and the next install refreshes only one file`;
534
- return [
535
- `Notifai hooks are installed in both ${layer.paths.hooksJson} and ${layer.paths.configToml}; ${consequence}. Run \`notifai hooks uninstall --harness codex\` and then \`notifai hooks install --harness codex\` to leave exactly one copy; foreign hooks in either file are left alone.`,
536
- ];
537
- }
538
- /**
539
- * The Machine layer where Notifai and someone else each own one Codex
540
- * representation.
541
- *
542
- * This is not a fault and never fails a check. Codex loads `hooks.json` and
543
- * inline `[hooks]` together and runs every matching handler, so both sets fire
544
- * exactly once — but Codex also prints "prefer one representation per layer"
545
- * at startup, and a user who sees that warning deserves to know which file is
546
- * Notifai's, which is not, and that Notifai will not touch the one it does not
547
- * own. Saying nothing here is what made the warning look like Notifai's bug.
548
- */
549
- export function codexCoexistenceNotes(env = process.env, platform = process.platform) {
550
- const layer = inspectCodexLayer(codexMachineLayerPaths(env, platform));
551
- const inJson = layer.ourJsonEvents.length > 0;
552
- const inToml = layer.ourTomlEvents.length > 0;
553
- if (inJson === inToml)
554
- return [];
555
- const ours = inJson ? layer.paths.hooksJson : layer.paths.configToml;
556
- const theirs = inJson ? layer.paths.configToml : layer.paths.hooksJson;
557
- const theirEvents = inJson ? layer.tomlEvents : layer.jsonEvents;
558
- if (theirEvents.length === 0)
559
- return [];
560
- return [
561
- `Notifai's Codex hooks are in ${ours}. ${theirs} defines hooks Notifai does not own (${theirEvents.join(', ')}) and Notifai will not modify it. Codex loads both files and runs every matching handler, so each set fires exactly once; its "prefer one representation per layer" startup warning is reporting that, not a Notifai fault.`,
562
- ];
563
- }
564
- /**
565
- * What `CODEX_HOME` is doing to this shell, when it is doing anything.
566
- *
567
- * Codex replaces its whole home when the variable is set, so hooks installed
568
- * from a shell without it are simply absent here, and its trust store is keyed
569
- * by absolute file path — a config copied between homes arrives untrusted.
570
- * Both facts turn into "Notifai is not wired" reports that look like a bug in
571
- * the installer, so doctor names the home it actually inspected.
572
- */
573
- export function codexHomeNote(env = process.env, platform = process.platform) {
574
- const effective = codexGlobalDir(env, platform);
575
- const accountDefault = path.join(harnessAccountHome(env, platform), '.codex');
576
- if (effective === accountDefault)
577
- return null;
578
- return `CODEX_HOME points Codex at ${effective}, so ${accountDefault} is not read by Codex in this shell at all. Global hooks installed from a shell without CODEX_HOME live there and are invisible here; Codex also keys hook trust by absolute file path, so hooks copied between homes need approving again in each one.`;
579
- }
580
- function tryLoadSettings(file) {
581
- if (!existsSync(file))
582
- return null;
583
- try {
584
- return loadSettings(file);
585
- }
586
- catch {
587
- return null;
588
- }
589
- }
590
- function hookEventNames(hooks) {
591
- return Object.entries(hooks ?? {})
592
- .filter(([, value]) => Array.isArray(value))
593
- .map(([event]) => event);
594
- }
595
- /** Events this document defines a Notifai handler for, by their event key. */
596
- function ourHandlerEvents(document) {
597
- if (document === null)
598
- return [];
599
- return [...new Set(locateHandlers(document).map((handler) => handler.event))];
600
- }
601
- /** Best-effort detection so `hooks install` usually needs no flags. */
602
- /**
603
- * Harnesses this *project* shows evidence of, in the working directory only.
604
- *
605
- * `CLAUDE.md` counts because it is Claude Code's own project file and is
606
- * frequently the only marker: a repository can be worked in daily through
607
- * Claude Code and never accumulate a `.claude/` directory.
608
- *
609
- * `AGENTS.md` deliberately counts for nothing. It began as a Codex
610
- * convention and is now read by most agent tooling — including Claude Code,
611
- * and including this repository, where the two filenames are the same
612
- * document. Treating it as evidence of any one harness would be a guess
613
- * dressed up as detection.
614
- */
615
- function localHarnessEvidence(cwd) {
616
- const found = [];
617
- if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
618
- found.push('claude-code');
619
- }
620
- if (existsSync(path.join(cwd, '.codex')))
621
- found.push('codex');
622
- if (existsSync(path.join(cwd, '.cursor')))
623
- found.push('cursor');
624
- if (existsSync(path.join(cwd, '.opencode')))
625
- found.push('opencode');
626
- if (existsSync(path.join(cwd, '.openclaw')))
627
- found.push('openclaw');
628
- if (existsSync(path.join(cwd, '.grok')))
629
- found.push('grok');
630
- return found;
631
- }
632
- /** Harnesses installed anywhere on this machine — a much weaker signal. */
633
- function globalHarnessEvidence(env = process.env, platform = process.platform) {
634
- const home = harnessAccountHome(env, platform);
635
- const found = [];
636
- if (existsSync(path.join(home, '.claude')))
637
- found.push('claude-code');
638
- if (existsSync(codexGlobalDir(env, platform)))
639
- found.push('codex');
640
- if (existsSync(path.join(home, '.cursor')))
641
- found.push('cursor');
642
- if (existsSync(opencodeConfigDir(env, platform)))
643
- found.push('opencode');
644
- if (openclawHasGlobalEvidence(existsSync, env, platform))
645
- found.push('openclaw');
646
- if (env['HERMES_SESSION_ID'] || (env['HERMES_HOME']?.trim() && existsSync(env['HERMES_HOME']))) {
647
- found.push('hermes');
648
- }
649
- if (existsSync(configHome(env, 'GROK_HOME', '.grok', platform)))
650
- found.push('grok');
651
- return found;
652
- }
653
- /**
654
- * Every supported harness this project or machine shows evidence of, in
655
- * declared order. Project markers come first, then machine installs that the
656
- * project did not already name.
657
- *
658
- * `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
659
- */
660
- export function detectedHarnesses(cwd, env = process.env, platform = process.platform) {
661
- const seen = new Set([
662
- ...localHarnessEvidence(cwd),
663
- ...globalHarnessEvidence(env, platform),
664
- ]);
665
- return HOOK_INSTALLABLE_HARNESSES.filter((harness) => seen.has(harness));
666
- }
667
- /**
668
- * Which *single* harness to wire when a caller still needs exactly one, or
669
- * null when it is genuinely unclear.
670
- *
671
- * Project evidence decides, and machine evidence is consulted only when the
672
- * project offers none. Several markers are not a failure of detection — they
673
- * are several harnesses. `detectedHarnesses` is the default for install;
674
- * this helper stays for uninstall and any caller that cannot take a list.
675
- */
676
- export function detectHarness(cwd, env = process.env, platform = process.platform) {
677
- const local = localHarnessEvidence(cwd);
678
- if (local.length > 0)
679
- return local.length === 1 ? local[0] : null;
680
- const global = globalHarnessEvidence(env, platform);
681
- return global.length === 1 ? global[0] : null;
682
- }
683
- function readCursorSettings(file) {
684
- if (!existsSync(file))
685
- return { version: 1 };
686
- const source = readOwnedRegularFile(file);
687
- try {
688
- const parsed = JSON.parse(source);
689
- return typeof parsed === 'object' && parsed !== null
690
- ? parsed
691
- : { version: 1 };
692
- }
693
- catch {
694
- throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`);
695
- }
696
- }
697
- function isOurCommand(command, scriptPath) {
698
- if (command.includes(OWNER_MARKER))
699
- return true;
700
- return (command.includes(`${scriptPath}' hook `) ||
701
- command.includes(`${scriptPath}" hook `) ||
702
- command.includes(`${scriptPath} hook `) ||
703
- isLegacyNotifaiCommand(command));
704
- }
705
- /** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
706
- function isLegacyNotifaiCommand(command) {
707
- const hook = `['"]?\\s+hook (?:${HOOK_EVENT_PATTERN})\\b`;
708
- return (new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
709
- new RegExp(`(?:^|[/\\\\])(?:notifai-public|notifai)[/\\\\]apps[/\\\\]cli[/\\\\]dist[/\\\\]main\\.js${hook}`, 'i').test(command) ||
710
- new RegExp(`[/\\\\]node_modules[/\\\\]@raidiant[/\\\\]notifai[/\\\\]dist[/\\\\]main\\.js${hook}`, 'i').test(command));
711
- }
712
- export function mergeCursorHooks(existing, incoming, scriptPath) {
713
- const hooks = Object.create(null);
714
- const added = [];
715
- const replaced = [];
716
- const removed = [];
717
- for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
718
- const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath));
719
- if (foreign.length !== handlers.length) {
720
- if (event in incoming)
721
- replaced.push(event);
722
- else
723
- removed.push(event);
724
- }
725
- if (foreign.length > 0)
726
- hooks[event] = foreign;
727
- }
728
- for (const [event, handlers] of Object.entries(incoming)) {
729
- if (!replaced.includes(event))
730
- added.push(event);
731
- hooks[event] = [...(hooks[event] ?? []), ...handlers];
732
- }
733
- return { document: { ...existing, version: 1, hooks }, added, replaced, removed };
734
- }
735
- export function removeCursorHooks(existing, scriptPath) {
736
- const hooks = Object.create(null);
737
- const replaced = [];
738
- for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
739
- const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath));
740
- if (foreign.length !== handlers.length)
741
- replaced.push(event);
742
- if (foreign.length > 0)
743
- hooks[event] = foreign;
744
- }
745
- const document = { ...existing };
746
- if (Object.keys(hooks).length > 0)
747
- document.hooks = hooks;
748
- else
749
- delete document.hooks;
750
- // `version` is Cursor's schema marker, which Notifai writes only because it
751
- // is writing hooks. Alone it is residue, not settings.
752
- if (Object.keys(document).length === 1 && document.version !== undefined)
753
- delete document.version;
754
- return { document, added: [], replaced, removed: [] };
755
- }
756
- function readSettings(file) {
757
- if (!existsSync(file))
758
- return {};
759
- const source = readOwnedRegularFile(file);
760
- try {
761
- const parsed = JSON.parse(source);
762
- return typeof parsed === 'object' && parsed !== null ? parsed : {};
763
- }
764
- catch {
765
- throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`);
766
- }
767
- }
768
- /**
769
- * True when a single handler is ours. Deliberately per-handler, not per-group:
770
- * removing a whole group destroyed a user's own handler if they had added one
771
- * beside ours inside the same matcher group.
772
- */
773
- function isOurHandler(handler, scriptPath) {
774
- return isOurCommand(handler.command, scriptPath);
775
- }
776
- /** Drops only our handlers, keeping the group and anyone else's handlers. */
777
- function withoutOurs(groups, scriptPath) {
778
- let removed = false;
779
- const kept = [];
780
- for (const group of groups) {
781
- const hooks = group.hooks.filter((handler) => {
782
- const ours = isOurHandler(handler, scriptPath);
783
- if (ours)
784
- removed = true;
785
- return !ours;
786
- });
787
- if (hooks.length > 0)
788
- kept.push({ ...group, hooks });
789
- }
790
- return { groups: kept, removed };
791
- }
792
- /**
793
- * Refresh owned handlers in their existing slots. Codex's approval identity
794
- * includes group and handler indices, so stripping then appending an unchanged
795
- * handler can revoke its approval and stale an active session's Stop definition.
796
- * Missing handlers append after existing slots; foreign handlers and matcher
797
- * metadata stay in place.
798
- *
799
- * Retired handlers are considered in every event. Hosts without index-based
800
- * approval can remove them; strict Codex repair refuses ambiguous removal so
801
- * an explicit migration can account for native trust and active sessions.
802
- */
803
- export function mergeHooks(existing, incoming, scriptPath, options = {}) {
804
- const hooks = Object.create(null);
805
- const added = [];
806
- const replaced = [];
807
- const removed = [];
808
- for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
809
- if (!Array.isArray(groups)) {
810
- // Codex keeps `[hooks.state]` beside event tables. It is not a matcher list.
811
- ;
812
- hooks[event] = groups;
813
- continue;
814
- }
815
- const desired = (incoming[event] ?? []).map(group => ({ ...group, hooks: [...group.hooks] }));
816
- const refreshed = [];
817
- let hadOurs = false;
818
- for (const group of groups) {
819
- const next = [];
820
- for (const handler of group.hooks) {
821
- if (!isOurHandler(handler, scriptPath)) {
822
- next.push(handler);
823
- continue;
824
- }
825
- hadOurs = true;
826
- const replacementGroup = desired.find(candidate => candidate.hooks.some(replacement => handlerEvent(replacement.command) === handlerEvent(handler.command)));
827
- const index = replacementGroup?.hooks.findIndex(replacement => handlerEvent(replacement.command) === handlerEvent(handler.command)) ?? -1;
828
- if (replacementGroup !== undefined && index >= 0) {
829
- next.push(replacementGroup.hooks.splice(index, 1)[0]);
830
- }
831
- else if (options.preserveIdentities === true) {
832
- throw new Error(`Cannot preserve Codex hook identities for ${event}: duplicate or retired owned handlers require explicit migration. Review the existing hooks before retrying; the hook document was not changed.`);
833
- }
834
- }
835
- // Empty native groups also occupy an approval index.
836
- if (next.length > 0 || options.preserveIdentities === true)
837
- refreshed.push({ ...group, hooks: next });
838
- }
839
- for (const group of desired.filter(candidate => candidate.hooks.length > 0)) {
840
- // Append to a matching owned group without inserting before a foreign
841
- // handler. An omitted matcher and '*' both cover the complete event.
842
- const target = refreshed.find(candidate => (candidate.matcher ?? '*') === (group.matcher ?? '*') &&
843
- candidate.hooks.some(handler => isOurHandler(handler, scriptPath)));
844
- if (target === undefined)
845
- refreshed.push(group);
846
- else
847
- target.hooks.push(...group.hooks);
848
- }
849
- if (hadOurs) {
850
- if (event in incoming)
851
- replaced.push(event);
852
- else
853
- removed.push(event);
854
- }
855
- if (refreshed.length > 0)
856
- hooks[event] = refreshed;
857
- }
858
- for (const [event, groups] of Object.entries(incoming)) {
859
- if (!replaced.includes(event))
860
- added.push(event);
861
- if (!(event in (existing.hooks ?? {})))
862
- hooks[event] = groups;
863
- }
864
- return { document: { ...existing, hooks }, added, replaced, removed };
865
- }
866
- export function removeHooks(existing, scriptPath) {
867
- const hooks = Object.create(null);
868
- const replaced = [];
869
- for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
870
- if (!Array.isArray(groups)) {
871
- ;
872
- hooks[event] = groups;
873
- continue;
874
- }
875
- const { groups: foreign, removed } = withoutOurs(groups, scriptPath);
876
- if (removed)
877
- replaced.push(event);
878
- if (foreign.length > 0)
879
- hooks[event] = foreign;
880
- }
881
- const remaining = Object.keys(hooks).length > 0 ? hooks : undefined;
882
- const document = { ...existing };
883
- if (remaining === undefined)
884
- delete document.hooks;
885
- else
886
- document.hooks = remaining;
887
- return { document, added: [], replaced, removed: [] };
888
- }
889
- /**
890
- * Writes the settings file the way a tool that does not own it should.
891
- *
892
- * Truncating in place lost the user's whole harness configuration if the write
893
- * failed halfway, and clobbered a concurrent editor's changes; following a
894
- * symlink wrote through to a target they never named. So: refuse
895
- * anything that is not a regular file, write a sibling temp file, fsync it, and
896
- * rename over the original — atomic within a directory on every platform we
897
- * support.
898
- */
899
- export function applyPlan(file, document) {
900
- preparePlan(file, document)();
901
- }
902
- /** Validate a proposed document before any file in a multi-file edit changes. */
903
- export function preparePlan(file, document, previous = readOptionalOwnedRegularFile(file)) {
904
- const body = isTomlSettingsPath(file)
905
- ? tomlBody(file, document, previous)
906
- : `${JSON.stringify(document, null, 2)}\n`;
907
- const empty = isTomlSettingsPath(file) ? body.trim() === '' : isEmptyJsonDocument(body);
908
- return prepareFileWrite(file, previous, empty ? null : body);
909
- }
910
- function readOptionalOwnedRegularFile(file) {
911
- try {
912
- return readOwnedRegularFile(file);
913
- }
914
- catch (err) {
915
- if (err.code === 'ENOENT')
916
- return null;
917
- throw err;
918
- }
919
- }
920
- function prepareFileWrite(file, previous, next) {
921
- return () => {
922
- const current = readOptionalOwnedRegularFile(file);
923
- if (current !== previous)
924
- throw new Error(`Configuration changed while preparing ${file}; retry.`);
925
- if (current === next)
926
- return;
927
- if (next === null)
928
- rmSync(file, { force: true });
929
- else
930
- atomicWriteFileSync(file, next, { requireCurrentUserOwner: true });
931
- };
932
- }
933
- function isEmptyJsonDocument(body) {
934
- try {
935
- const parsed = JSON.parse(body);
936
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0;
937
- }
938
- catch {
939
- return false;
940
- }
941
- }
942
- /** Delete an emptied Codex hooks.json and a now-empty layer directory. */
943
- export function cleanupEmptiedCodexLayer(paths) {
944
- if (existsSync(paths.hooksJson)) {
945
- try {
946
- const document = loadSettings(paths.hooksJson);
947
- const keys = Object.keys(document);
948
- const hooksEmpty = document.hooks === undefined || Object.keys(document.hooks).length === 0;
949
- if (keys.every((key) => key === 'hooks') && hooksEmpty) {
950
- rmSync(paths.hooksJson, { force: true });
951
- }
952
- }
953
- catch {
954
- // Leave a file uninstall could not parse; the caller already reported it.
955
- }
956
- }
957
- if (!existsSync(paths.dir))
958
- return;
959
- try {
960
- if (readdirSync(paths.dir).length === 0)
961
- rmdirSync(paths.dir);
962
- }
963
- catch {
964
- // A concurrently replaced directory is not ours to fight.
965
- }
966
- }
967
- /**
968
- * A TOML file rewritten around its `[hooks]` tables, not over them.
969
- *
970
- * `config.toml` is the user's, and for most of them it is hand-written: model
971
- * choices, sandbox settings, MCP servers, and the comments explaining why. A
972
- * parse-and-restringify write returns all of that as data and none of it as
973
- * the document they wrote — comments gone, order normalized, spacing theirs no
974
- * longer. Notifai adds three hook handlers; it has no business reflowing the
975
- * rest.
976
- *
977
- * So everything outside `[hooks...]` is carried across as the exact bytes it
978
- * came in as, and only the hooks tables are regenerated — re-emitted at the end
979
- * of the file, because TOML does not care where a table sits and splicing into
980
- * the middle would mean reasoning about a region line by line. If the result
981
- * cannot be proven to parse back to the document asked for, refuse the write.
982
- */
983
- function tomlBody(file, document, previous) {
984
- if (previous === null)
985
- return `${stringifyToml(document)}\n`;
986
- if (sameTomlValue(parseToml(previous), document))
987
- return previous;
988
- const next = spliceTomlHooks(previous, document);
989
- if (next === null)
990
- throw new Error(`Cannot preserve unrelated TOML configuration in ${file}; no changes written.`);
991
- return next;
992
- }
993
- /** Only locate syntax; smol-toml owns validity and decoded key identity. */
994
- function tomlStatements(source) {
995
- parseToml(source);
996
- const statements = [];
997
- let offset = 0;
998
- while (offset < source.length) {
999
- if (/\s/.test(source[offset])) {
1000
- offset++;
1001
- continue;
1002
- }
1003
- if (source[offset] === '#') {
1004
- while (offset < source.length && source[offset] !== '\n')
1005
- offset++;
1006
- continue;
1007
- }
1008
- const start = offset;
1009
- let depth = 0;
1010
- let equals = -1;
1011
- const comments = [];
1012
- while (offset < source.length) {
1013
- const char = source[offset];
1014
- if (char === '"' || char === "'") {
1015
- const multiline = source.slice(offset, offset + 3) === char.repeat(3);
1016
- offset += multiline ? 3 : 1;
1017
- while (offset < source.length) {
1018
- if (char === '"' && source[offset] === '\\') {
1019
- offset += 2;
1020
- continue;
1021
- }
1022
- if (source[offset] === char) {
1023
- let count = 1;
1024
- while (source[offset + count] === char)
1025
- count++;
1026
- if (!multiline || count >= 3) {
1027
- offset += multiline ? count : 1;
1028
- break;
1029
- }
1030
- offset += count;
1031
- }
1032
- else
1033
- offset++;
1034
- }
1035
- continue;
1036
- }
1037
- if (char === '#') {
1038
- if (depth === 0)
1039
- break;
1040
- const commentStart = offset;
1041
- while (offset < source.length && source[offset] !== '\n')
1042
- offset++;
1043
- comments.push({ start: commentStart, end: offset });
1044
- continue;
1045
- }
1046
- if (char === '\n' && depth === 0)
1047
- break;
1048
- if (char === '[' || char === '{')
1049
- depth++;
1050
- if (char === ']' || char === '}')
1051
- depth--;
1052
- if (char === '=' && depth === 0 && equals === -1)
1053
- equals = offset;
1054
- offset++;
1055
- }
1056
- const table = source[start] === '[';
1057
- const syntax = source.slice(start, offset).trim();
1058
- // A standalone table or a dummy assignment decodes dotted/quoted/escaped
1059
- // keys without interpreting brackets, dots or quotes inside a key as syntax.
1060
- let decoded = parseToml(table ? syntax : `${source.slice(start, equals)} = 0`);
1061
- const keys = [];
1062
- while (decoded !== null && typeof decoded === 'object') {
1063
- if (Array.isArray(decoded)) {
1064
- decoded = decoded[0];
1065
- continue;
1066
- }
1067
- const entries = Object.entries(decoded);
1068
- if (entries.length !== 1)
1069
- break;
1070
- const [key, value] = entries[0];
1071
- keys.push(key);
1072
- decoded = value;
1073
- }
1074
- statements.push({ start, end: offset, table, path: keys, comments });
1075
- }
1076
- return statements;
1077
- }
1078
- /** Splice syntax spans only; even comments inside removed arrays survive. */
1079
- function omitTomlStatements(source, removed) {
1080
- let next = '';
1081
- let offset = 0;
1082
- for (const statement of removed) {
1083
- next += source.slice(offset, statement.start);
1084
- for (const comment of statement.comments)
1085
- next += `${source.slice(comment.start, comment.end)}\n`;
1086
- offset = statement.end;
1087
- }
1088
- return next + source.slice(offset);
1089
- }
1090
- /** Replace hook syntax only, validating all unrelated parsed values. */
1091
- function spliceTomlHooks(source, document) {
1092
- try {
1093
- let table = [];
1094
- const removed = tomlStatements(source).filter((statement) => {
1095
- if (statement.table)
1096
- table = statement.path;
1097
- return (statement.table ? table : [...table, ...statement.path])[0] === 'hooks';
1098
- });
1099
- const head = omitTomlStatements(source, removed);
1100
- const body = document.hooks === undefined ? '' : stringifyToml({ hooks: document.hooks });
1101
- const spliced = body === '' ? head : `${head}${head === '' || head.endsWith('\n') ? '' : '\n'}${body}\n`;
1102
- return sameTomlValue(parseToml(spliced), document) ? spliced : null;
1103
- }
1104
- catch {
1105
- return null;
1106
- }
1107
- }
1108
- /** Structural equality for parsed TOML, which carries dates as well as data. */
1109
- function sameTomlValue(a, b) {
1110
- if (Object.is(a, b))
1111
- return true;
1112
- if (a instanceof Date && b instanceof Date)
1113
- return a.getTime() === b.getTime();
1114
- if (Array.isArray(a) || Array.isArray(b)) {
1115
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
1116
- return false;
1117
- return a.every((item, index) => sameTomlValue(item, b[index]));
1118
- }
1119
- if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
1120
- return false;
1121
- const left = a;
1122
- const right = b;
1123
- const keys = Object.keys(left);
1124
- if (keys.length !== Object.keys(right).length)
1125
- return false;
1126
- return keys.every((key) => Object.hasOwn(right, key) && sameTomlValue(left[key], right[key]));
1127
- }
1128
- export function loadSettings(file) {
1129
- return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file);
1130
- }
1131
- export function isTomlSettingsPath(file) {
1132
- return file.endsWith('.toml');
1133
- }
1134
- function readTomlSettings(file) {
1135
- if (!existsSync(file))
1136
- return {};
1137
- const source = readOwnedRegularFile(file);
1138
- try {
1139
- const parsed = parseToml(source);
1140
- return typeof parsed === 'object' && parsed !== null ? parsed : {};
1141
- }
1142
- catch {
1143
- throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`);
1144
- }
1145
- }
1146
- export function loadCursorSettings(file) {
1147
- return readCursorSettings(file);
1148
- }
1149
- /** Codex's canonical event spelling inside its persisted trust keys. */
1150
- function codexEventName(event) {
1151
- return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
1152
- }
1153
- /** Recursively sort object keys before hashing, matching Codex's canonical JSON. */
1154
- function canonicalValue(value) {
1155
- if (Array.isArray(value))
1156
- return value.map(canonicalValue);
1157
- if (typeof value !== 'object' || value === null)
1158
- return value;
1159
- return Object.fromEntries(Object.entries(value)
1160
- .sort(([left], [right]) => left.localeCompare(right))
1161
- .map(([key, child]) => [key, canonicalValue(child)]));
1162
- }
1163
- /**
1164
- * Best-effort mirror of the identity current Codex builds compare with
1165
- * `hooks.state.*.trusted_hash`. Codex hashes `type`, `command`, `timeout`,
1166
- * `async`, `statusMessage`, and `additionalContextLimit` (omitting the 2,500
1167
- * default). This persisted format is diagnostic evidence, not an API: Codex's
1168
- * `/hooks` UI remains authoritative and Notifai never writes the trust store.
1169
- */
1170
- /**
1171
- * Codex's default additionalContext spill threshold, in approximate tokens.
1172
- * Codex strips this value before hashing, so an explicit 2,500 matches omit.
1173
- */
1174
- const CODEX_DEFAULT_ADDITIONAL_CONTEXT_LIMIT = 2_500;
1175
- export function codexHookIdentityHash(handler) {
1176
- const command = {
1177
- type: 'command',
1178
- command: handler.command,
1179
- timeout: Math.max(1, handler.timeout ?? 600),
1180
- async: handler.async ?? false,
1181
- ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
1182
- ...(handler.additionalContextLimit === undefined
1183
- || handler.additionalContextLimit === CODEX_DEFAULT_ADDITIONAL_CONTEXT_LIMIT
1184
- ? {}
1185
- : { additionalContextLimit: handler.additionalContextLimit }),
1186
- };
1187
- const normalized = canonicalValue({
1188
- event_name: codexEventName(handler.event),
1189
- hooks: [command],
1190
- });
1191
- return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`;
1192
- }
1193
- function codexTrustState(env) {
1194
- const file = path.join(codexGlobalDir(env), 'config.toml');
1195
- if (!existsSync(file))
1196
- return {};
1197
- try {
1198
- const parsed = parseToml(readFileSync(file, 'utf8'));
1199
- const hooks = parsed['hooks'];
1200
- if (typeof hooks !== 'object' || hooks === null)
1201
- return {};
1202
- const state = hooks['state'];
1203
- return typeof state === 'object' && state !== null ? state : {};
1204
- }
1205
- catch {
1206
- return {};
1207
- }
1208
- }
1209
- /** Best-effort mirror of current Codex's persisted key for one handler. */
1210
- export function codexTrustKey(installation, handler) {
1211
- let source = installation.file;
1212
- try {
1213
- source = path.join(realpathSync(path.dirname(source)), path.basename(source));
1214
- }
1215
- catch {
1216
- // Codex falls back to the logical source path when canonicalization fails,
1217
- // so the diagnostic must do the same.
1218
- }
1219
- return `${source}:${codexEventName(handler.event)}:${handler.groupIndex}:${handler.handlerIndex}`;
1220
- }
1221
- /**
1222
- * Stable identity of the one Codex Stop definition an Agent Session loaded.
1223
- *
1224
- * Codex materializes hook handlers into the session runtime. A later config
1225
- * change can therefore make the file on disk current while an older session
1226
- * keeps executing the definition it loaded at activation. Include Codex's
1227
- * source key as well as the normalized handler identity so moving an otherwise
1228
- * byte-identical definition between project and global layers also requires a
1229
- * fresh session.
1230
- */
1231
- export function codexStopDefinitionFingerprint(installations) {
1232
- const candidates = installations
1233
- .filter((installation) => installation.harness === 'codex')
1234
- .flatMap((installation) => installation.handlers
1235
- // The attend handler shares the Stop group; it is not the Stop definition.
1236
- .filter((handler) => handler.event === 'Stop' && handlerEvent(handler.command) !== 'attend')
1237
- .map((handler) => ({ installation, handler })));
1238
- if (candidates.length !== 1)
1239
- return undefined;
1240
- const candidate = candidates[0];
1241
- if (candidate === undefined)
1242
- return undefined;
1243
- const { installation, handler } = candidate;
1244
- return createHash('sha256')
1245
- .update(`${codexTrustKey(installation, handler)}\0${codexHookIdentityHash(handler)}`)
1246
- .digest('hex');
1247
- }
1248
- /**
1249
- * Trust defects that make installed Codex handlers look present while Codex
1250
- * skips them. Trust is user-owned; the supported repair is Codex's `/hooks`
1251
- * review UI, never writing the trust store on the user's behalf. Because the
1252
- * persisted shape is not a public Codex contract, this diagnosis can drift;
1253
- * `/hooks` remains the source of truth.
1254
- */
1255
- export function codexTrustProblems(installations, env = process.env) {
1256
- const state = codexTrustState(env);
1257
- return installations
1258
- .filter((installation) => installation.harness === 'codex')
1259
- .flatMap((installation) => installation.handlers.flatMap((handler) => {
1260
- const key = codexTrustKey(installation, handler);
1261
- const entry = state[key];
1262
- const trustedHash = typeof entry === 'object' && entry !== null
1263
- ? entry['trusted_hash']
1264
- : undefined;
1265
- const currentHash = codexHookIdentityHash(handler);
1266
- if (typeof entry === 'object' &&
1267
- entry !== null &&
1268
- entry['enabled'] === false) {
1269
- return [
1270
- `${handler.event} in ${installation.file} is disabled in Codex; open \`/hooks\` and enable the Notifai handler`,
1271
- ];
1272
- }
1273
- if (trustedHash === currentHash)
1274
- return [];
1275
- return [
1276
- `${handler.event} in ${installation.file} is ${typeof trustedHash === 'string' ? 'changed since it was trusted' : 'not trusted'}; open \`/hooks\` in Codex and approve the Notifai handler`,
1277
- ];
1278
- }));
1279
- }
1280
- /**
1281
- * Trust defects in the handlers Question Routing runs. The Session Attendant
1282
- * is presence, never a routing gate: an attend handler still awaiting
1283
- * approval is reported by doctor and init and never refuses a question.
1284
- */
1285
- export function codexRoutingTrustProblems(installations, env = process.env) {
1286
- return codexTrustProblems(installations.map((installation) => ({
1287
- ...installation,
1288
- handlers: installation.handlers.filter((handler) => !['attend', 'post-tool-use'].includes(handlerEvent(handler.command) ?? '')),
1289
- })), env);
1290
- }
1291
- /**
1292
- * Any Notifai handler, whatever checkout wrote it.
1293
- *
1294
- * Deliberately looser than `isOurHandler`: this answers "has Notifai been set
1295
- * up here at all", and a handler installed from a second checkout is still
1296
- * evidence that it has (and is itself worth reporting).
1297
- */
1298
- function isNotifaiCommand(command) {
1299
- return HOOK_EVENT_COMMAND_RE.test(command);
1300
- }
1301
- /**
1302
- * Native plugin ids that would fire beside document hooks if left enabled.
1303
- *
1304
- * Notifai no longer wires through harness plugins. A leftover enablement from
1305
- * a trial or an older experiment still runs the same adapter command, so
1306
- * document handlers plus that plugin fire every event twice. Matching is the
1307
- * plugin name `notifai` with an optional `@marketplace` suffix — never a
1308
- * substring of someone else's plugin id.
1309
- */
1310
- function isNotifaiNativePluginKey(key) {
1311
- const id = key.split('@')[0]?.trim() ?? '';
1312
- return id.toLowerCase() === 'notifai';
1313
- }
1314
- function pluginEntryIsEnabled(value) {
1315
- if (value === false)
1316
- return false;
1317
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
1318
- return value['enabled'] !== false;
1319
- }
1320
- return true;
1321
- }
1322
- export function notifaiNativePluginEnablementKeys(value) {
1323
- if (value === null || typeof value !== 'object' || Array.isArray(value))
1324
- return [];
1325
- return Object.entries(value)
1326
- .filter(([key, enabled]) => isNotifaiNativePluginKey(key) && pluginEntryIsEnabled(enabled))
1327
- .map(([key]) => key);
1328
- }
1329
- export function stripObsoleteNotifaiPluginEnablement(document) {
1330
- const next = { ...document };
1331
- const removed = [];
1332
- for (const field of ['enabledPlugins', 'plugins']) {
1333
- const value = next[field];
1334
- if (value === null || typeof value !== 'object' || Array.isArray(value))
1335
- continue;
1336
- const record = { ...value };
1337
- let changed = false;
1338
- for (const key of Object.keys(record)) {
1339
- if (!isNotifaiNativePluginKey(key) || !pluginEntryIsEnabled(record[key]))
1340
- continue;
1341
- delete record[key];
1342
- removed.push(key);
1343
- changed = true;
1344
- }
1345
- if (!changed)
1346
- continue;
1347
- if (Object.keys(record).length === 0)
1348
- delete next[field];
1349
- else
1350
- next[field] = record;
1351
- }
1352
- return { document: next, removed };
1353
- }
1354
- /**
1355
- * Remove enabled Notifai plugin tables only after lexical ownership and full
1356
- * semantic preservation are proven. Unsupported dotted/inline definitions
1357
- * fail before mutation, rather than silently leaving a second firing path.
1358
- */
1359
- export function spliceOutNotifaiPluginTables(source) {
1360
- const expected = parseToml(source);
1361
- const plugins = expected['plugins'];
1362
- const removed = notifaiNativePluginEnablementKeys(plugins);
1363
- if (removed.length === 0)
1364
- return { next: source, removed };
1365
- const remaining = { ...plugins };
1366
- for (const key of removed)
1367
- delete remaining[key];
1368
- expected['plugins'] = remaining;
1369
- let dropping = false;
1370
- const omitted = tomlStatements(source).filter((statement) => {
1371
- if (statement.table) {
1372
- dropping = statement.path[0] === 'plugins' && removed.includes(statement.path[1] ?? '');
1373
- }
1374
- return dropping;
1375
- });
1376
- const next = omitTomlStatements(source, omitted);
1377
- const parsed = parseToml(next);
1378
- // Removing the last child table may also remove its implicit parent.
1379
- for (const value of [expected, parsed]) {
1380
- const entries = value['plugins'];
1381
- if (entries && typeof entries === 'object' && !Array.isArray(entries) && Object.keys(entries).length === 0) {
1382
- delete value['plugins'];
1383
- }
1384
- }
1385
- if (!sameTomlValue(parsed, expected)) {
1386
- throw new Error('Cannot safely remove obsolete native plugin wiring from this TOML form; disable the Notifai plugin or use [plugins."notifai"] table form and retry. No changes written.');
1387
- }
1388
- return { next, removed };
1389
- }
1390
- /**
1391
- * Leftover native plugin enablement that would fire beside document hooks.
1392
- *
1393
- * Reads only harness settings files this installer already owns inspecting.
1394
- * It never shells out to a plugin CLI and never writes a plugin.
1395
- */
1396
- export function findObsoleteNotifaiPluginWiring(env = process.env, platform = process.platform) {
1397
- const found = [];
1398
- const claudeFile = settingsFile('claude-code', env, platform);
1399
- if (existsSync(claudeFile)) {
1400
- try {
1401
- const keys = notifaiNativePluginEnablementKeys(loadSettings(claudeFile)['enabledPlugins']);
1402
- if (keys.length > 0)
1403
- found.push({ harness: 'claude-code', file: claudeFile, keys });
1404
- }
1405
- catch {
1406
- // Unreadable settings are reported by the ordinary install/doctor path.
1407
- }
1408
- }
1409
- const codexToml = codexMachineLayerPaths(env, platform).configToml;
1410
- if (existsSync(codexToml)) {
1411
- try {
1412
- const keys = notifaiNativePluginEnablementKeys(loadSettings(codexToml)['plugins']);
1413
- if (keys.length > 0)
1414
- found.push({ harness: 'codex', file: codexToml, keys });
1415
- }
1416
- catch {
1417
- // Same: leave diagnosis to the settings reader that already ran.
1418
- }
1419
- }
1420
- return found;
1421
- }
1422
- /**
1423
- * Plan native plugin retirement without changing configuration. The caller
1424
- * validates every destination before applying this plan under the layer lock.
1425
- */
1426
- export function prepareCodexPluginCleanup(file) {
1427
- const previous = readOptionalOwnedRegularFile(file);
1428
- if (previous === null)
1429
- return { source: null, removed: [], apply: prepareFileWrite(file, null, null) };
1430
- const { next, removed } = spliceOutNotifaiPluginTables(previous);
1431
- return { source: next, removed, apply: prepareFileWrite(file, previous, next) };
1432
- }
1433
- /**
1434
- * The one Machine installation each harness would read a Notifai handler from.
1435
- */
1436
- export function findInstallations(env = process.env, adapterHome, platform = process.platform) {
1437
- return HOOK_INSTALLABLE_HARNESSES.flatMap((harness) => collectInstallations(harness, machineHookFiles(harness, env, platform), adapterHome, platform, env));
1438
- }
1439
- /**
1440
- * Notifai handlers a previous build left in Project-scoped files.
1441
- *
1442
- * These are never an install target and never a fallback: they are residue to
1443
- * report and remove once the Machine installation is proven current. Foreign
1444
- * handlers sharing those files are not returned and are never touched.
1445
- */
1446
- export function findLegacyProjectInstallations(cwd, env = process.env, adapterHome, platform = process.platform) {
1447
- const machine = new Set(HOOK_INSTALLABLE_HARNESSES.flatMap((harness) => machineHookFiles(harness, env, platform).map((file) => path.resolve(file))));
1448
- return HOOK_INSTALLABLE_HARNESSES.flatMap((harness) => collectInstallations(harness,
1449
- // A Project checkout that happens to be the harness home would otherwise
1450
- // report the Machine installation as legacy residue and delete it.
1451
- legacyProjectHookFiles(harness, cwd).filter((file) => !machine.has(path.resolve(file))), adapterHome, platform, env));
1452
- }
1453
- function collectInstallations(harness, files, adapterHome, platform, env) {
1454
- const nodePath = inspectHookAdapter(adapterHome, platform).target?.execPath;
1455
- const commandOptions = {
1456
- platform,
1457
- ...(nodePath === undefined ? {} : { nodePath }),
1458
- };
1459
- const found = [];
1460
- for (const file of files) {
1461
- if (!existsSync(file))
1462
- continue;
1463
- if (harness === 'hermes') {
1464
- if (!isOurHermesPlugin(path.dirname(file)))
1465
- continue;
1466
- found.push({
1467
- harness,
1468
- file,
1469
- handlers: [],
1470
- ...(hermesPluginCurrent(adapterHome, env) ? {} : {
1471
- problems: ['Hermes plugin does not match the current Notifai adapter; rerun `notifai hooks install --harness hermes`'],
1472
- }),
1473
- });
1474
- continue;
1475
- }
1476
- // OpenCode's adapter is a plugin module, not a settings document, so it
1477
- // is reported as one installation covering all three events rather than
1478
- // parsed for handlers.
1479
- if (harness === 'opencode' || harness === 'openclaw') {
1480
- let source;
1481
- try {
1482
- source = readOwnedRegularFile(file);
1483
- }
1484
- catch {
1485
- continue;
1486
- }
1487
- const target = harness === 'openclaw' ? openclawPluginTarget(source) : opencodePluginTarget(source);
1488
- if (target === null)
1489
- continue;
1490
- const label = harness === 'openclaw' ? 'OpenClaw' : 'OpenCode';
1491
- const problems = [
1492
- ...(!target.current
1493
- ? [`obsolete ${label} event wiring; rerun \`notifai hooks install --harness ${harness}\``]
1494
- : []),
1495
- ...(target.adapter !== hookAdapterPath(adapterHome)
1496
- ? [
1497
- `${label} still names a mutable CLI or runtime path; rerun \`notifai hooks install --harness ${harness}\``,
1498
- ]
1499
- : []),
1500
- ];
1501
- const events = harness === 'openclaw' ? OPENCLAW_EVENTS : OPENCODE_EVENTS;
1502
- found.push({
1503
- harness,
1504
- file,
1505
- ...(problems.length > 0 ? { problems } : {}),
1506
- handlers: events.map(([event, hookEvent]) => ({
1507
- event,
1508
- groupIndex: 0,
1509
- handlerIndex: 0,
1510
- command: hookCommand(target.adapter, hookEvent, harness, {
1511
- ...commandOptions,
1512
- ...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
1513
- }),
1514
- ...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
1515
- })),
1516
- });
1517
- continue;
1518
- }
1519
- if (harness === 'cursor') {
1520
- let document;
1521
- try {
1522
- document = readCursorSettings(file);
1523
- }
1524
- catch {
1525
- continue;
1526
- }
1527
- const handlers = locateCursorHandlers(document);
1528
- if (handlers.length > 0) {
1529
- const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
1530
- found.push({ harness, file, handlers, ...(problems.length > 0 ? { problems } : {}) });
1531
- }
1532
- continue;
1533
- }
1534
- let document;
1535
- try {
1536
- document = loadSettings(file);
1537
- }
1538
- catch {
1539
- continue;
1540
- }
1541
- const handlers = locateHandlers(document);
1542
- if (handlers.length > 0) {
1543
- const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
1544
- found.push({ harness, file, handlers, ...(problems.length > 0 ? { problems } : {}) });
1545
- }
1546
- }
1547
- return found;
1548
- }
1549
- function harnessMarkerProblems(harness, handlers, adapterHome, options = {}) {
1550
- const problems = [];
1551
- if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
1552
- problems.push(`installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``);
1553
- }
1554
- const expected = `${hookCommandPrefix(hookAdapterPath(adapterHome), options)}hook `;
1555
- if (!handlers.every((handler) => handler.command.startsWith(expected))) {
1556
- problems.push('installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter');
1557
- }
1558
- return problems;
1559
- }
1560
- function locateCursorHandlers(document) {
1561
- const handlers = [];
1562
- for (const [event, eventHandlers] of Object.entries(document.hooks ?? {})) {
1563
- eventHandlers.forEach((handler, handlerIndex) => {
1564
- if (isNotifaiCommand(handler.command)) {
1565
- handlers.push({
1566
- event,
1567
- groupIndex: 0,
1568
- handlerIndex,
1569
- command: handler.command,
1570
- ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
1571
- });
1572
- }
1573
- });
1574
- }
1575
- return handlers;
1576
- }
1577
- function locateAllHandlers(document) {
1578
- const handlers = [];
1579
- for (const [event, groups] of Object.entries(document.hooks ?? {})) {
1580
- if (!Array.isArray(groups))
1581
- continue;
1582
- groups.forEach((group, groupIndex) => {
1583
- group.hooks?.forEach((handler, handlerIndex) => {
1584
- if (typeof handler?.command !== 'string')
1585
- return;
1586
- handlers.push({
1587
- event,
1588
- groupIndex,
1589
- handlerIndex,
1590
- command: handler.command,
1591
- ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
1592
- ...(handler.async === undefined ? {} : { async: handler.async }),
1593
- ...(handler.asyncRewake === undefined ? {} : { asyncRewake: handler.asyncRewake }),
1594
- ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
1595
- ...(typeof handler.additionalContextLimit === 'number'
1596
- ? { additionalContextLimit: handler.additionalContextLimit }
1597
- : {}),
1598
- });
1599
- });
1600
- });
1601
- }
1602
- return handlers;
1603
- }
1604
- function locateHandlers(document) {
1605
- return locateAllHandlers(document).filter((handler) => isNotifaiCommand(handler.command));
1606
- }
1607
- /** The hook event a handler's command actually invokes, e.g. `stop`. */
1608
- export function handlerEvent(command) {
1609
- return / hook ([a-z-]+)/.exec(command)?.[1] ?? null;
1610
- }
1611
- function readOwnedRegularFile(file) {
1612
- const stat = lstatSync(file);
1613
- if (stat.isSymbolicLink() || !stat.isFile()) {
1614
- throw new Error(`${file} is not a regular file${stat.isSymbolicLink() ? ' (symlink)' : ''}; refusing to read it.`);
1615
- }
1616
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
1617
- if (uid !== undefined && stat.uid !== uid) {
1618
- throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
1619
- }
1620
- return readFileSync(file, 'utf8');
1621
- }
1622
- //# sourceMappingURL=install-hooks.js.map