@raidiant/notifai 4.0.0 → 5.0.0

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 (66) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +5 -0
  3. package/dist/commands.d.ts +10 -1
  4. package/dist/commands.js +174 -58
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +14 -5
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +11 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +72 -5
  17. package/dist/hooks.js +407 -79
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/logging.d.ts +8 -0
  25. package/dist/logging.js +8 -0
  26. package/dist/logging.js.map +1 -1
  27. package/dist/main.js +44 -17
  28. package/dist/main.js.map +1 -1
  29. package/dist/opencode-plugin.d.ts +8 -0
  30. package/dist/opencode-plugin.js +24 -162
  31. package/dist/opencode-plugin.js.map +1 -1
  32. package/dist/send.d.ts +6 -0
  33. package/dist/send.js +25 -14
  34. package/dist/send.js.map +1 -1
  35. package/dist/ui/help.d.ts +0 -1
  36. package/dist/ui/help.js +6 -3
  37. package/dist/ui/help.js.map +1 -1
  38. package/dist/ui/theme.d.ts +0 -15
  39. package/dist/ui/theme.js +2 -18
  40. package/dist/ui/theme.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/commands.test.ts +185 -51
  43. package/src/commands.ts +198 -56
  44. package/src/config-schema.test.ts +1 -1
  45. package/src/config-schema.ts +16 -5
  46. package/src/config.test.ts +41 -9
  47. package/src/config.ts +13 -0
  48. package/src/file-lock.test.ts +41 -0
  49. package/src/file-lock.ts +59 -9
  50. package/src/harnesses.test.ts +37 -29
  51. package/src/harnesses.ts +1 -4
  52. package/src/hooks.test.ts +358 -21
  53. package/src/hooks.ts +457 -97
  54. package/src/install-hooks.test.ts +232 -12
  55. package/src/install-hooks.ts +241 -37
  56. package/src/interactive.ts +1 -1
  57. package/src/logging-process-worker.test.ts +8 -2
  58. package/src/logging.test.ts +33 -18
  59. package/src/logging.ts +11 -0
  60. package/src/main.ts +57 -23
  61. package/src/opencode-plugin.ts +24 -164
  62. package/src/question-caps.test.ts +49 -0
  63. package/src/send.ts +32 -16
  64. package/src/skill.test.ts +199 -146
  65. package/src/ui/help.ts +8 -3
  66. package/src/ui/theme.ts +2 -16
@@ -32,6 +32,8 @@ import {
32
32
  codexProjectRoot,
33
33
  findInstallations,
34
34
  codexHookIdentityHash,
35
+ codexCoexistenceNotes,
36
+ codexHomeNote,
35
37
  codexRepresentationProblems,
36
38
  codexTrustKey,
37
39
  codexTrustProblems,
@@ -270,7 +272,13 @@ describe('settings locations', () => {
270
272
  expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
271
273
  })
272
274
 
273
- it('stays on hooks.json when it alone has hooks, even if config.toml already exists', () => {
275
+ /**
276
+ * A `hooks.json` nobody has proven is Notifai's is not Notifai's to write.
277
+ * Joining it would avoid Codex's dual-representation warning, but a hook
278
+ * entry carries no owner, so the file may belong to a tool that regenerates
279
+ * it wholesale and would drop our handlers without a word.
280
+ */
281
+ it('writes config.toml rather than joining a hooks.json that is not ours', () => {
274
282
  const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
275
283
  const layer = path.join(repo, '.codex')
276
284
  mkdirSync(layer, { recursive: true })
@@ -284,7 +292,7 @@ describe('settings locations', () => {
284
292
  },
285
293
  })
286
294
 
287
- expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'hooks.json'))
295
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'config.toml'))
288
296
  })
289
297
 
