@raidiant/notifai 10.1.1 → 10.1.3

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 (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/commands-core.d.ts +4 -1
  3. package/dist/commands-core.js.map +1 -1
  4. package/dist/commands-doctor.js +2 -7
  5. package/dist/commands-doctor.js.map +1 -1
  6. package/dist/commands-hooks.d.ts +12 -0
  7. package/dist/commands-hooks.js +15 -1
  8. package/dist/commands-hooks.js.map +1 -1
  9. package/dist/commands-init.js +9 -6
  10. package/dist/commands-init.js.map +1 -1
  11. package/dist/commands-send-support.js +10 -11
  12. package/dist/commands-send-support.js.map +1 -1
  13. package/dist/commands-skill.js +72 -54
  14. package/dist/commands-skill.js.map +1 -1
  15. package/dist/harness-session-title.d.ts +22 -0
  16. package/dist/harness-session-title.js +74 -0
  17. package/dist/harness-session-title.js.map +1 -0
  18. package/dist/invocation-context.d.ts +1 -0
  19. package/dist/invocation-context.js +15 -1
  20. package/dist/invocation-context.js.map +1 -1
  21. package/dist/native-skills.d.ts +13 -3
  22. package/dist/native-skills.js +51 -12
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/node-floor.d.ts +2 -1
  25. package/dist/node-floor.js +9 -5
  26. package/dist/node-floor.js.map +1 -1
  27. package/dist/orca-session-title.d.ts +5 -4
  28. package/dist/orca-session-title.js +32 -19
  29. package/dist/orca-session-title.js.map +1 -1
  30. package/dist/platform.d.ts +11 -17
  31. package/dist/platform.js +20 -23
  32. package/dist/platform.js.map +1 -1
  33. package/dist/release.d.ts +5 -5
  34. package/dist/release.js +5 -5
  35. package/dist/session-labels.d.ts +2 -0
  36. package/dist/session-labels.js +20 -9
  37. package/dist/session-labels.js.map +1 -1
  38. package/dist/setup-destinations.js +1 -1
  39. package/dist/setup-destinations.js.map +1 -1
  40. package/dist/skill-integrity.d.ts +49 -0
  41. package/dist/skill-integrity.js +154 -0
  42. package/dist/skill-integrity.js.map +1 -0
  43. package/dist/skill-source/manifest.json +16 -0
  44. package/dist/skill-source/notifai/SKILL.md +390 -0
  45. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  46. package/package.json +11 -14
  47. package/src/atomic-file.ts +0 -198
  48. package/src/claude-wake.test.ts +0 -321
  49. package/src/claude-wake.ts +0 -499
  50. package/src/cli-bin.test.ts +0 -176
  51. package/src/cli-bin.ts +0 -233
  52. package/src/cli-release.test.ts +0 -46
  53. package/src/cli-release.ts +0 -78
  54. package/src/cli-update-contract.ts +0 -9
  55. package/src/cli-update.test.ts +0 -125
  56. package/src/client.test.ts +0 -390
  57. package/src/client.ts +0 -352
  58. package/src/codex-wake.test.ts +0 -254
  59. package/src/codex-wake.ts +0 -301
  60. package/src/command-session.ts +0 -38
  61. package/src/commands-agent-sessions.ts +0 -72
  62. package/src/commands-auth.ts +0 -238
  63. package/src/commands-config.ts +0 -336
  64. package/src/commands-core.ts +0 -312
  65. package/src/commands-devices.ts +0 -180
  66. package/src/commands-doctor.ts +0 -1501
  67. package/src/commands-guidance.ts +0 -181
  68. package/src/commands-harness-context.ts +0 -207
  69. package/src/commands-hook-shape.ts +0 -69
  70. package/src/commands-hooks.ts +0 -2318
  71. package/src/commands-init.ts +0 -1055
  72. package/src/commands-io.ts +0 -108
  73. package/src/commands-logs.ts +0 -243
  74. package/src/commands-project.ts +0 -47
  75. package/src/commands-send-support.test.ts +0 -156
  76. package/src/commands-send-support.ts +0 -311
  77. package/src/commands-send.ts +0 -875
  78. package/src/commands-setup-proof.test.ts +0 -72
  79. package/src/commands-setup-proof.ts +0 -124
  80. package/src/commands-skill.test.ts +0 -44
  81. package/src/commands-skill.ts +0 -204
  82. package/src/commands-sounds.test.ts +0 -106
  83. package/src/commands-sounds.ts +0 -49
  84. package/src/commands-update.ts +0 -180
  85. package/src/commands.test.ts +0 -8297
  86. package/src/commands.ts +0 -77
  87. package/src/config-schema.test.ts +0 -153
  88. package/src/config-schema.ts +0 -349
  89. package/src/config.test.ts +0 -581
  90. package/src/config.ts +0 -475
  91. package/src/credentials.test.ts +0 -403
  92. package/src/credentials.ts +0 -403
  93. package/src/file-lock-process-worker.test.ts +0 -64
  94. package/src/file-lock.test.ts +0 -320
  95. package/src/file-lock.ts +0 -393
  96. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  97. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  98. package/src/generated-session-label.ts +0 -155
  99. package/src/guidance-content.ts +0 -278
  100. package/src/guidance-render.ts +0 -56
  101. package/src/guidance.test.ts +0 -327
  102. package/src/guidance.ts +0 -233
  103. package/src/harness-integration.test.ts +0 -294
  104. package/src/harnesses.test.ts +0 -149
  105. package/src/harnesses.ts +0 -164
  106. package/src/hook-adapter.test.ts +0 -496
  107. package/src/hook-adapter.ts +0 -424
  108. package/src/hook-input.test.ts +0 -39
  109. package/src/hook-input.ts +0 -72
  110. package/src/hooks.test.ts +0 -5824
  111. package/src/hooks.ts +0 -4011
  112. package/src/install-hooks.test.ts +0 -1614
  113. package/src/install-hooks.ts +0 -1501
  114. package/src/interactive.test.ts +0 -387
  115. package/src/interactive.ts +0 -858
  116. package/src/invocation-context.test.ts +0 -352
  117. package/src/invocation-context.ts +0 -197
  118. package/src/logging-process-worker.test.ts +0 -45
  119. package/src/logging.test.ts +0 -544
  120. package/src/logging.ts +0 -698
  121. package/src/main-run.ts +0 -45
  122. package/src/main.ts +0 -12
  123. package/src/native-skills.test.ts +0 -115
  124. package/src/native-skills.ts +0 -162
  125. package/src/node-floor.test.ts +0 -27
  126. package/src/node-floor.ts +0 -37
  127. package/src/onboarding-docs.test.ts +0 -25
  128. package/src/openclaw-plugin.ts +0 -409
  129. package/src/opencode-plugin.ts +0 -298
  130. package/src/orca-session-title.test.ts +0 -54
  131. package/src/orca-session-title.ts +0 -103
  132. package/src/pack-feedback-slice.test.ts +0 -212
  133. package/src/pack-feedback-slice.ts +0 -187
  134. package/src/platform.test.ts +0 -152
  135. package/src/platform.ts +0 -173
  136. package/src/program.test.ts +0 -317
  137. package/src/program.ts +0 -842
  138. package/src/project-enablement.test.ts +0 -55
  139. package/src/project-enablement.ts +0 -99
  140. package/src/question-caps.test.ts +0 -49
  141. package/src/question-settlement-process.ts +0 -47
  142. package/src/question-timing.ts +0 -56
  143. package/src/readiness.test.ts +0 -25
  144. package/src/readiness.ts +0 -212
  145. package/src/release.ts +0 -51
  146. package/src/send-attempts.test.ts +0 -143
  147. package/src/send-attempts.ts +0 -188
  148. package/src/send.test.ts +0 -28
  149. package/src/send.ts +0 -436
  150. package/src/session-activation.ts +0 -61
  151. package/src/session-end-log-lock-worker.test.ts +0 -35
  152. package/src/session-labels.test.ts +0 -490
  153. package/src/session-labels.ts +0 -474
  154. package/src/setup-destinations.test.ts +0 -41
  155. package/src/setup-destinations.ts +0 -50
  156. package/src/skill.test.ts +0 -426
  157. package/src/sound-ref.test.ts +0 -31
  158. package/src/sound-ref.ts +0 -34
  159. package/src/trust-policy.test.ts +0 -701
  160. package/src/ui/banner.ts +0 -95
  161. package/src/ui/config-view.test.ts +0 -90
  162. package/src/ui/config-view.ts +0 -138
  163. package/src/ui/help.test.ts +0 -16
  164. package/src/ui/help.ts +0 -128
  165. package/src/ui/theme.test.ts +0 -92
  166. package/src/ui/theme.ts +0 -265
  167. package/src/url-policy.ts +0 -399
  168. package/tsconfig.json +0 -10
@@ -1,1614 +0,0 @@
1
- import {
2
- existsSync,
3
- lstatSync,
4
- mkdirSync,
5
- mkdtempSync,
6
- readFileSync,
7
- readdirSync,
8
- statSync,
9
- symlinkSync,
10
- writeFileSync,
11
- } from 'node:fs'
12
- import os from 'node:os'
13
- import path from 'node:path'
14
- import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
15
- import { describe, expect, it } from 'vitest'
16
- import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
17
- import { parseChoices } from './send.js'
18
- import {
19
- OPENCODE_PLUGIN_MARKER,
20
- isOurOpencodePlugin,
21
- opencodePluginPath,
22
- opencodePluginSource,
23
- opencodePluginTarget,
24
- } from './opencode-plugin.js'
25
- import {
26
- OPENCLAW_PLUGIN_MARKER,
27
- isOurOpenclawPlugin,
28
- mergeOpenclawNotifaiEntry,
29
- openclawHasGlobalEvidence,
30
- openclawPluginPath,
31
- openclawPluginSource,
32
- openclawPluginTarget,
33
- parseOpenclawConfig,
34
- removeOpenclawNotifaiEntry,
35
- } from './openclaw-plugin.js'
36
- import {
37
- MISSING_LIFECYCLE_GUIDANCE_CONTEXT,
38
- WORKER_ACTIVATION_CONTEXT,
39
- } from './session-activation.js'
40
- import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
41
- import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
42
- import {
43
- QUESTION_STOP_TIMEOUT_SECONDS,
44
- applyPlan,
45
- detectHarness,
46
- detectedHarnesses,
47
- buildCursorHookConfig,
48
- buildHookConfig,
49
- codexLayerDir,
50
- codexProjectRoot,
51
- findInstallations,
52
- codexHookIdentityHash,
53
- codexCoexistenceNotes,
54
- codexHomeNote,
55
- codexRepresentationProblems,
56
- codexTrustKey,
57
- codexTrustProblems,
58
- handlerEvent,
59
- harnessAccountHome,
60
- hookCommand,
61
- quoteWindowsArg,
62
- loadSettings,
63
- mergeHooks,
64
- removeHooks,
65
- settingsFile,
66
- type HookConfig,
67
- type SettingsDocument,
68
- } from './install-hooks.js'
69
-
70
- const SCRIPT = '/opt/notifai/dist/main.js'
71
- const EXEC = '/usr/local/bin/node'
72
- const ADAPTER = '/Users/test/.notifai/bin/hook-adapter'
73
-
74
- function ours(): HookConfig {
75
- return buildHookConfig({ adapterPath: ADAPTER })
76
- }
77
-
78
- describe('choice labels', () => {
79
- it('treats a comma as an ordinary character, never a delimiter', () => {
80
- // Observed live 2026-08-02 under the old comma-split grammar:
81
- // "Yes, it worked,No, something broke" passed as one value silently
82
- // became four choices. The delimiter form is gone; one flag, one answer.
83
- expect(parseChoices(['Yes, it worked', 'No, something broke'])).toEqual([
84
- { id: 'yes-it-worked', label: 'Yes, it worked' },
85
- { id: 'no-something-broke', label: 'No, something broke' },
86
- ])
87
- expect(parseChoices(['Staging,Production', 'Neither'])).toEqual([
88
- { id: 'staging-production', label: 'Staging,Production' },
89
- { id: 'neither', label: 'Neither' },
90
- ])
91
- })
92
-
93
- it('rejects a set that cannot become a valid closed question', () => {
94
- expect(parseChoices(['Only one'])).toBe('invalid')
95
- expect(parseChoices(['A', 'a'])).toBe('invalid')
96
- expect(parseChoices(['1', '2', '3', '4', '5', '6', '7'])).toBe('invalid')
97
- })
98
-
99
- it('treats an unused repeatable flag as no choices at all', () => {
100
- expect(parseChoices([])).toBeNull()
101
- expect(parseChoices(undefined)).toBeNull()
102
- })
103
- })
104
-
105
- describe('hook config', () => {
106
- it('activates Notifai at the session lifecycle seam for every native hook adapter', () => {
107
- const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
108
- const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
109
- const cursor = buildCursorHookConfig({ adapterPath: ADAPTER, harness: 'cursor' })
110
-
111
- expect(claude['SessionStart']?.[0]?.hooks[0]).toMatchObject({
112
- command: hookCommand(ADAPTER, 'session-start', 'claude-code'),
113
- })
114
- expect(codex['SessionStart']?.[0]?.hooks[0]).toMatchObject({
115
- command: hookCommand(ADAPTER, 'session-start', 'codex'),
116
- })
117
- expect(cursor['sessionStart']?.[0]).toMatchObject({
118
- command: hookCommand(ADAPTER, 'session-start', 'cursor'),
119
- })
120
- expect(claude['SubagentStart']?.[0]?.hooks[0]).toMatchObject({
121
- command: hookCommand(ADAPTER, 'subagent-start', 'claude-code'),
122
- })
123
- expect(codex['SubagentStart']?.[0]?.hooks[0]).toMatchObject({
124
- command: hookCommand(ADAPTER, 'subagent-start', 'codex'),
125
- })
126
- expect(cursor['subagentStart']).toBeUndefined()
127
- expect(cursor['stop']?.[0]).toMatchObject({
128
- command: hookCommand(ADAPTER, 'activation-stop', 'cursor'),
129
- loop_limit: 1,
130
- })
131
- })
132
-
133
- it('invokes one stable adapter so a sparse hook PATH still works', () => {
134
- expect(hookCommand(ADAPTER, 'stop')).toBe(`'${ADAPTER}' hook stop --owner notifai`)
135
- })
136
-
137
- it('single-quotes so a path cannot become a command', () => {
138
- // Double quotes still expand $() and backticks, so a checkout path with
139
- // shell syntax was execution on every hook event.
140
- const cmd = hookCommand('/tmp/$(touch /tmp/pwned)/hook-adapter', 'stop')
141
- expect(cmd).toBe("'/tmp/$(touch /tmp/pwned)/hook-adapter' hook stop --owner notifai")
142
- expect(hookCommand("/it's/here", 'stop')).toContain(`'/it'\\''s/here'`)
143
- })
144
-
145
- it('uses one fixed blocking process budget rather than accepting mutable timing inputs', () => {
146
- const config = buildHookConfig({ adapterPath: ADAPTER })
147
- expect(config['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
148
- expect(config['Stop']?.[0]?.hooks[0]?.async).toBeUndefined()
149
- // Claude Code defaults UserPromptSubmit to 30s; Codex gives SessionEnd 1-3s.
150
- expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
151
- expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
152
- })
153
-
154
- it('stamps the exact harness on every generated command', () => {
155
- const config = buildHookConfig({
156
- adapterPath: ADAPTER,
157
- harness: 'claude-code',
158
- })
159
- expect(
160
- Object.values(config)
161
- .flatMap((groups) => groups)
162
- .flatMap((group) => group.hooks)
163
- .every((handler) => handler.command.includes('--harness claude-code')),
164
- ).toBe(true)
165
- })
166
-
167
- it('gives Codex a Stop timeout beyond the longest answer window', () => {
168
- const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
169
-
170
- expect(codex['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
171
- expect(codex['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
172
- expect(codex['Stop']?.[0]?.hooks[0]?.timeout).toBe(QUESTION_STOP_TIMEOUT_SECONDS)
173
- expect(QUESTION_STOP_TIMEOUT_SECONDS).toBeGreaterThan(REPLY_MAX_WINDOW_SECONDS)
174
- })
175
-
176
- it('gives Claude Code an asynchronous Stop with an explicit waiter budget on POSIX', () => {
177
- const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
178
-
179
- // `async: true` is what frees the turn: the handler returns at once and the
180
- // same process lives on as the waiter. The explicit timeout is what keeps
181
- // that process alive — Claude kills a background hook at its own 600s
182
- // default and reports nothing, so a wait near that boundary loses answers.
183
- expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
184
- type: 'command',
185
- command: hookCommand(ADAPTER, 'stop', 'claude-code'),
186
- timeout: QUESTION_STOP_TIMEOUT_SECONDS,
187
- async: true,
188
- })
189
- expect(claude['UserPromptSubmit']?.[0]?.hooks[0]?.async).toBeUndefined()
190
- expect(claude['SessionEnd']?.[0]?.hooks[0]?.async).toBeUndefined()
191
- })
192
-
193
- it('gives Claude Code a blocking Stop continuation on Windows', () => {
194
- const options = {
195
- adapterPath: 'C:\\Users\\Ada\\.notifai\\bin\\hook-adapter',
196
- harness: 'claude-code' as const,
197
- platform: 'win32' as const,
198
- nodePath: 'C:\\Program Files\\nodejs\\node.exe',
199
- }
200
- const claude = buildHookConfig(options)
201
-
202
- expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
203
- type: 'command',
204
- command: hookCommand(options.adapterPath, 'stop', 'claude-code', options),
205
- timeout: QUESTION_STOP_TIMEOUT_SECONDS,
206
- })
207
- })
208
-
209
- it('restores missing SessionStart exactly once when an old owned shape is reinstalled over', () => {
210
- const old = mergeHooks({}, buildHookConfig({ adapterPath: ADAPTER }), SCRIPT)
211
- delete old.document.hooks?.['SessionStart']
212
- const migrated = mergeHooks(
213
- old.document,
214
- buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' }),
215
- SCRIPT,
216
- )
217
-
218
- for (const event of ['SessionStart', 'SubagentStart', 'UserPromptSubmit', 'Stop', 'SessionEnd']) {
219
- const groups = migrated.document.hooks?.[event] ?? []
220
- expect(groups.flatMap((group) => group.hooks)).toHaveLength(1)
221
- }
222
- expect(migrated.document.hooks?.['Stop']?.[0]?.hooks[0]).toMatchObject({
223
- timeout: QUESTION_STOP_TIMEOUT_SECONDS,
224
- async: true,
225
- })
226
- expect(migrated.replaced.sort()).toEqual([
227
- 'SessionEnd',
228
- 'Stop',
229
- 'SubagentStart',
230
- 'UserPromptSubmit',
231
- ])
232
- })
233
- })
234
-
235
- describe('merging into existing settings', () => {
236
- it('leaves unrelated settings and other people’s hooks untouched', () => {
237
- const existing = {
238
- permissions: { allow: ['Bash(git *)'] },
239
- hooks: {
240
- Stop: [{ hooks: [{ type: 'command' as const, command: 'make lint' }] }],
241
- PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command' as const, command: 'guard.sh' }] }],
242
- },
243
- }
244
- const merged = mergeHooks(existing, ours(), SCRIPT)
245
-
246
- expect(merged.document['permissions']).toEqual({ allow: ['Bash(git *)'] })
247
- expect(merged.document.hooks?.['PreToolUse']).toEqual(existing.hooks.PreToolUse)
248
- const stop = merged.document.hooks?.['Stop'] ?? []
249
- expect(stop[0]?.hooks[0]?.command).toBe('make lint')
250
- expect(stop[1]?.hooks[0]?.command).toContain('hook stop')
251
- })
252
-
253
- it('replaces our own groups on reinstall instead of duplicating them', () => {
254
- const once = mergeHooks({}, ours(), SCRIPT)
255
- const twice = mergeHooks(
256
- once.document,
257
- buildHookConfig({ adapterPath: ADAPTER }),
258
- SCRIPT,
259
- )
260
- expect(twice.document.hooks?.['Stop']).toHaveLength(1)
261
- expect(twice.document.hooks?.['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
262
- expect(twice.replaced).toContain('Stop')
263
- })
264
-
265
- it('keeps a user handler that shares a matcher group with ours', () => {
266
- const installed = mergeHooks({}, ours(), SCRIPT)
267
- const mixed = installed.document.hooks!['Stop']!
268
- mixed[0]!.hooks.push({ type: 'command', command: 'make lint' })
269
-
270
- const stripped = removeHooks(installed.document, SCRIPT)
271
- expect(stripped.document.hooks?.['Stop']).toEqual([
272
- { hooks: [{ type: 'command', command: 'make lint' }] },
273
- ])
274
- })
275
-
276
- it('uninstall removes only our handlers', () => {
277
- const installed = mergeHooks(
278
- { hooks: { Stop: [{ hooks: [{ type: 'command' as const, command: 'make lint' }] }] } },
279
- ours(),
280
- SCRIPT,
281
- )
282
- const stripped = removeHooks(installed.document, SCRIPT)
283
- expect(stripped.document.hooks?.['Stop']).toEqual([
284
- { hooks: [{ type: 'command', command: 'make lint' }] },
285
- ])
286
- expect(stripped.document.hooks?.['UserPromptSubmit']).toBeUndefined()
287
- })
288
- })
289
-
290
- describe('settings locations', () => {
291
- it('writes a project install to the gitignored file, not the shared one', () => {
292
- expect(settingsFile('claude-code', false, '/repo', {})).toBe('/repo/.claude/settings.local.json')
293
- expect(settingsFile('claude-code', true, '/repo', {})).toBe(
294
- path.join(os.homedir(), '.claude', 'settings.json'),
295
- )
296
- })
297
-
298
- it('keeps ambient global settings inside the test account', () => {
299
- expect(os.homedir()).toBe(process.env['HOME'])
300
- expect(os.homedir()).toBe(process.env['NOTIFAI_TEST_HOME'])
301
- expect(settingsFile('claude-code', true, '/repo', {})).toBe(
302
- path.join(process.env['HOME']!, '.claude', 'settings.json'),
303
- )
304
- })
305
-
306
- it('uses inline Codex hooks for a new layer', () => {
307
- expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/config.toml')
308
- expect(settingsFile('codex', true, '/repo', {})).toBe(
309
- path.join(os.homedir(), '.codex', 'config.toml'),
310
- )
311
- })
312
-
313
- it('keeps a Notifai-only hooks.json instead of rewriting config.toml', () => {
314
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-'))
315
- mkdirSync(path.join(repo, '.codex'), { recursive: true })
316
- applyPlan(path.join(repo, '.codex', 'hooks.json'), { hooks: ours() })
317
-
318
- expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
319
- })
320
-
321
- it('joins the only populated hook representation without special-casing its owner', () => {
322
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
323
- const layer = path.join(repo, '.codex')
324
- mkdirSync(layer, { recursive: true })
325
- writeFileSync(
326
- path.join(layer, 'config.toml'),
327
- `[hooks.state."${layer}/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
328
- )
329
- applyPlan(path.join(layer, 'hooks.json'), {
330
- hooks: {
331
- Stop: [{ hooks: [{ type: 'command', command: 'gdh-stop' }] }],
332
- },
333
- })
334
-
335
- expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'hooks.json'))
336
- })
337
-
338
- it('keeps inline hooks when config.toml is the only populated representation', () => {
339
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-toml-'))
340
- const layer = path.join(repo, '.codex')
341
- mkdirSync(layer, { recursive: true })
342
- writeFileSync(
343
- path.join(layer, 'config.toml'),
344
- ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
345
- '\n',
346
- ),
347
- )
348
-
349
- expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'config.toml'))
350
- })
351
-
352
- it('does not migrate our hooks when both representations already exist', () => {
353
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
354
- const layer = path.join(repo, '.codex')
355
- mkdirSync(layer, { recursive: true })
356
- writeFileSync(
357
- path.join(layer, 'config.toml'),
358
- ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
359
- '\n',
360
- ),
361
- )
362
- applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
363
-
364
- expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'hooks.json'))
365
- })
366
- })
367
-
368
- /**
369
- * Codex resolves project hooks against the main repository, so an install run
370
- * inside a worktree used to write a file Codex never reads — and reported
371
- * success. Proven against the real binary 2026-08-03.
372
- */
373
- describe('a Codex install run inside a git worktree', () => {
374
- /** The on-disk shape `git worktree add` produces, verified against real git. */
375
- function repoWithWorktree(): { main: string; worktree: string } {
376
- const base = mkdtempSync(path.join(os.tmpdir(), 'notifai-wt-'))
377
- const main = path.join(base, 'main')
378
- const worktree = path.join(base, 'wt')
379
- const gitDir = path.join(main, '.git', 'worktrees', 'wt')
380
- mkdirSync(gitDir, { recursive: true })
381
- mkdirSync(worktree, { recursive: true })
382
- writeFileSync(path.join(gitDir, 'commondir'), '../..\n')
383
- writeFileSync(path.join(worktree, '.git'), `gitdir: ${gitDir}\n`)
384
- return { main, worktree }
385
- }
386
-
387
- it('targets the main repository, which is the file Codex actually reads', () => {
388
- const { main, worktree } = repoWithWorktree()
389
-
390
- expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'config.toml'))
391
- expect(codexProjectRoot(worktree)).toBe(main)
392
- })
393
-
394
- it('resolves the same way from a subdirectory of the worktree', () => {
395
- const { main, worktree } = repoWithWorktree()
396
- const nested = path.join(worktree, 'apps', 'cli')
397
- mkdirSync(nested, { recursive: true })
398
-
399
- expect(codexProjectRoot(nested)).toBe(main)
400
- })
401
-
402
- it('leaves an ordinary checkout alone', () => {
403
- const base = mkdtempSync(path.join(os.tmpdir(), 'notifai-repo-'))
404
- mkdirSync(path.join(base, '.git'), { recursive: true })
405
- const nested = path.join(base, 'packages', 'core')
406
- mkdirSync(nested, { recursive: true })
407
-
408
- expect(codexProjectRoot(base)).toBe(base)
409
- expect(codexProjectRoot(nested)).toBe(base)
410
- })
411
-
412
- it('falls back to the working directory outside a repository', () => {
413
- // Not every project is a git checkout, and an install there should still
414
- // land somewhere predictable rather than walking off to the filesystem root.
415
- const base = mkdtempSync(path.join(os.tmpdir(), 'notifai-bare-'))
416
-
417
- expect(codexProjectRoot(base)).toBe(base)
418
- })
419
-
420
- it('names the directory Codex needs before it will look for project hooks', () => {
421
- // Writing the main repository's file is not enough: Codex only loads the
422
- // project layer when a `.codex` directory sits at or above cwd, and in a
423
- // worktree there is nothing to find. An empty directory was the difference
424
- // between the handler running and nothing running.
425
- const { worktree } = repoWithWorktree()
426
-
427
- expect(codexLayerDir(worktree)).toBe(path.join(worktree, '.codex'))
428
- })
429
-
430
- it('anchors that directory at the worktree root so subdirectories are covered', () => {
431
- const { worktree } = repoWithWorktree()
432
- const nested = path.join(worktree, 'apps', 'cli')
433
- mkdirSync(nested, { recursive: true })
434
-
435
- // Codex walks up from cwd looking for it, so one at the root serves every
436
- // directory an agent might run from.
437
- expect(codexLayerDir(nested)).toBe(path.join(worktree, '.codex'))
438
- })
439
-
440
- it('asks for nothing extra in an ordinary checkout, where both halves agree', () => {
441
- const base = mkdtempSync(path.join(os.tmpdir(), 'notifai-plain-'))
442
- mkdirSync(path.join(base, '.git'), { recursive: true })
443
-
444
- expect(codexLayerDir(base)).toBeNull()
445
- })
446
-
447
- it('does not redirect Claude Code, which reads the worktree it runs in', () => {
448
- const { worktree } = repoWithWorktree()
449
-
450
- expect(settingsFile('claude-code', false, worktree, {})).toBe(
451
- path.join(worktree, '.claude', 'settings.local.json'),
452
- )
453
- })
454
-
455
- it('follows the active harness home for global hook install', () => {
456
- expect(settingsFile('codex', true, '/repo', { HOME: '/user', CODEX_HOME: '/managed/codex' })).toBe(
457
- '/managed/codex/config.toml',
458
- )
459
- expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
460
- '/managed/claude/settings.json',
461
- )
462
- })
463
- })
464
-
465
- describe('upgrading past a dropped event', () => {
466
- it('removes a handler for an event this build no longer serves', () => {
467
- // Found live in this repo: settings.local.json still ran
468
- // `main.js hook permission-request`, written by a build that had such an
469
- // event. The current binary exits 2 with "Unknown hook event" every time
470
- // the harness fires it, and reinstalling never cleaned it up because the
471
- // merge only touched events present in the incoming config.
472
- const stale = {
473
- hooks: {
474
- PermissionRequest: [
475
- { hooks: [{ type: 'command' as const, command: hookCommand(ADAPTER, 'permission-request') }] },
476
- ],
477
- Stop: [{ hooks: [{ type: 'command' as const, command: hookCommand(ADAPTER, 'stop') }] }],
478
- },
479
- }
480
-
481
- const merged = mergeHooks(stale, ours(), SCRIPT)
482
-
483
- expect(merged.document.hooks?.['PermissionRequest']).toBeUndefined()
484
- expect(merged.removed).toEqual(['PermissionRequest'])
485
- expect(merged.replaced).toContain('Stop')
486
- })
487
-
488
- it('keeps someone else’s handler for that same dropped event', () => {
489
- const mixed = {
490
- hooks: {
491
- PermissionRequest: [
492
- {
493
- hooks: [
494
- { type: 'command' as const, command: hookCommand(ADAPTER, 'permission-request') },
495
- { type: 'command' as const, command: 'their-own-tool --check' },
496
- ],
497
- },
498
- ],
499
- },
500
- }
501
-
502
- const merged = mergeHooks(mixed, ours(), SCRIPT)
503
-
504
- expect(merged.document.hooks?.['PermissionRequest']?.[0]?.hooks).toEqual([
505
- { type: 'command', command: 'their-own-tool --check' },
506
- ])
507
- })
508
-
509
- it('does not accumulate duplicates when run twice', () => {
510
- const once = mergeHooks({}, ours(), SCRIPT)
511
- const twice = mergeHooks(once.document, ours(), SCRIPT)
512
- expect(twice.document.hooks?.['Stop']?.[0]?.hooks).toHaveLength(1)
513
- expect(twice.removed).toEqual([])
514
- })
515
- })
516
-
517
- describe('writing the settings file', () => {
518
- function scratch(): string {
519
- return mkdtempSync(path.join(os.tmpdir(), 'notifai-apply-'))
520
- }
521
-
522
- it('refuses to write through a symlink', () => {
523
- const dir = scratch()
524
- const real = path.join(dir, 'somewhere-else.json')
525
- const link = path.join(dir, 'settings.json')
526
- writeFileSync(real, '{"untouched":true}\n')
527
- symlinkSync(real, link)
528
-
529
- expect(() => applyPlan(link, { hooks: {} })).toThrow(/symlink/)
530
- // The point of refusing: the target must be exactly as it was.
531
- expect(readFileSync(real, 'utf8')).toBe('{"untouched":true}\n')
532
- expect(lstatSync(link).isSymbolicLink()).toBe(true)
533
- })
534
-
535
- it('preserves an existing file’s mode and leaves no temp file behind', () => {
536
- const dir = scratch()
537
- const file = path.join(dir, 'settings.json')
538
- writeFileSync(file, '{}\n', { mode: 0o644 })
539
-
540
- applyPlan(file, { hooks: ours() })
541
-
542
- expect(statSync(file).mode & 0o777).toBe(0o644)
543
- expect(JSON.parse(readFileSync(file, 'utf8'))).toHaveProperty('hooks.Stop')
544
- expect(readdirSync(dir)).toEqual(['settings.json'])
545
- })
546
-
547
- it('keeps a file it creates private, since settings can carry tokens', () => {
548
- const dir = scratch()
549
- const file = path.join(dir, 'nested', 'settings.json')
550
- applyPlan(file, { hooks: {} })
551
- expect(statSync(file).mode & 0o777).toBe(0o600)
552
- })
553
-
554
- it('merges into Codex config.toml without dropping trust state or foreign hooks', () => {
555
- const dir = scratch()
556
- const file = path.join(dir, 'config.toml')
557
- writeFileSync(
558
- file,
559
- [
560
- 'model = "gpt-5.6"',
561
- '',
562
- '[features]',
563
- 'hooks = true',
564
- '',
565
- `[hooks.state."${file}:stop:0:0"]`,
566
- 'trusted_hash = "sha256:abc"',
567
- '',
568
- '[[hooks.Stop]]',
569
- '',
570
- '[[hooks.Stop.hooks]]',
571
- 'type = "command"',
572
- 'command = "gdh-stop"',
573
- '',
574
- ].join('\n'),
575
- )
576
-
577
- const merged = mergeHooks(loadSettings(file), ours(), SCRIPT)
578
- applyPlan(file, merged.document)
579
-
580
- const text = readFileSync(file, 'utf8')
581
- expect(text).toContain('model = "gpt-5.6"')
582
- expect(text).toContain('trusted_hash = "sha256:abc"')
583
- expect(text).toContain('gdh-stop')
584
- expect(text).toContain('hook stop')
585
- expect(text).toContain('[[hooks.UserPromptSubmit]]')
586
- expect(text).toContain('[[hooks.SessionEnd]]')
587
- expect(merged.document.hooks?.['Stop']?.flatMap((group) => group.hooks)).toHaveLength(2)
588
- })
589
- })
590
-
591
- describe('finding what is installed', () => {
592
- it('reports Notifai handlers written as inline Codex [hooks]', () => {
593
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-find-toml-'))
594
- const file = path.join(cwd, '.codex', 'config.toml')
595
- mkdirSync(path.dirname(file), { recursive: true })
596
- writeFileSync(
597
- file,
598
- [
599
- '[[hooks.Stop]]',
600
- '',
601
- '[[hooks.Stop.hooks]]',
602
- 'type = "command"',
603
- 'command = "gdh-stop"',
604
- '',
605
- '[[hooks.Stop]]',
606
- '',
607
- '[[hooks.Stop.hooks]]',
608
- 'type = "command"',
609
- `command = "${hookCommand(ADAPTER, 'stop', 'codex')}"`,
610
- '',
611
- ].join('\n'),
612
- )
613
-
614
- const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
615
- const codex = found.find((installation) => installation.harness === 'codex' && !installation.global)
616
- expect(codex?.file).toBe(file)
617
- expect(codex?.handlers.map((handler) => handler.event)).toEqual(['Stop'])
618
- expect(codex?.handlers[0]?.handlerIndex).toBe(0)
619
- expect(codex?.handlers[0]?.groupIndex).toBe(1)
620
- })
621
-
622
- /**
623
- * The shape a session manager leaves behind: it owns `hooks.json` for its own
624
- * agent lifecycle, Notifai sits in `config.toml`, and every handler fires
625
- * exactly once. Reported as a Notifai failure for a whole release, it sent
626
- * users to hand-edit a file another program regenerates — for nothing.
627
- */
628
- it('says nothing when the other representation holds only foreign hooks', () => {
629
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-foreign-rep-'))
630
- const layer = path.join(cwd, '.codex')
631
- mkdirSync(layer, { recursive: true })
632
- applyPlan(path.join(layer, 'hooks.json'), {
633
- hooks: {
634
- Stop: [{ hooks: [{ type: 'command', command: '/bin/sh /opt/other-tool/hook.sh' }] }],
635
- UserPromptSubmit: [
636
- { hooks: [{ type: 'command', command: '/bin/sh /opt/other-tool/hook.sh' }] },
637
- ],
638
- },
639
- })
640
- writeFileSync(
641
- path.join(layer, 'config.toml'),
642
- stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
643
- )
644
-
645
- expect(codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })).toEqual([])
646
- })
647
-
648
- it('names the foreign file and says Notifai will not touch it', () => {
649
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-coexist-'))
650
- const layer = path.join(cwd, '.codex')
651
- mkdirSync(layer, { recursive: true })
652
- applyPlan(path.join(layer, 'hooks.json'), {
653
- hooks: {
654
- Stop: [{ hooks: [{ type: 'command', command: '/bin/sh /opt/other-tool/hook.sh' }] }],
655
- },
656
- })
657
- writeFileSync(
658
- path.join(layer, 'config.toml'),
659
- stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
660
- )
661
-
662
- const notes = codexCoexistenceNotes(cwd, { HOME: path.join(cwd, 'isolated-home') })
663
- expect(notes).toHaveLength(1)
664
- expect(notes[0]).toContain(path.join(layer, 'hooks.json'))
665
- expect(notes[0]).toContain('Notifai will not modify it')
666
- expect(notes[0]).toContain('not a Notifai fault')
667
- expect(notes[0]).toContain("can end a turn before Notifai's answer arrives")
668
- })
669
-
670
- it('says nothing when Notifai is the only thing in the layer', () => {
671
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-coexist-alone-'))
672
- const layer = path.join(cwd, '.codex')
673
- mkdirSync(layer, { recursive: true })
674
- writeFileSync(
675
- path.join(layer, 'config.toml'),
676
- stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
677
- )
678
-
679
- expect(codexCoexistenceNotes(cwd, { HOME: path.join(cwd, 'isolated-home') })).toEqual([])
680
- })
681
-
682
- it('names the events a Notifai copy in each file would notify twice for', () => {
683
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-dual-rep-'))
684
- const layer = path.join(cwd, '.codex')
685
- mkdirSync(layer, { recursive: true })
686
- applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
687
- writeFileSync(
688
- path.join(layer, 'config.toml'),
689
- stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
690
- )
691
-
692
- const problems = codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })
693
- expect(problems).toHaveLength(1)
694
- expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
695
- expect(problems[0]).toContain(path.join(layer, 'config.toml'))
696
- expect(problems[0]).toMatch(/Stop/)
697
- expect(problems[0]).toMatch(
698
- /notifies twice per turn for SessionStart, SubagentStart, UserPromptSubmit, Stop, SessionEnd/,
699
- )
700
- expect(problems[0]).toMatch(/notifai hooks uninstall --harness codex/)
701
- expect(problems[0]).not.toMatch(/--global/)
702
- })
703
-
704
- it('reports Notifai split across both files, where only one copy stays current', () => {
705
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-split-rep-'))
706
- const layer = path.join(cwd, '.codex')
707
- mkdirSync(layer, { recursive: true })
708
- const { Stop, ...rest } = ours()
709
- applyPlan(path.join(layer, 'hooks.json'), { hooks: { Stop: Stop! } })
710
- writeFileSync(
711
- path.join(layer, 'config.toml'),
712
- stringifyToml({ hooks: rest as unknown as Record<string, unknown> }),
713
- )
714
-
715
- const problems = codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })
716
- expect(problems).toHaveLength(1)
717
- expect(problems[0]).toMatch(/split between them/)
718
- expect(problems[0]).toMatch(/Stop/)
719
- expect(problems[0]).toMatch(/UserPromptSubmit/)
720
- expect(problems[0]).not.toMatch(/twice per turn/)
721
- })
722
-
723
- it('scopes the remedy to the global layer it found the duplicate in', () => {
724
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-global-rep-'))
725
- const home = path.join(cwd, 'isolated-home')
726
- const layer = path.join(home, '.codex')
727
- mkdirSync(layer, { recursive: true })
728
- applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
729
- writeFileSync(
730
- path.join(layer, 'config.toml'),
731
- stringifyToml({ hooks: ours() as unknown as Record<string, unknown> }),
732
- )
733
-
734
- const problems = codexRepresentationProblems(cwd, { HOME: home })
735
- expect(problems).toHaveLength(1)
736
- expect(problems[0]).toMatch(/notifai hooks uninstall --harness codex --global/)
737
- expect(problems[0]).toMatch(/notifai hooks install --harness codex --global/)
738
- })
739
-
740
- it('reports handlers from either harness with their positions', () => {
741
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-find-'))
742
- mkdirSync(path.join(cwd, '.claude'), { recursive: true })
743
- applyPlan(path.join(cwd, '.claude', 'settings.local.json'), { hooks: ours() })
744
-
745
- const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
746
- const claude = found.find((i) => i.harness === 'claude-code' && !i.global)
747
-
748
- expect(claude).toBeDefined()
749
- expect(claude?.handlers.map((h) => h.event).sort()).toEqual([
750
- 'SessionEnd',
751
- 'SessionStart',
752
- 'Stop',
753
- 'SubagentStart',
754
- 'UserPromptSubmit',
755
- ])
756
- expect(claude?.handlers.every((h) => h.groupIndex === 0 && h.handlerIndex === 0)).toBe(true)
757
- })
758
-
759
- it('reads the event a handler actually invokes, not the key it sits under', () => {
760
- // These diverge exactly when an upgrade drops an event, which is the case
761
- // worth detecting.
762
- expect(handlerEvent(hookCommand(ADAPTER, 'permission-request'))).toBe('permission-request')
763
- expect(handlerEvent('unrelated --tool')).toBeNull()
764
- })
765
-
766
- it('detects when Codex still trusts an older definition of an installed hook', () => {
767
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-'))
768
- const home = path.join(cwd, 'home')
769
- const env = { HOME: home }
770
- const hookFile = path.join(cwd, '.codex', 'hooks.json')
771
- mkdirSync(path.dirname(hookFile), { recursive: true })
772
- applyPlan(hookFile, { hooks: ours() })
773
- const installations = findInstallations(cwd, env)
774
- const codex = installations.find((installation) => installation.harness === 'codex')
775
- const stop = codex?.handlers.find((handler) => handler.event === 'Stop')
776
- expect(stop).toBeDefined()
777
-
778
- const trustFile = path.join(home, '.codex', 'config.toml')
779
- mkdirSync(path.dirname(trustFile), { recursive: true })
780
- writeFileSync(
781
- trustFile,
782
- `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "sha256:obsolete"\n`,
783
- )
784
-
785
- const stale = codexTrustProblems(installations, env)
786
- expect(stale).toHaveLength(5)
787
- expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
788
-
789
- writeFileSync(
790
- trustFile,
791
- `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
792
- )
793
- const untrusted = codexTrustProblems(installations, env)
794
- expect(untrusted).toHaveLength(4)
795
- expect(untrusted).toEqual(
796
- expect.arrayContaining([
797
- expect.stringMatching(/SessionStart.*not trusted.*\/hooks/i),
798
- expect.stringMatching(/SubagentStart.*not trusted.*\/hooks/i),
799
- expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
800
- expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
801
- ]),
802
- )
803
-
804
- writeFileSync(
805
- trustFile,
806
- `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\nenabled = false\n`,
807
- )
808
- expect(codexTrustProblems(installations, env)).toEqual(
809
- expect.arrayContaining([expect.stringMatching(/Stop.*disabled.*\/hooks/i)]),
810
- )
811
- })
812
-
813
- })
814
-
815
- /**
816
- * Ownership was matched on the absolute script path, so a second
817
- * checkout did not recognise the first one's handlers as ours: both stayed,
818
- * the harness ran both, and one question produced two notifications.
819
- */
820
- describe('two checkouts', () => {
821
- const OTHER = '/Users/rafael/notifai-public/apps/cli/dist/main.js'
822
-
823
- function documentWith(command: string): SettingsDocument {
824
- return { hooks: { Stop: [{ matcher: '*', hooks: [{ type: 'command', command, timeout: 300 }] }] } }
825
- }
826
-
827
- function stopCommands(document: SettingsDocument): string[] {
828
- return (document.hooks?.['Stop'] ?? []).flatMap((group) => group.hooks.map((h) => h.command))
829
- }
830
-
831
- it('replaces a handler another checkout installed instead of running beside it', () => {
832
- const existing = documentWith(hookCommand('/other/notifai/hook-adapter', 'stop'))
833
- const merged = mergeHooks(
834
- existing,
835
- buildHookConfig({ adapterPath: ADAPTER }),
836
- SCRIPT,
837
- )
838
- const commands = stopCommands(merged.document)
839
- expect(commands.filter((c) => c.includes('hook stop'))).toHaveLength(1)
840
- expect(commands[0]).toContain(ADAPTER)
841
- expect(commands.join(' ')).not.toContain('/other/notifai/hook-adapter')
842
- })
843
-
844
- it('uninstalls a handler another checkout installed', () => {
845
- const removed = removeHooks(documentWith(hookCommand('/other/notifai/hook-adapter', 'stop')), SCRIPT)
846
- expect(stopCommands(removed.document)).toHaveLength(0)
847
- })
848
-
849
- it('still recognises an install that predates the marker', () => {
850
- // Different checkout, no marker: the form written by an older build.
851
- const legacy = documentWith(`'${EXEC}' '${OTHER}' hook stop`)
852
- expect(stopCommands(removeHooks(legacy, SCRIPT).document)).toHaveLength(0)
853
- })
854
-
855
- it("leaves someone else's hook alone", () => {
856
- const foreign = documentWith('/usr/local/bin/my-own-thing --stop')
857
- expect(stopCommands(removeHooks(foreign, SCRIPT).document)).toHaveLength(1)
858
- })
859
-
860
- it("preserves a foreign project's generic dist/main.js hook", () => {
861
- const foreign = documentWith("'/usr/local/bin/node' '/opt/foreign/dist/main.js' hook stop")
862
- expect(stopCommands(removeHooks(foreign, SCRIPT).document)).toHaveLength(1)
863
- })
864
- })
865
-
866
- /**
867
- * OpenCode's extension point is a plugin module, not a command
868
- * hook, so it needs a different adapter — but deliberately not different logic.
869
- */
870
- describe('the OpenCode adapter', () => {
871
- const source = opencodePluginSource({
872
- adapterPath: ADAPTER,
873
- timeoutSeconds: 240,
874
- })
875
-
876
- it('shells out to the same hook commands the other harnesses run', () => {
877
- // The whole point: presence, escalation and retirement stay in the CLI.
878
- expect(source).toContain('"hook", event')
879
- expect(source).toContain('"--harness", "opencode"')
880
- expect(source).toContain(JSON.stringify(ADAPTER))
881
- expect(source).not.toContain(JSON.stringify(SCRIPT))
882
- expect(source).not.toContain(JSON.stringify(EXEC))
883
- })
884
-
885
- it('wires the three question lifecycle joints and leaves permissions alone', () => {
886
- expect(source).toContain('"chat.message"')
887
- expect(source).toContain('event: async ({ event })')
888
- expect(source).toContain('event?.type === "session.idle"')
889
- expect(source).toContain('event?.properties?.sessionID')
890
- expect(source).toContain('event?.type === "session.deleted"')
891
- expect(source).toContain('event?.properties?.info?.id')
892
- expect(source).not.toContain('"permission.ask"')
893
- })
894
-
895
- it('classifies OpenCode parent, child, lookup failure, and missing identity safely', async () => {
896
- expect(source).toContain('"experimental.chat.system.transform"')
897
- const generated = (await import(
898
- `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
899
- )) as {
900
- NotifAIPlugin(input: {
901
- directory: string
902
- client: {
903
- session: {
904
- get(input: { path: { id: string } }): Promise<{ data: object }>
905
- }
906
- }
907
- }): Promise<{
908
- 'experimental.chat.system.transform'?: (
909
- input: { sessionID?: string },
910
- output: { system: string[] },
911
- ) => Promise<void>
912
- }>
913
- }
914
- const plugin = await generated.NotifAIPlugin({
915
- directory: '/repo',
916
- client: {
917
- session: {
918
- get: async ({ path: { id } }) => {
919
- if (id === 'ses_parent') return { data: { id } }
920
- if (id === 'ses_child') return { data: { id, parentID: 'ses_parent' } }
921
- throw new Error('relationship lookup unavailable')
922
- },
923
- },
924
- },
925
- })
926
- const parent = { system: ['existing context'] }
927
-
928
- await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_parent' }, parent)
929
-
930
- expect(parent.system).toEqual([`existing context\n\n${MISSING_LIFECYCLE_GUIDANCE_CONTEXT}`])
931
-
932
- const child = { system: [] as string[] }
933
- await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_child' }, child)
934
- expect(child.system).toEqual([WORKER_ACTIVATION_CONTEXT])
935
-
936
- const lookupFailure = { system: [] as string[] }
937
- await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_unknown' }, lookupFailure)
938
- expect(lookupFailure.system).toEqual([WORKER_ACTIVATION_CONTEXT])
939
-
940
- const missingIdentity = { system: ['internal agent-generation context'] }
941
- await plugin['experimental.chat.system.transform']?.({}, missingIdentity)
942
- expect(missingIdentity.system).toEqual(['internal agent-generation context'])
943
- })
944
-
945
- it('publishes the session pointer on a user message without injecting content', () => {
946
- // The CLI has never emitted `hookSpecificOutput.additionalContext`, so the
947
- // injection this used to assert could not fire. The handler earns its place
948
- // by publishing the pointer that lets `notifai ask` find this session.
949
- expect(source).toContain('"chat.message"')
950
- expect(source).toContain('hook_event_name: "UserPromptSubmit"')
951
- expect(source).not.toContain('additionalContext')
952
- expect(source).not.toContain('synthetic: true')
953
- })
954
-
955
- it('injects exact identity and the first-party OpenCode title into agent shells', () => {
956
- expect(source).toContain('"shell.env"')
957
- expect(source).toContain('NOTIFAI_ACTIVE_HARNESS')
958
- expect(source).toContain('NOTIFAI_ACTIVE_SESSION_ID')
959
- expect(source).toContain('NOTIFAI_ACTIVE_SESSION_LABEL')
960
- expect(source).toContain('NOTIFAI_ACTIVE_SESSION_LABEL_PENDING')
961
- expect(source).toContain('input?.sessionID')
962
- expect(source).toContain('client.session.get({ path: { id: sessionID } })')
963
- expect(source).toContain('response?.data?.title')
964
- })
965
-
966
- it('executes the generated adapter and publishes the SDK session title', async () => {
967
- let requested = ''
968
- const generated = (await import(
969
- `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
970
- )) as {
971
- NotifAIPlugin(input: {
972
- directory: string
973
- client: {
974
- session: {
975
- get(input: { path: { id: string } }): Promise<{ data: { title: string } }>
976
- }
977
- }
978
- }): Promise<{
979
- 'shell.env'?: (
980
- input: { sessionID?: string },
981
- output: { env: Record<string, string> },
982
- ) => Promise<void>
983
- }>
984
- }
985
- const plugin = await generated.NotifAIPlugin({
986
- directory: '/repo',
987
- client: {
988
- session: {
989
- get: async ({ path: request }) => {
990
- requested = request.id
991
- return { data: { title: 'Semantic session names' } }
992
- },
993
- },
994
- },
995
- })
996
- const output = { env: {} as Record<string, string> }
997
- await plugin['shell.env']?.({ sessionID: 'opencode-session' }, output)
998
-
999
- expect(requested).toBe('opencode-session')
1000
- expect(output.env).toMatchObject({
1001
- NOTIFAI_ACTIVE_HARNESS: 'opencode',
1002
- NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
1003
- NOTIFAI_ACTIVE_SESSION_LABEL: 'Semantic session names',
1004
- })
1005
- })
1006
-
1007
- it('marks OpenCode placeholder titles as pending instead of publishing them', async () => {
1008
- const generated = (await import(
1009
- `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
1010
- )) as {
1011
- NotifAIPlugin(input: {
1012
- directory: string
1013
- client: {
1014
- session: {
1015
- get(input: { path: { id: string } }): Promise<{ data: { title: string } }>
1016
- }
1017
- }
1018
- }): Promise<{
1019
- 'shell.env'?: (
1020
- input: { sessionID?: string },
1021
- output: { env: Record<string, string> },
1022
- ) => Promise<void>
1023
- }>
1024
- }
1025
- const plugin = await generated.NotifAIPlugin({
1026
- directory: '/repo',
1027
- client: {
1028
- session: {
1029
- get: async () => ({
1030
- data: { title: 'New session - 2026-08-20T13:05:00.000Z' },
1031
- }),
1032
- },
1033
- },
1034
- })
1035
- const output = { env: {} as Record<string, string> }
1036
- await plugin['shell.env']?.({ sessionID: 'opencode-session' }, output)
1037
-
1038
- expect(output.env).toEqual({
1039
- NOTIFAI_ACTIVE_HARNESS: 'opencode',
1040
- NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
1041
- NOTIFAI_ACTIVE_SESSION_LABEL_PENDING: '1',
1042
- })
1043
- })
1044
-
1045
- it('carries the ownership marker so a second checkout replaces it', () => {
1046
- expect(source).toContain(OPENCODE_PLUGIN_MARKER)
1047
- expect(isOurOpencodePlugin(source)).toBe(true)
1048
- expect(isOurOpencodePlugin('export const SomeoneElse = () => ({})')).toBe(false)
1049
- })
1050
-
1051
- it('installs beside the config rather than into it', () => {
1052
- const local = opencodePluginPath(false, '/repo', {})
1053
- expect(local).toBe(path.join('/repo', '.opencode', 'plugins', 'notifai.js'))
1054
- const global = opencodePluginPath(true, '/repo', { OPENCODE_CONFIG_DIR: '/cfg/opencode' })
1055
- expect(global).toBe(path.join('/cfg/opencode', 'plugins', 'notifai.js'))
1056
- })
1057
-
1058
- it('is a harness `hooks install` knows about', () => {
1059
- expect(HOOK_INSTALLABLE_HARNESSES).toContain('opencode')
1060
- expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
1061
- expect(settingsFile('opencode', false, '/repo', {})).toContain('notifai.js')
1062
- })
1063
-
1064
- it('reports the same stable adapter identity as command-hook harnesses', () => {
1065
- expect(opencodePluginTarget(source)).toEqual({
1066
- adapter: ADAPTER,
1067
- current: true,
1068
- timeoutSeconds: 240,
1069
- })
1070
- expect(opencodePluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
1071
- adapter: ADAPTER,
1072
- current: false,
1073
- timeoutSeconds: 240,
1074
- })
1075
- expect(opencodePluginTarget('export const SomeoneElse = () => ({})')).toBeNull()
1076
- // Ours, but with the constants edited beyond recognition: no answer beats
1077
- // a wrong one, since the caller compares these for equality.
1078
- expect(opencodePluginTarget(`${OPENCODE_PLUGIN_MARKER}\nconst ADAPTER = whatever\n`)).toBeNull()
1079
- })
1080
- })
1081
-
1082
- describe('the OpenClaw adapter', () => {
1083
- const source = openclawPluginSource({
1084
- adapterPath: ADAPTER,
1085
- timeoutSeconds: 240,
1086
- })
1087
-
1088
- async function loadPlugin() {
1089
- return (await import(
1090
- `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
1091
- )) as {
1092
- default: {
1093
- id: string
1094
- register(api: {
1095
- on(name: string, handler: (...args: never[]) => unknown): void
1096
- }): void
1097
- }
1098
- }
1099
- }
1100
-
1101
- function handlersOf(mod: Awaited<ReturnType<typeof loadPlugin>>) {
1102
- const handlers = new Map<string, (...args: never[]) => unknown>()
1103
- mod.default.register({
1104
- on(name, handler) {
1105
- handlers.set(name, handler)
1106
- },
1107
- })
1108
- return handlers
1109
- }
1110
-
1111
- it('shells out to the same hook commands the other harnesses run', () => {
1112
- expect(source).toContain('"hook", event')
1113
- expect(source).toContain('"--harness", "openclaw"')
1114
- expect(source).toContain(JSON.stringify(ADAPTER))
1115
- expect(source).not.toContain('command:stop')
1116
- })
1117
-
1118
- it('wires current plugin joints and leaves user abort alone', () => {
1119
- expect(source).toContain('api.on("before_prompt_build"')
1120
- expect(source).toContain('api.on("message_received"')
1121
- expect(source).toContain('api.on("agent_end"')
1122
- expect(source).toContain('api.on("session_end"')
1123
- expect(source).toContain('api.on("resolve_exec_env"')
1124
- expect(source).not.toContain('command:stop')
1125
- })
1126
-
1127
- it('classifies root, subagent, ACP, and missing identity safely', async () => {
1128
- const handlers = handlersOf(await loadPlugin())
1129
- const activate = handlers.get('before_prompt_build') as (
1130
- event: object,
1131
- ctx: object,
1132
- ) => Promise<{ prependContext?: string } | undefined>
1133
-
1134
- const root = await activate({}, { sessionKey: 'agent:main:main', workspaceDir: '/ws' })
1135
- expect(root?.prependContext).toBe(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)
1136
-
1137
- const worker = await activate({}, { sessionKey: 'agent:main:subagent:abc', workspaceDir: '/ws' })
1138
- expect(worker?.prependContext).toBe(WORKER_ACTIVATION_CONTEXT)
1139
-
1140
- const acp = await activate({}, { sessionKey: 'agent:main:acp-child', targetKind: 'acp' })
1141
- expect(acp?.prependContext).toBe(WORKER_ACTIVATION_CONTEXT)
1142
-
1143
- const missing = await activate({}, {})
1144
- expect(missing?.prependContext).toBe(WORKER_ACTIVATION_CONTEXT)
1145
- })
1146
-
1147
- it('does not treat heartbeat or cron traffic as User presence', async () => {
1148
- const handlers = handlersOf(await loadPlugin())
1149
- const presence = handlers.get('message_received') as (
1150
- event: object,
1151
- ctx: object,
1152
- ) => Promise<void>
1153
- await presence({ from: '' }, { sessionKey: 'agent:main:main' })
1154
- await presence({ from: 'cron' }, { sessionKey: 'agent:main:main', trigger: 'cron' })
1155
- await presence({ from: 'hb' }, { sessionKey: 'agent:main:main', trigger: 'heartbeat' })
1156
- expect(source).toContain('trigger === "cron" || trigger === "heartbeat"')
1157
- })
1158
-
1159
- it('publishes exact sessionKey markers into exec without PATH', async () => {
1160
- const handlers = handlersOf(await loadPlugin())
1161
- const resolve = handlers.get('resolve_exec_env') as (
1162
- event: object,
1163
- ctx: object,
1164
- ) => Record<string, string> | undefined
1165
- expect(resolve({}, { sessionKey: 'agent:main:telegram:dm:1' })).toEqual({
1166
- NOTIFAI_ACTIVE_HARNESS: 'openclaw',
1167
- NOTIFAI_ACTIVE_SESSION_ID: 'agent:main:telegram:dm:1',
1168
- })
1169
- expect(resolve({}, {})).toBeUndefined()
1170
- expect(source).not.toContain('PATH:')
1171
- })
1172
-
1173
- it('activates a parent session only once', async () => {
1174
- const handlers = handlersOf(await loadPlugin())
1175
- const activate = handlers.get('before_prompt_build') as (
1176
- event: object,
1177
- ctx: object,
1178
- ) => Promise<{ prependContext?: string } | undefined>
1179
- const first = await activate({}, { sessionKey: 'agent:main:main' })
1180
- const second = await activate({}, { sessionKey: 'agent:main:main' })
1181
- expect(first?.prependContext).toBe(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)
1182
- expect(second).toBeUndefined()
1183
- })
1184
-
1185
- it('carries the ownership marker so a second checkout replaces it', () => {
1186
- expect(source).toContain(OPENCLAW_PLUGIN_MARKER)
1187
- expect(isOurOpenclawPlugin(source)).toBe(true)
1188
- expect(isOurOpenclawPlugin('export default { id: "other" }')).toBe(false)
1189
- })
1190
-
1191
- it('installs beside the OpenClaw config rather than into a hook document', () => {
1192
- const local = openclawPluginPath(false, '/repo', {})
1193
- expect(local).toBe(path.join('/repo', '.openclaw', 'extensions', 'notifai', 'index.js'))
1194
- const global = openclawPluginPath(true, '/repo', { OPENCLAW_STATE_DIR: '/cfg/openclaw' })
1195
- expect(global).toBe(path.join('/cfg/openclaw', 'extensions', 'notifai', 'index.js'))
1196
- })
1197
-
1198
- it('is a harness `hooks install` knows about', () => {
1199
- expect(HOOK_INSTALLABLE_HARNESSES).toContain('openclaw')
1200
- expect(settingsFile('openclaw', false, '/repo', {})).toContain('index.js')
1201
- })
1202
-
1203
- it('reports the same stable adapter identity as command-hook harnesses', () => {
1204
- expect(openclawPluginTarget(source)).toEqual({
1205
- adapter: ADAPTER,
1206
- current: true,
1207
- timeoutSeconds: 240,
1208
- })
1209
- expect(openclawPluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
1210
- adapter: ADAPTER,
1211
- current: false,
1212
- timeoutSeconds: 240,
1213
- })
1214
- expect(openclawPluginTarget('export default { id: "other" }')).toBeNull()
1215
- })
1216
-
1217
- it('merges only the Notifai plugin entry into OpenClaw config', () => {
1218
- const merged = mergeOpenclawNotifaiEntry(
1219
- { gateway: { port: 18789 }, plugins: { entries: { memory: { enabled: true } } } },
1220
- '/repo/.openclaw/extensions/notifai',
1221
- false,
1222
- )
1223
- expect(merged).toMatchObject({
1224
- gateway: { port: 18789 },
1225
- plugins: {
1226
- entries: {
1227
- memory: { enabled: true },
1228
- notifai: { enabled: true, hooks: { allowConversationAccess: true } },
1229
- },
1230
- load: { paths: ['/repo/.openclaw/extensions/notifai'] },
1231
- },
1232
- })
1233
- const stripped = removeOpenclawNotifaiEntry(merged, '/repo/.openclaw/extensions/notifai')
1234
- expect(stripped).toEqual({
1235
- gateway: { port: 18789 },
1236
- plugins: { entries: { memory: { enabled: true } } },
1237
- })
1238
- })
1239
-
1240
- it('parses JSON with comments without requiring a JSON5 dependency', () => {
1241
- expect(parseOpenclawConfig('{\n // port\n "gateway": { "port": 1 },\n}\n')).toEqual({
1242
- gateway: { port: 1 },
1243
- })
1244
- })
1245
-
1246
- it('does not treat a skills-only ~/.openclaw tree as OpenClaw evidence', () => {
1247
- const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-skills-only-'))
1248
- mkdirSync(path.join(home, '.openclaw', 'skills'), { recursive: true })
1249
- expect(openclawHasGlobalEvidence(existsSync, { HOME: home })).toBe(false)
1250
- writeFileSync(path.join(home, '.openclaw', 'openclaw.json'), '{}\n')
1251
- expect(openclawHasGlobalEvidence(existsSync, { HOME: home })).toBe(true)
1252
- })
1253
- })
1254
-
1255
- describe('detectHarness', () => {
1256
- function project(...entries: string[]): string {
1257
- const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-'))
1258
- for (const entry of entries) {
1259
- if (entry.endsWith('.md')) writeFileSync(path.join(dir, entry), '# project\n')
1260
- else mkdirSync(path.join(dir, entry), { recursive: true })
1261
- }
1262
- return dir
1263
- }
1264
-
1265
- it('detects Claude Code from a project .claude directory', () => {
1266
- expect(detectHarness(project('.claude'))).toBe('claude-code')
1267
- })
1268
-
1269
- it('detects Claude Code from CLAUDE.md alone', () => {
1270
- // A repository worked in daily through Claude Code may never accumulate a
1271
- // .claude/ directory; its project file is the only marker there is.
1272
- expect(detectHarness(project('CLAUDE.md'))).toBe('claude-code')
1273
- })
1274
-
1275
- it('detects Claude Code from .claude and CLAUDE.md together without double-counting', () => {
1276
- expect(detectHarness(project('.claude', 'CLAUDE.md'))).toBe('claude-code')
1277
- })
1278
-
1279
- it('does not infer a harness from AGENTS.md', () => {
1280
- // AGENTS.md began as a Codex convention and is now read by most agent
1281
- // tooling, Claude Code included. Naming a harness from it would be a guess
1282
- // wearing detection's clothes. With no other project evidence this falls
1283
- // through to machine evidence, which on a multi-tool machine is ambiguous.
1284
- const dir = project('AGENTS.md')
1285
- const answer = detectHarness(dir)
1286
- expect(answer).not.toBe('codex')
1287
- expect(HOOK_INSTALLABLE_HARNESSES.includes(answer as never) || answer === null).toBe(true)
1288
- })
1289
-
1290
- it('pairs AGENTS.md with CLAUDE.md as Claude Code, not Codex', () => {
1291
- // The shape this very repository has, and four of the five projects the
1292
- // release was verified in.
1293
- expect(detectHarness(project('CLAUDE.md', 'AGENTS.md'))).toBe('claude-code')
1294
- })
1295
-
1296
- it('detects each harness from its own project directory', () => {
1297
- expect(detectHarness(project('.codex'))).toBe('codex')
1298
- expect(detectHarness(project('.cursor'))).toBe('cursor')
1299
- expect(detectHarness(project('.opencode'))).toBe('opencode')
1300
- expect(detectHarness(project('.openclaw'))).toBe('openclaw')
1301
- })
1302
-
1303
- it('returns null when the project itself names two harnesses', () => {
1304
- // Genuine ambiguity for a single-harness caller. detectedHarnesses lists
1305
- // both so install can wire them together.
1306
- expect(detectHarness(project('.claude', '.codex'))).toBeNull()
1307
- })
1308
-
1309
- it('lists every project-named harness in declared order', () => {
1310
- const isolated = { HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-home-')) }
1311
- expect(detectedHarnesses(project('.codex', '.claude'), isolated)).toEqual([
1312
- 'claude-code',
1313
- 'codex',
1314
- ])
1315
- })
1316
-
1317
- it('detects Codex from the active custom CODEX_HOME', () => {
1318
- const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-home-'))
1319
- const codexHome = path.join(home, 'managed', 'codex')
1320
- mkdirSync(codexHome, { recursive: true })
1321
-
1322
- expect(detectedHarnesses(project(), { HOME: home, CODEX_HOME: codexHome })).toEqual(['codex'])
1323
- })
1324
-
1325
- it('prefers project evidence over anything installed on the machine', () => {
1326
- // The regression. Machine-global markers used to be OR-ed in per harness,
1327
- // so a developer with several agent tools installed got a candidate for
1328
- // every one of them, the "exactly one" test never passed, and detection
1329
- // returned null in every repository on that machine — including ones whose
1330
- // own contents named a single harness unambiguously.
1331
- //
1332
- // This asserts the ordering rather than the machine state, so it holds on
1333
- // a CI box with a bare home directory and on a developer laptop carrying
1334
- // all four.
1335
- expect(detectHarness(project('.cursor'))).toBe('cursor')
1336
- expect(detectHarness(project('CLAUDE.md'))).toBe('claude-code')
1337
- })
1338
-
1339
- it('falls back to machine evidence only when the project offers none', () => {
1340
- const empty = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-empty-'))
1341
- // Whatever this machine has, an empty project must never crash and must
1342
- // return either a single harness or null — never an arbitrary pick.
1343
- const answer = detectHarness(empty)
1344
- expect(answer === null || HOOK_INSTALLABLE_HARNESSES.includes(answer)).toBe(true)
1345
- })
1346
- })
1347
-
1348
- describe('Windows hook commands and discovery', () => {
1349
- const winNode = 'C:\\Program Files\\nodejs\\node.exe'
1350
- const winAdapter = 'C:\\Users\\Ada Lovelace\\.notifai\\bin\\hook-adapter'
1351
- const winOpts = { platform: 'win32' as const, nodePath: winNode }
1352
-
1353
- it('keeps POSIX command bytes unchanged', () => {
1354
- expect(hookCommand(ADAPTER, 'stop', undefined, { platform: 'posix' })).toBe(
1355
- `'${ADAPTER}' hook stop --owner notifai`,
1356
- )
1357
- expect(hookCommand(ADAPTER, 'stop', 'codex', { platform: 'posix' })).toBe(
1358
- `'${ADAPTER}' hook stop --owner notifai --harness codex`,
1359
- )
1360
- })
1361
-
1362
- it('emits a Windows-safe command with no POSIX single quotes', () => {
1363
- const command = hookCommand(winAdapter, 'stop', 'codex', winOpts)
1364
- expect(command).toBe(
1365
- `"${winNode}" "${winAdapter}" hook stop --owner notifai --harness codex`,
1366
- )
1367
- expect(command).not.toContain("'")
1368
- expect(quoteWindowsArg('C:\\ends\\with\\')).toBe('"C:\\ends\\with\\\\"')
1369
- expect(quoteWindowsArg('say "hi"')).toBe('"say \\"hi\\""')
1370
- })
1371
-
1372
- it('round-trips the Windows command through JSON and TOML documents', () => {
1373
- const command = hookCommand(winAdapter, 'stop', 'cursor', winOpts)
1374
- expect(JSON.parse(JSON.stringify({ command }))).toEqual({ command })
1375
- expect(parseToml(stringifyToml({ command }))).toEqual({ command })
1376
- })
1377
-
1378
- it('discovers a Windows-quoted installation as the stable adapter', () => {
1379
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-win-discover-'))
1380
- const homeDir = path.join(cwd, 'home')
1381
- const script = path.join(cwd, 'cli.js')
1382
- writeFileSync(script, '')
1383
- installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir, 'win32')
1384
- const adapter = hookAdapterPath(homeDir)
1385
- mkdirSync(path.join(cwd, '.claude'), { recursive: true })
1386
- applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
1387
- hooks: buildHookConfig({
1388
- adapterPath: adapter,
1389
- harness: 'claude-code',
1390
- platform: 'win32',
1391
- nodePath: process.execPath,
1392
- }),
1393
- })
1394
-
1395
- const found = findInstallations(
1396
- cwd,
1397
- { HOME: homeDir, USERPROFILE: 'C:\\Users\\Ada' },
1398
- homeDir,
1399
- 'win32',
1400
- )
1401
- expect(found).toHaveLength(1)
1402
- expect(found[0]?.harness).toBe('claude-code')
1403
- expect(found[0]?.problems ?? []).toEqual([])
1404
- expect(found[0]?.handlers[0]?.command.startsWith(`${quoteWindowsArg(process.execPath)} `)).toBe(
1405
- true,
1406
- )
1407
- })
1408
-
1409
- it('prefers a Windows-absolute USERPROFILE over an MSYS HOME', () => {
1410
- const env = { HOME: '/c/Users/msys', USERPROFILE: 'C:\\Users\\Ada' }
1411
- expect(harnessAccountHome(env, 'win32')).toBe('C:\\Users\\Ada')
1412
- expect(settingsFile('cursor', true, '/repo', env, 'win32')).toBe(
1413
- path.join('C:\\Users\\Ada', '.cursor', 'hooks.json'),
1414
- )
1415
- expect(settingsFile('claude-code', true, '/repo', env, 'win32')).toBe(
1416
- path.join('C:\\Users\\Ada', '.claude', 'settings.json'),
1417
- )
1418
- expect(opencodePluginPath(true, '/repo', env, 'win32')).toBe(
1419
- path.join('C:\\Users\\Ada', '.config', 'opencode', 'plugins', 'notifai.js'),
1420
- )
1421
- expect(openclawPluginPath(true, '/repo', env, 'win32')).toBe(
1422
- path.join('C:\\Users\\Ada', '.openclaw', 'extensions', 'notifai', 'index.js'),
1423
- )
1424
- })
1425
-
1426
- it('makes OpenCode spawn the adapter through Node on Windows with shell:false', () => {
1427
- const source = opencodePluginSource({
1428
- adapterPath: winAdapter,
1429
- timeoutSeconds: 240,
1430
- platform: 'win32',
1431
- nodePath: winNode,
1432
- })
1433
- expect(source).toContain(`const NODE = ${JSON.stringify(winNode)}`)
1434
- expect(source).toContain('spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"]')
1435
- expect(source).toContain('shell: false')
1436
- expect(source).toContain('windowsHide: true')
1437
- expect(source).not.toContain('spawn(ADAPTER,')
1438
- expect(opencodePluginTarget(source)).toEqual({
1439
- adapter: winAdapter,
1440
- current: true,
1441
- timeoutSeconds: 240,
1442
- nodePath: winNode,
1443
- })
1444
- })
1445
-
1446
- it('makes OpenClaw spawn the adapter through Node on Windows with shell:false', () => {
1447
- const source = openclawPluginSource({
1448
- adapterPath: winAdapter,
1449
- timeoutSeconds: 240,
1450
- platform: 'win32',
1451
- nodePath: winNode,
1452
- })
1453
- expect(source).toContain(`const NODE = ${JSON.stringify(winNode)}`)
1454
- expect(source).toContain('spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "openclaw"]')
1455
- expect(source).toContain('shell: false')
1456
- expect(source).toContain('windowsHide: true')
1457
- expect(source).not.toContain('spawn(ADAPTER,')
1458
- expect(openclawPluginTarget(source)).toEqual({
1459
- adapter: winAdapter,
1460
- current: true,
1461
- timeoutSeconds: 240,
1462
- nodePath: winNode,
1463
- })
1464
- })
1465
- })
1466
-
1467
- /**
1468
- * `CODEX_HOME` replaces the Codex home rather than shadowing it: `codex doctor`
1469
- * with the variable set resolves every path inside it and never reads
1470
- * `~/.codex`. Installing to the account default anyway would write hooks the
1471
- * running agent cannot see, so Notifai follows the variable — and says so,
1472
- * because the same divergence is what makes a correct install look absent.
1473
- */
1474
- describe('codex home note', () => {
1475
- it('is silent when CODEX_HOME is unset', () => {
1476
- expect(codexHomeNote({ HOME: '/home/someone' }, 'linux')).toBeNull()
1477
- })
1478
-
1479
- it('is silent when CODEX_HOME names the account default', () => {
1480
- expect(
1481
- codexHomeNote({ HOME: '/home/someone', CODEX_HOME: '/home/someone/.codex' }, 'linux'),
1482
- ).toBeNull()
1483
- })
1484
-
1485
- it('names both homes and why the other one is not read', () => {
1486
- const note = codexHomeNote({ HOME: '/home/someone', CODEX_HOME: '/runtime/home' }, 'linux')
1487
- expect(note).toContain('/runtime/home')
1488
- expect(note).toContain('/home/someone/.codex')
1489
- expect(note).toContain('not read by Codex in this shell')
1490
- expect(note).toContain('trust by absolute file path')
1491
- })
1492
- })
1493
-
1494
- /**
1495
- * `config.toml` is the user's file and usually hand-written. Notifai adds three
1496
- * handlers to it and must hand back everything else exactly as it arrived —
1497
- * comments, ordering, and spacing included.
1498
- */
1499
- describe('writing config.toml around what is already there', () => {
1500
- const layerFor = (name: string): string => {
1501
- const repo = mkdtempSync(path.join(os.tmpdir(), name))
1502
- const layer = path.join(repo, '.codex')
1503
- mkdirSync(layer, { recursive: true })
1504
- return layer
1505
- }
1506
-
1507
- it('leaves every byte outside the hooks tables untouched', () => {
1508
- const layer = layerFor('notifai-toml-comments-')
1509
- const file = path.join(layer, 'config.toml')
1510
- const original = [
1511
- '# why this model, in the user\'s own words',
1512
- 'model = "gpt-5.6" # trailing note',
1513
- '',
1514
- '[mcp_servers.linear]',
1515
- '# a comment nobody but the user cares about',
1516
- 'command = "npx"',
1517
- 'args = ["-y", "linear-mcp"]',
1518
- '',
1519
- ].join('\n')
1520
- writeFileSync(file, original)
1521
-
1522
- applyPlan(file, { ...loadSettings(file), hooks: ours() })
1523
-
1524
- const after = readFileSync(file, 'utf8')
1525
- expect(after).toContain(original.trimEnd())
1526
- expect(after).toContain("# why this model, in the user's own words")
1527
- expect(after).toContain('# trailing note')
1528
- expect(after).toContain('# a comment nobody but the user cares about')
1529
- expect(parseToml(after)).toMatchObject({ model: 'gpt-5.6' })
1530
- expect(after).toContain('[[hooks.Stop]]')
1531
- })
1532
-
1533
- it('replaces the hooks tables rather than appending a second copy', () => {
1534
- const layer = layerFor('notifai-toml-replace-')
1535
- const file = path.join(layer, 'config.toml')
1536
- writeFileSync(file, '# keep me\nmodel = "gpt-5.6"\n')
1537
-
1538
- applyPlan(file, { ...loadSettings(file), hooks: ours() })
1539
- const once = readFileSync(file, 'utf8')
1540
- applyPlan(file, { ...loadSettings(file), hooks: ours() })
1541
- const twice = readFileSync(file, 'utf8')
1542
-
1543
- expect(twice).toBe(once)
1544
- expect(twice).toContain('# keep me')
1545
- expect(twice.match(/\[\[hooks\.Stop\]\]/g) ?? []).toHaveLength(1)
1546
- })
1547
-
1548
- it('carries the Codex trust store across a write', () => {
1549
- const layer = layerFor('notifai-toml-trust-')
1550
- const file = path.join(layer, 'config.toml')
1551
- writeFileSync(
1552
- file,
1553
- `model = "gpt-5.6"\n\n[hooks.state."${layer}/config.toml:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
1554
- )
1555
-
1556
- const existing = loadSettings(file)
1557
- applyPlan(file, { ...existing, hooks: { ...existing.hooks, ...ours() } })
1558
-
1559
- const after = parseToml(readFileSync(file, 'utf8')) as {
1560
- hooks: { state: Record<string, { trusted_hash: string }> }
1561
- }
1562
- expect(after.hooks.state[`${layer}/config.toml:stop:0:0`]?.trusted_hash).toBe('sha256:abc')
1563
- })
1564
-
1565
- it('falls back to a whole-file write when the splice cannot be trusted', () => {
1566
- const layer = layerFor('notifai-toml-inline-')
1567
- const file = path.join(layer, 'config.toml')
1568
- // An inline top-level `hooks` key cannot be spliced around, only rewritten.
1569
- writeFileSync(file, '# this comment is lost, and that is the safe outcome\nhooks = {}\n')
1570
-
1571
- applyPlan(file, { hooks: ours() })
1572
-
1573
- const after = readFileSync(file, 'utf8')
1574
- expect(after).toContain('[[hooks.Stop]]')
1575
- expect(after).not.toContain('hooks = {}')
1576
- })
1577
-
1578
- it('is a no-op when the serialized document already matches the file', () => {
1579
- const layer = layerFor('notifai-toml-noop-')
1580
- const file = path.join(layer, 'config.toml')
1581
- writeFileSync(file, '# keep me\nmodel = "gpt-5.6"\n')
1582
- applyPlan(file, { ...loadSettings(file), hooks: ours() })
1583
- const once = readFileSync(file, 'utf8')
1584
- applyPlan(file, { ...loadSettings(file), hooks: ours() })
1585
- expect(readFileSync(file, 'utf8')).toBe(once)
1586
- })
1587
- })
1588
-
1589
- describe('emptied Codex representations', () => {
1590
- it('deletes an empty JSON hooks file instead of writing {}', () => {
1591
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-empty-json-'))
1592
- const file = path.join(repo, 'hooks.json')
1593
- applyPlan(file, { hooks: ours() })
1594
- expect(existsSync(file)).toBe(true)
1595
- const stripped = removeHooks(loadSettings(file), SCRIPT)
1596
- applyPlan(file, stripped.document)
1597
- expect(existsSync(file)).toBe(false)
1598
- })
1599
-
1600
- it('keeps a trust-only TOML file and drops empty event tables', () => {
1601
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-trust-only-'))
1602
- const file = path.join(repo, 'config.toml')
1603
- writeFileSync(
1604
- file,
1605
- `model = "gpt-5.6"\n\n[hooks.state."${file}:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
1606
- )
1607
- const stripped = removeHooks(loadSettings(file), SCRIPT)
1608
- applyPlan(file, stripped.document)
1609
- const after = readFileSync(file, 'utf8')
1610
- expect(after).toContain('model = "gpt-5.6"')
1611
- expect(after).toContain('trusted_hash')
1612
- expect(after).not.toContain('[[hooks.Stop]]')
1613
- })
1614
- })