@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.
- package/CHANGELOG.md +46 -0
- package/README.md +5 -0
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +174 -58
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +14 -5
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +72 -5
- package/dist/hooks.js +407 -79
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +44 -17
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +6 -0
- package/dist/send.js +25 -14
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +185 -51
- package/src/commands.ts +198 -56
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +16 -5
- package/src/config.test.ts +41 -9
- package/src/config.ts +13 -0
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +358 -21
- package/src/hooks.ts +457 -97
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +57 -23
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +32 -16
- package/src/skill.test.ts +199 -146
- package/src/ui/help.ts +8 -3
- 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
|
-
|
|
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, '
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
729
|
-
|
|
730
|
-
|
|
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
|
|
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
|
+
})
|
package/src/install-hooks.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
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
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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 (
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
?
|
|
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
|
}
|