290
298
  it('does not migrate our hooks when both representations already exist', () => {
@@ -557,25 +565,120 @@ describe('finding what is installed', () => {
557
565
  expect(codex?.handlers[0]?.groupIndex).toBe(1)
558
566
  })
559
567
 
560
- it('names a Codex layer that loads both representations', () => {
568
+ /**
569
+ * The shape a session manager leaves behind: it owns `hooks.json` for its own
570
+ * agent lifecycle, Notifai sits in `config.toml`, and every handler fires
571
+ * exactly once. Reported as a Notifai failure for a whole release, it sent
572
+ * users to hand-edit a file another program regenerates — for nothing.
573
+ */
574
+ it('says nothing when the other representation holds only foreign hooks', () => {
575
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-foreign-rep-'))
576
+ const layer = path.join(cwd, '.codex')
577
+ mkdirSync(layer, { recursive: true })
578
+ applyPlan(path.join(layer, 'hooks.json'), {
579
+ hooks: {
580
+ Stop: [{ hooks: [{ type: 'command', command: '/bin/sh /opt/other-tool/hook.sh' }] }],
581
+ UserPromptSubmit: [
582
+ { hooks: [{ type: 'command', command: '/bin/sh /opt/other-tool/hook.sh' }] },
583
+ ],
584
+ },
585
+ })
586
+ writeFileSync(
587
+ path.join(layer, 'config.toml'),
588
+ stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
589
+ )
590
+
591
+ expect(codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })).toEqual([])
592
+ })
593
+
594
+ it('names the foreign file and says Notifai will not touch it', () => {
595
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-coexist-'))
596
+ const layer = path.join(cwd, '.codex')
597
+ mkdirSync(layer, { recursive: true })
598
+ applyPlan(path.join(layer, 'hooks.json'), {
599
+ hooks: {
600
+ Stop: [{ hooks: [{ type: 'command', command: '/bin/sh /opt/other-tool/hook.sh' }] }],
601
+ },
602
+ })
603
+ writeFileSync(
604
+ path.join(layer, 'config.toml'),
605
+ stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
606
+ )
607
+
608
+ const notes = codexCoexistenceNotes(cwd, { HOME: path.join(cwd, 'isolated-home') })
609
+ expect(notes).toHaveLength(1)
610
+ expect(notes[0]).toContain(path.join(layer, 'hooks.json'))
611
+ expect(notes[0]).toContain('Notifai will not modify it')
612
+ expect(notes[0]).toContain('not a Notifai fault')
613
+ expect(notes[0]).toContain("can end a turn before Notifai's answer arrives")
614
+ })
615
+
616
+ it('says nothing when Notifai is the only thing in the layer', () => {
617
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-coexist-alone-'))
618
+ const layer = path.join(cwd, '.codex')
619
+ mkdirSync(layer, { recursive: true })
620
+ writeFileSync(
621
+ path.join(layer, 'config.toml'),
622
+ stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
623
+ )
624
+
625
+ expect(codexCoexistenceNotes(cwd, { HOME: path.join(cwd, 'isolated-home') })).toEqual([])
626
+ })
627
+
628
+ it('names the events a Notifai copy in each file would notify twice for', () => {
561
629
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-dual-rep-'))
562
630
  const layer = path.join(cwd, '.codex')
563
631
  mkdirSync(layer, { recursive: true })
564
632
  applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
565
633
  writeFileSync(
566
634
  path.join(layer, 'config.toml'),
567
- ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
568
- '\n',
569
- ),
635
+ stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
570
636
  )
571
637
 
572
638
  const problems = codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })
573
639
  expect(problems).toHaveLength(1)
574
- expect(problems[0]).toContain('loading hooks from both')
575
640
  expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
576
641
  expect(problems[0]).toContain(path.join(layer, 'config.toml'))
577
- expect(problems[0]).toMatch(/prefer a single representation for this layer/)
578
642
  expect(problems[0]).toMatch(/Stop/)
643
+ expect(problems[0]).toMatch(/notifies twice per turn for UserPromptSubmit, Stop, SessionEnd/)
644
+ expect(problems[0]).toMatch(/notifai hooks uninstall --harness codex/)
645
+ expect(problems[0]).not.toMatch(/--global/)
646
+ })
647
+
648
+ it('reports Notifai split across both files, where only one copy stays current', () => {
649
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-split-rep-'))
650
+ const layer = path.join(cwd, '.codex')
651
+ mkdirSync(layer, { recursive: true })
652
+ const { Stop, ...rest } = ours()
653
+ applyPlan(path.join(layer, 'hooks.json'), { hooks: { Stop: Stop! } })
654
+ writeFileSync(
655
+ path.join(layer, 'config.toml'),
656
+ stringifyToml({ hooks: rest as unknown as Record<string, unknown> }),
657
+ )
658
+
659
+ const problems = codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })
660
+ expect(problems).toHaveLength(1)
661
+ expect(problems[0]).toMatch(/split between them/)
662
+ expect(problems[0]).toMatch(/Stop/)
663
+ expect(problems[0]).toMatch(/UserPromptSubmit/)
664
+ expect(problems[0]).not.toMatch(/twice per turn/)
665
+ })
666
+
667
+ it('scopes the remedy to the global layer it found the duplicate in', () => {
668
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-global-rep-'))
669
+ const home = path.join(cwd, 'isolated-home')
670
+ const layer = path.join(home, '.codex')
671
+ mkdirSync(layer, { recursive: true })
672
+ applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
673
+ writeFileSync(
674
+ path.join(layer, 'config.toml'),
675
+ stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
676
+ )
677
+
678
+ const problems = codexRepresentationProblems(cwd, { HOME: home })
679
+ expect(problems).toHaveLength(1)
680
+ expect(problems[0]).toMatch(/notifai hooks uninstall --harness codex --global/)
681
+ expect(problems[0]).toMatch(/notifai hooks install --harness codex --global/)
579
682
  })
580
683
 
581
684
  it('reports handlers from either harness with their positions', () => {
@@ -725,16 +828,21 @@ describe('the OpenCode adapter', () => {
725
828
  expect(source).not.toContain('"permission.ask"')
726
829
  })
727
830
 
728
- it('injects collected late answers into the next OpenCode user message', () => {
729
- expect(source).toContain('hookSpecificOutput?.additionalContext')
730
- expect(source).toContain('synthetic: true')
831
+ it('publishes the session pointer on a user message without injecting content', () => {
832
+ // The CLI has never emitted `hookSpecificOutput.additionalContext`, so the
833
+ // injection this used to assert could not fire. The handler earns its place
834
+ // by publishing the pointer that lets `notifai ask` find this session.
835
+ expect(source).toContain('"chat.message"')
836
+ expect(source).toContain('hook_event_name: "UserPromptSubmit"')
837
+ expect(source).not.toContain('additionalContext')
838
+ expect(source).not.toContain('synthetic: true')
731
839
  })
732
840
 
733
841
  it('injects an exact active-harness and session marker into OpenCode shells', () => {
734
842
  expect(source).toContain('"shell.env"')
735
843
  expect(source).toContain('NOTIFAI_ACTIVE_HARNESS')
736
844
  expect(source).toContain('NOTIFAI_ACTIVE_SESSION_ID')
737
- expect(source).toContain('input.sessionID')
845
+ expect(source).toContain('input?.sessionID')
738
846
  })
739
847
 
740
848
  it('carries the ownership marker so a second checkout replaces it', () => {
@@ -960,3 +1068,115 @@ describe('Windows hook commands and discovery', () => {
960
1068
  })
961
1069
  })
962
1070
  })
1071
+
1072
+ /**
1073
+ * `CODEX_HOME` replaces the Codex home rather than shadowing it: `codex doctor`
1074
+ * with the variable set resolves every path inside it and never reads
1075
+ * `~/.codex`. Installing to the account default anyway would write hooks the
1076
+ * running agent cannot see, so Notifai follows the variable — and says so,
1077
+ * because the same divergence is what makes a correct install look absent.
1078
+ */
1079
+ describe('codex home note', () => {
1080
+ it('is silent when CODEX_HOME is unset', () => {
1081
+ expect(codexHomeNote({ HOME: '/home/someone' }, 'linux')).toBeNull()
1082
+ })
1083
+
1084
+ it('is silent when CODEX_HOME names the account default', () => {
1085
+ expect(
1086
+ codexHomeNote({ HOME: '/home/someone', CODEX_HOME: '/home/someone/.codex' }, 'linux'),
1087
+ ).toBeNull()
1088
+ })
1089
+
1090
+ it('names both homes and why the other one is not read', () => {
1091
+ const note = codexHomeNote({ HOME: '/home/someone', CODEX_HOME: '/runtime/home' }, 'linux')
1092
+ expect(note).toContain('/runtime/home')
1093
+ expect(note).toContain('/home/someone/.codex')
1094
+ expect(note).toContain('not read by Codex in this shell')
1095
+ expect(note).toContain('trust by absolute file path')
1096
+ })
1097
+ })
1098
+
1099
+ /**
1100
+ * `config.toml` is the user's file and usually hand-written. Notifai adds three
1101
+ * handlers to it and must hand back everything else exactly as it arrived —
1102
+ * comments, ordering, and spacing included.
1103
+ */
1104
+ describe('writing config.toml around what is already there', () => {
1105
+ const layerFor = (name: string): string => {
1106
+ const repo = mkdtempSync(path.join(os.tmpdir(), name))
1107
+ const layer = path.join(repo, '.codex')
1108
+ mkdirSync(layer, { recursive: true })
1109
+ return layer
1110
+ }
1111
+
1112
+ it('leaves every byte outside the hooks tables untouched', () => {
1113
+ const layer = layerFor('notifai-toml-comments-')
1114
+ const file = path.join(layer, 'config.toml')
1115
+ const original = [
1116
+ '# why this model, in the user\'s own words',
1117
+ 'model = "gpt-5.6" # trailing note',
1118
+ '',
1119
+ '[mcp_servers.linear]',
1120
+ '# a comment nobody but the user cares about',
1121
+ 'command = "npx"',
1122
+ 'args = ["-y", "linear-mcp"]',
1123
+ '',
1124
+ ].join('\n')
1125
+ writeFileSync(file, original)
1126
+
1127
+ applyPlan(file, { ...loadSettings(file), hooks: ours() })
1128
+
1129
+ const after = readFileSync(file, 'utf8')
1130
+ expect(after).toContain(original.trimEnd())
1131
+ expect(after).toContain("# why this model, in the user's own words")
1132
+ expect(after).toContain('# trailing note')
1133
+ expect(after).toContain('# a comment nobody but the user cares about')
1134
+ expect(parseToml(after)).toMatchObject({ model: 'gpt-5.6' })
1135
+ expect(after).toContain('[[hooks.Stop]]')
1136
+ })
1137
+
1138
+ it('replaces the hooks tables rather than appending a second copy', () => {
1139
+ const layer = layerFor('notifai-toml-replace-')
1140
+ const file = path.join(layer, 'config.toml')
1141
+ writeFileSync(file, '# keep me\nmodel = "gpt-5.6"\n')
1142
+
1143
+ applyPlan(file, { ...loadSettings(file), hooks: ours() })
1144
+ const once = readFileSync(file, 'utf8')
1145
+ applyPlan(file, { ...loadSettings(file), hooks: ours() })
1146
+ const twice = readFileSync(file, 'utf8')
1147
+
1148
+ expect(twice).toBe(once)
1149
+ expect(twice).toContain('# keep me')
1150
+ expect(twice.match(/\[\[hooks\.Stop\]\]/g) ?? []).toHaveLength(1)
1151
+ })
1152
+
1153
+ it('carries the Codex trust store across a write', () => {
1154
+ const layer = layerFor('notifai-toml-trust-')
1155
+ const file = path.join(layer, 'config.toml')
1156
+ writeFileSync(
1157
+ file,
1158
+ `model = "gpt-5.6"\n\n[hooks.state."${layer}/config.toml:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
1159
+ )
1160
+
1161
+ const existing = loadSettings(file)
1162
+ applyPlan(file, { ...existing, hooks: { ...existing.hooks, ...ours() } })
1163
+
1164
+ const after = parseToml(readFileSync(file, 'utf8')) as {
1165
+ hooks: { state: Record<string, { trusted_hash: string }> }
1166
+ }
1167
+ expect(after.hooks.state[`${layer}/config.toml:stop:0:0`]?.trusted_hash).toBe('sha256:abc')
1168
+ })
1169
+
1170
+ it('falls back to a whole-file write when the splice cannot be trusted', () => {
1171
+ const layer = layerFor('notifai-toml-inline-')
1172
+ const file = path.join(layer, 'config.toml')
1173
+ // An inline top-level `hooks` key cannot be spliced around, only rewritten.
1174
+ writeFileSync(file, '# this comment is lost, and that is the safe outcome\nhooks = {}\n')
1175
+
1176
+ applyPlan(file, { hooks: ours() })
1177
+
1178
+ const after = readFileSync(file, 'utf8')
1179
+ expect(after).toContain('[[hooks.Stop]]')
1180
+ expect(after).not.toContain('hooks = {}')
1181
+ })
1182
+ })
@@ -42,8 +42,9 @@ const OPENCODE_EVENTS = [
42
42
  * location and the event set differ. Codex supports either a dedicated
43
43
  * `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
44
44
  * We default a hook-empty layer to inline config and preserve whichever single
45
- * representation a populated layer already uses. One representation per layer,
46
- * never both.
45
+ * representation a populated layer already uses. Notifai lives in one of them,
46
+ * never both — a layer whose other file holds someone else's hooks is that
47
+ * other tool's arrangement, and stays untouched.
47
48
  *
48
49
  * Cursor's native format is flat and lower-camel-cased, while OpenCode's
49
50
  * extension point is a JavaScript plugin module. Each therefore has a bounded
@@ -200,6 +201,22 @@ export const CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS = 3600
200
201
  */
201
202
  export const BLOCKING_STOP_TIMEOUT_SECONDS = 540
202
203
 
204
+ /**
205
+ * Whether this harness's Stop handler runs detached from the turn.
206
+ *
207
+ * Three places decide this — the installer that declares `async: true`, the
208
+ * hook runtime that picks the waiter's wall clock, and doctor's check that the
209
+ * installed definition still has the right shape — and they must agree or the
210
+ * waiter outlives a turn nobody left open. They were three separate spellings
211
+ * of the same condition with nothing tying them together.
212
+ */
213
+ export function stopHandlerIsDetached(
214
+ harness: Harness | undefined,
215
+ platform?: Parameters<typeof hookHostPlatform>[0],
216
+ ): boolean {
217
+ return harness === 'claude-code' && hookHostPlatform(platform) === 'posix'
218
+ }
219
+
203
220
  /**
204
221
  * The turn-end handler, whose shape is the whole per-harness difference.
205
222
  *
@@ -216,7 +233,7 @@ function stopHandler(
216
233
  options: HookCommandOptions,
217
234
  ): HookHandler {
218
235
  const command = hookCommand(adapterPath, 'stop', harness, options)
219
- if (harness === 'claude-code' && hookHostPlatform(options.platform) === 'posix') {
236
+ if (stopHandlerIsDetached(harness, options.platform)) {
220
237
  return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true }
221
238
  }
222
239
  if (harness === 'codex') return { type: 'command', command }
@@ -455,7 +472,19 @@ export function configHome(
455
472
  return path.join(home, fallback)
456
473
  }
457
474
 
458
- /** User-global Codex config directory for the active Codex installation. */
475
+ /**
476
+ * User-global Codex config directory for the *running* Codex installation.
477
+ *
478
+ * `CODEX_HOME` replaces the home; it does not shadow it. `codex doctor` with
479
+ * the variable set reports that directory for every path it resolves and never
480
+ * consults `~/.codex` at all. So a session manager that points Codex at its own
481
+ * home makes `~/.codex` inert for those sessions, and installing there would
482
+ * write hooks the running agent never reads while reporting success.
483
+ *
484
+ * Following the variable is therefore correct even though it means install and
485
+ * doctor resolve different homes when run from different shells. Doctor names
486
+ * the home it inspected for exactly that reason — see `codexHomeNote`.
487
+ */
459
488
  export function codexGlobalDir(
460
489
  env: NodeJS.ProcessEnv = process.env,
461
490
  platform: NodeJS.Platform | HookHostPlatform = process.platform,
@@ -488,47 +517,56 @@ export function codexLayerPaths(
488
517
 
489
518
  export interface CodexLayerInspection {
490
519
  paths: CodexLayerPaths
520
+ /** Every event this file defines a handler for, whoever owns it. */
491
521
  jsonEvents: string[]
492
522
  tomlEvents: string[]
523
+ /** The subset of those events whose handlers are Notifai's own. */
524
+ ourJsonEvents: string[]
525
+ ourTomlEvents: string[]
493
526
  writeTarget: string
494
- dual: boolean
495
- overlappingEvents: string[]
496
527
  }
497
528
 
498
529
  /**
499
- * Which representation this Codex layer already uses.
530
+ * Where Notifai writes in this Codex layer: wherever Notifai already is, and
531
+ * inline `config.toml` otherwise.
532
+ *
533
+ * Codex supports `hooks.json` and inline `[hooks]` side by side, loads both,
534
+ * and runs every matching handler. The tempting rule is to join whichever
535
+ * representation the layer already uses, so Notifai never adds Codex's "prefer
536
+ * one representation per layer" startup warning. Notifai must not do that,
537
+ * because a hook entry carries no marker saying who owns it: a `hooks.json`
538
+ * full of foreign handlers may be one a person wrote by hand or one a tool
539
+ * regenerates wholesale, and those two look identical from here. Appending to
540
+ * the second kind gets Notifai's handlers deleted the next time that tool
541
+ * rewrites its file — silently, on a product whose entire job is reaching
542
+ * someone who is not at the keyboard to notice it stopped.
500
543
  *
501
- * Codex supports `hooks.json` and inline `[hooks]` side by side, but loads both
502
- * and runs every matching handler. Use inline config for a hook-empty layer and
503
- * preserve an existing single representation so installing Notifai never
504
- * invents a dual layer. When the layer is already dual, keep our handlers in
505
- * the representation that already contains them instead of silently migrating
506
- * them. `[hooks.state]` is the trust store, not a hook definition, so it does
507
- * not count as a representation.
544
+ * So Notifai only ever writes a file it owns. The warning it may cause is
545
+ * Codex accurately reporting that two handler sets merge and all of them fire;
546
+ * it is noise, and `codexCoexistenceNotes` explains it. That trade is
547
+ * deliberate: visible noise over silent non-delivery.
548
+ *
549
+ * Staying put where Notifai already is also keeps reinstalls idempotent and
550
+ * preserves the `[hooks.state]` trust hash, which Codex keys by file path and
551
+ * handler index. `[hooks.state]` is the trust store, not a hook definition, so
552
+ * it does not count as a representation.
508
553
  */
509
554
  export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
510
555
  const jsonDocument = tryLoadSettings(paths.hooksJson)
511
556
  const tomlDocument = tryLoadSettings(paths.configToml)
512
557
  const jsonEvents = hookEventNames(jsonDocument?.hooks)
513
558
  const tomlEvents = hookEventNames(tomlDocument?.hooks)
514
- const dual = jsonEvents.length > 0 && tomlEvents.length > 0
515
- let writeTarget = paths.configToml
516
- if (jsonEvents.length > 0 && tomlEvents.length === 0) {
517
- writeTarget = paths.hooksJson
518
- } else if (dual) {
519
- const jsonOurs = documentHasOurHandlers(jsonDocument)
520
- const tomlOurs = documentHasOurHandlers(tomlDocument)
521
- if (jsonOurs && !tomlOurs) writeTarget = paths.hooksJson
522
- }
559
+ const ourJsonEvents = ourHandlerEvents(jsonDocument)
560
+ const ourTomlEvents = ourHandlerEvents(tomlDocument)
561
+ const writeTarget =
562
+ ourJsonEvents.length > 0 && ourTomlEvents.length === 0 ? paths.hooksJson : paths.configToml
523
563
  return {
524
564
  paths,
525
565
  jsonEvents,
526
566
  tomlEvents,
567
+ ourJsonEvents,
568
+ ourTomlEvents,
527
569
  writeTarget,
528
- dual,
529
- overlappingEvents: dual
530
- ? jsonEvents.filter((event) => tomlEvents.includes(event))
531
- : [],
532
570
  }
533
571
  }
534
572
 
@@ -556,6 +594,23 @@ export function hookDefinitionFiles(
556
594
  return [paths.hooksJson, paths.configToml]
557
595
  }
558
596
 
597
+ /**
598
+ * Layers where *Notifai* is installed in both Codex representations.
599
+ *
600
+ * A layer that uses `hooks.json` and inline `[hooks]` at once is not by itself
601
+ * a Notifai fault: Codex supports both, loads both, runs every matching
602
+ * handler, and already prints its own "prefer a single representation" warning
603
+ * at startup. Foreign handlers in the other file are the user's — often written
604
+ * by another tool that manages that file — and repeating Codex's warning as a
605
+ * Notifai failure demanded an edit to configuration Notifai does not own, for a
606
+ * condition where every handler still fires exactly once.
607
+ *
608
+ * What is ours to diagnose is Notifai in both files. Then one event really does
609
+ * send two Notification Requests per turn, and the copy outside the write
610
+ * target keeps firing an older definition after the next install refreshes only
611
+ * the other one. Uninstall strips Notifai from both files, so the remedy is
612
+ * exact — and it never touches a foreign handler.
613
+ */
559
614
  export function codexRepresentationProblems(
560
615
  cwd: string,
561
616
  env: NodeJS.ProcessEnv = process.env,
@@ -563,17 +618,71 @@ export function codexRepresentationProblems(
563
618
  ): string[] {
564
619
  return [false, true].flatMap((global) => {
565
620
  const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform))
566
- if (!layer.dual) return []
567
- const overlap =
568
- layer.overlappingEvents.length > 0
569
- ? ` Matching handlers from both files all run — this layer's ${layer.overlappingEvents.join(', ')} handlers will both fire.`
570
- : ' Matching handlers from both files all run.'
621
+ if (layer.ourJsonEvents.length === 0 || layer.ourTomlEvents.length === 0) return []
622
+ const doubled = layer.ourJsonEvents.filter((event) => layer.ourTomlEvents.includes(event))
623
+ const consequence =
624
+ doubled.length > 0
625
+ ? `Codex runs every matching handler, so this layer notifies twice per turn for ${doubled.join(', ')}`
626
+ : `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`
627
+ const scope = global ? ' --global' : ''
571
628
  return [
572
- `loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap} Notifai preserves existing representations instead of migrating them automatically.`,
629
+ `Notifai hooks are installed in both ${layer.paths.hooksJson} and ${layer.paths.configToml}; ${consequence}. Run \`notifai hooks uninstall --harness codex${scope}\` and then \`notifai hooks install --harness codex${scope}\` to leave exactly one copy; foreign hooks in either file are left alone.`,
573
630
  ]
574
631
  })
575
632
  }
576
633
 
634
+ /**
635
+ * Layers where Notifai and someone else each own one Codex representation.
636
+ *
637
+ * This is not a fault and never fails a check. Codex loads `hooks.json` and
638
+ * inline `[hooks]` together and runs every matching handler, so both sets fire
639
+ * exactly once — but Codex also prints "prefer one representation per layer"
640
+ * at startup, and a user who sees that warning deserves to know which file is
641
+ * Notifai's, which is not, and that Notifai will not touch the one it does not
642
+ * own. Saying nothing here is what made the warning look like Notifai's bug.
643
+ */
644
+ export function codexCoexistenceNotes(
645
+ cwd: string,
646
+ env: NodeJS.ProcessEnv = process.env,
647
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
648
+ ): string[] {
649
+ return [false, true].flatMap((global) => {
650
+ const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform))
651
+ const inJson = layer.ourJsonEvents.length > 0
652
+ const inToml = layer.ourTomlEvents.length > 0
653
+ if (inJson === inToml) return []
654
+ const ours = inJson ? layer.paths.hooksJson : layer.paths.configToml
655
+ const theirs = inJson ? layer.paths.configToml : layer.paths.hooksJson
656
+ const theirEvents = inJson ? layer.tomlEvents : layer.jsonEvents
657
+ if (theirEvents.length === 0) return []
658
+ const stop = theirEvents.includes('Stop')
659
+ ? " That file's Stop handler can end a turn before Notifai's answer arrives, because Codex lets any Stop handler stop continuation."
660
+ : ''
661
+ return [
662
+ `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.${stop}`,
663
+ ]
664
+ })
665
+ }
666
+
667
+ /**
668
+ * What `CODEX_HOME` is doing to this shell, when it is doing anything.
669
+ *
670
+ * Codex replaces its whole home when the variable is set, so hooks installed
671
+ * from a shell without it are simply absent here, and its trust store is keyed
672
+ * by absolute file path — a config copied between homes arrives untrusted.
673
+ * Both facts turn into "Notifai is not wired" reports that look like a bug in
674
+ * the installer, so doctor names the home it actually inspected.
675
+ */
676
+ export function codexHomeNote(
677
+ env: NodeJS.ProcessEnv = process.env,
678
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
679
+ ): string | null {
680
+ const effective = codexGlobalDir(env, platform)
681
+ const accountDefault = path.join(harnessAccountHome(env, platform), '.codex')
682
+ if (effective === accountDefault) return null
683
+ 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.`
684
+ }
685
+
577
686
  function tryLoadSettings(file: string): SettingsDocument | null {
578
687
  if (!existsSync(file)) return null
579
688
  try {
@@ -589,9 +698,10 @@ function hookEventNames(hooks: SettingsDocument['hooks']): string[] {
589
698
  .map(([event]) => event)
590
699
  }
591
700
 
592
- function documentHasOurHandlers(document: SettingsDocument | null): boolean {
593
- if (document === null) return false
594
- return locateHandlers(document).length > 0
701
+ /** Events this document defines a Notifai handler for, by their event key. */
702
+ function ourHandlerEvents(document: SettingsDocument | null): string[] {
703
+ if (document === null) return []
704
+ return [...new Set(locateHandlers(document).map((handler) => handler.event))]
595
705
  }
596
706
 
597
707
  /** Best-effort detection so `hooks install` usually needs no flags. */
@@ -868,13 +978,107 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
868
978
  */
869
979
  export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
870
980
  const body = isTomlSettingsPath(file)
871
- ? `${stringifyToml(document)}\n`
981
+ ? tomlBody(file, document as SettingsDocument)
872
982
  : `${JSON.stringify(document, null, 2)}\n`
873
983
  atomicWriteFileSync(file, body, {
874
984
  requireCurrentUserOwner: true,
875
985
  })
876
986
  }
877
987
 
988
+ /**
989
+ * A TOML file rewritten around its `[hooks]` tables, not over them.
990
+ *
991
+ * `config.toml` is the user's, and for most of them it is hand-written: model
992
+ * choices, sandbox settings, MCP servers, and the comments explaining why. A
993
+ * parse-and-restringify write returns all of that as data and none of it as
994
+ * the document they wrote — comments gone, order normalized, spacing theirs no
995
+ * longer. Notifai adds three hook handlers; it has no business reflowing the
996
+ * rest.
997
+ *
998
+ * So everything outside `[hooks...]` is carried across as the exact bytes it
999
+ * came in as, and only the hooks tables are regenerated — re-emitted at the end
1000
+ * of the file, because TOML does not care where a table sits and splicing into
1001
+ * the middle would mean reasoning about a region line by line. If the result
1002
+ * cannot be proven to parse back to the document asked for, this falls back to
1003
+ * the whole-file rewrite rather than risk a file it half-understood.
1004
+ */
1005
+ function tomlBody(file: string, document: SettingsDocument): string {
1006
+ const whole = `${stringifyToml(document)}\n`
1007
+ if (!existsSync(file)) return whole
1008
+ let previous: string
1009
+ try {
1010
+ previous = readOwnedRegularFile(file)
1011
+ } catch {
1012
+ return whole
1013
+ }
1014
+ return spliceTomlHooks(previous, document) ?? whole
1015
+ }
1016
+
1017
+ /** A TOML table header line, e.g. `[hooks.state."…"]` or `[[hooks.Stop]]`. */
1018
+ const TOML_TABLE_HEADER = /^\s*\[\[?([^[\]]+)\]\]?\s*(?:#.*)?$/
1019
+
1020
+ /**
1021
+ * The first key in a dotted TOML key path, unquoting it if it is quoted.
1022
+ * `hooks.state."/a/b:stop:0:0"` is rooted at `hooks`, and so is `hooks.Stop`.
1023
+ */
1024
+ function firstTomlKey(keyPath: string): string {
1025
+ const trimmed = keyPath.trim()
1026
+ const quote = trimmed[0]
1027
+ if (quote === '"' || quote === "'") {
1028
+ const end = trimmed.indexOf(quote, 1)
1029
+ return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
1030
+ }
1031
+ const dot = trimmed.indexOf('.')
1032
+ return (dot === -1 ? trimmed : trimmed.slice(0, dot)).trim()
1033
+ }
1034
+
1035
+ /**
1036
+ * `source` with its hooks tables replaced by `document`'s, or null when that
1037
+ * cannot be done safely — an inline top-level `hooks` key, or a result that
1038
+ * does not parse back to exactly the document asked for.
1039
+ */
1040
+ function spliceTomlHooks(source: string, document: SettingsDocument): string | null {
1041
+ const kept: string[] = []
1042
+ let inHooks = false
1043
+ for (const line of source.split('\n')) {
1044
+ const header = TOML_TABLE_HEADER.exec(line)
1045
+ if (header !== null) inHooks = firstTomlKey(header[1] ?? '') === 'hooks'
1046
+ // A root-level `hooks = …` or `hooks.x = …` would survive the splice and
1047
+ // then collide with the tables appended below.
1048
+ if (!inHooks && /^\s*hooks\s*[.=]/.test(line)) return null
1049
+ if (!inHooks) kept.push(line)
1050
+ }
1051
+
1052
+ const hooks = document.hooks
1053
+ const body = hooks === undefined ? '' : stringifyToml({ hooks })
1054
+ const head = kept.join('\n').trimEnd()
1055
+ const spliced =
1056
+ body === '' ? `${head}\n` : head === '' ? `${body}\n` : `${head}\n\n${body}\n`
1057
+
1058
+ try {
1059
+ if (!sameTomlValue(parseToml(spliced), document)) return null
1060
+ } catch {
1061
+ return null
1062
+ }
1063
+ return spliced
1064
+ }
1065
+
1066
+ /** Structural equality for parsed TOML, which carries dates as well as data. */
1067
+ function sameTomlValue(a: unknown, b: unknown): boolean {
1068
+ if (a === b) return true
1069
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
1070
+ if (Array.isArray(a) || Array.isArray(b)) {
1071
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
1072
+ return a.every((item, index) => sameTomlValue(item, b[index]))
1073
+ }
1074
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
1075
+ const left = a as Record<string, unknown>
1076
+ const right = b as Record<string, unknown>
1077
+ const keys = Object.keys(left)
1078
+ if (keys.length !== Object.keys(right).length) return false
1079
+ return keys.every((key) => key in right && sameTomlValue(left[key], right[key]))
1080
+ }
1081
+
878
1082
  export function loadSettings(file: string): SettingsDocument {
879
1083
  return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file)
880
1084
  }