@raidiant/notifai 7.0.2 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
@@ -1,4 +1,5 @@
1
1
  import {
2
+ existsSync,
2
3
  lstatSync,
3
4
  mkdirSync,
4
5
  mkdtempSync,
@@ -20,6 +21,7 @@ import {
20
21
  opencodePluginSource,
21
22
  opencodePluginTarget,
22
23
  } from './opencode-plugin.js'
24
+ import { SESSION_ACTIVATION_CONTEXT } from './session-activation.js'
23
25
  import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
24
26
  import {
25
27
  BLOCKING_STOP_TIMEOUT_SECONDS,
@@ -27,6 +29,7 @@ import {
27
29
  applyPlan,
28
30
  detectHarness,
29
31
  detectedHarnesses,
32
+ buildCursorHookConfig,
30
33
  buildHookConfig,
31
34
  codexLayerDir,
32
35
  codexProjectRoot,
@@ -85,6 +88,33 @@ describe('choice labels', () => {
85
88
  })
86
89
 
87
90
  describe('hook config', () => {
91
+ it('activates Notifai at the session lifecycle seam for every native hook adapter', () => {
92
+ const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
93
+ const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
94
+ const cursor = buildCursorHookConfig({ adapterPath: ADAPTER, harness: 'cursor' })
95
+
96
+ expect(claude['SessionStart']?.[0]?.hooks[0]).toMatchObject({
97
+ command: hookCommand(ADAPTER, 'session-start', 'claude-code'),
98
+ })
99
+ expect(codex['SessionStart']?.[0]?.hooks[0]).toMatchObject({
100
+ command: hookCommand(ADAPTER, 'session-start', 'codex'),
101
+ })
102
+ expect(cursor['sessionStart']?.[0]).toMatchObject({
103
+ command: hookCommand(ADAPTER, 'session-start', 'cursor'),
104
+ })
105
+ expect(claude['SubagentStart']?.[0]?.hooks[0]).toMatchObject({
106
+ command: hookCommand(ADAPTER, 'subagent-start', 'claude-code'),
107
+ })
108
+ expect(codex['SubagentStart']?.[0]?.hooks[0]).toMatchObject({
109
+ command: hookCommand(ADAPTER, 'subagent-start', 'codex'),
110
+ })
111
+ expect(cursor['subagentStart']).toBeUndefined()
112
+ expect(cursor['stop']?.[0]).toMatchObject({
113
+ command: hookCommand(ADAPTER, 'activation-stop', 'cursor'),
114
+ loop_limit: 1,
115
+ })
116
+ })
117
+
88
118
  it('invokes one stable adapter so a sparse hook PATH still works', () => {
89
119
  expect(hookCommand(ADAPTER, 'stop')).toBe(`'${ADAPTER}' hook stop --owner notifai`)
90
120
  })
@@ -101,7 +131,7 @@ describe('hook config', () => {
101
131
  const config = buildHookConfig({ adapterPath: ADAPTER })
102
132
  expect(config['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
103
133
  expect(config['Stop']?.[0]?.hooks[0]?.async).toBeUndefined()
104
- // Claude Code caps UserPromptSubmit at 30s; Codex gives SessionEnd 1-3s.
134
+ // Claude Code defaults UserPromptSubmit to 30s; Codex gives SessionEnd 1-3s.
105
135
  expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
106
136
  expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
107
137
  })
@@ -174,7 +204,7 @@ describe('hook config', () => {
174
204
  SCRIPT,
175
205
  )
176
206
 
177
- for (const event of ['UserPromptSubmit', 'Stop', 'SessionEnd']) {
207
+ for (const event of ['SessionStart', 'SubagentStart', 'UserPromptSubmit', 'Stop', 'SessionEnd']) {
178
208
  const groups = migrated.document.hooks?.[event] ?? []
179
209
  expect(groups.flatMap((group) => group.hooks)).toHaveLength(1)
180
210
  }
@@ -182,7 +212,13 @@ describe('hook config', () => {
182
212
  timeout: 3600,
183
213
  async: true,
184
214
  })
185
- expect(migrated.replaced.sort()).toEqual(['SessionEnd', 'Stop', 'UserPromptSubmit'])
215
+ expect(migrated.replaced.sort()).toEqual([
216
+ 'SessionEnd',
217
+ 'SessionStart',
218
+ 'Stop',
219
+ 'SubagentStart',
220
+ 'UserPromptSubmit',
221
+ ])
186
222
  })
187
223
  })
188
224
 
@@ -272,13 +308,7 @@ describe('settings locations', () => {
272
308
  expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
273
309
  })
274
310
 
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', () => {
311
+ it('joins the only populated hook representation without special-casing its owner', () => {
282
312
  const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
283
313
  const layer = path.join(repo, '.codex')
284
314
  mkdirSync(layer, { recursive: true })
@@ -292,6 +322,20 @@ describe('settings locations', () => {
292
322
  },
293
323
  })
294
324
 
325
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'hooks.json'))
326
+ })
327
+
328
+ it('keeps inline hooks when config.toml is the only populated representation', () => {
329
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-toml-'))
330
+ const layer = path.join(repo, '.codex')
331
+ mkdirSync(layer, { recursive: true })
332
+ writeFileSync(
333
+ path.join(layer, 'config.toml'),
334
+ ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
335
+ '\n',
336
+ ),
337
+ )
338
+
295
339
  expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'config.toml'))
296
340
  })
297
341
 
@@ -640,7 +684,9 @@ describe('finding what is installed', () => {
640
684
  expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
641
685
  expect(problems[0]).toContain(path.join(layer, 'config.toml'))
642
686
  expect(problems[0]).toMatch(/Stop/)
643
- expect(problems[0]).toMatch(/notifies twice per turn for UserPromptSubmit, Stop, SessionEnd/)
687
+ expect(problems[0]).toMatch(
688
+ /notifies twice per turn for SessionStart, SubagentStart, UserPromptSubmit, Stop, SessionEnd/,
689
+ )
644
690
  expect(problems[0]).toMatch(/notifai hooks uninstall --harness codex/)
645
691
  expect(problems[0]).not.toMatch(/--global/)
646
692
  })
@@ -692,7 +738,9 @@ describe('finding what is installed', () => {
692
738
  expect(claude).toBeDefined()
693
739
  expect(claude?.handlers.map((h) => h.event).sort()).toEqual([
694
740
  'SessionEnd',
741
+ 'SessionStart',
695
742
  'Stop',
743
+ 'SubagentStart',
696
744
  'UserPromptSubmit',
697
745
  ])
698
746
  expect(claude?.handlers.every((h) => h.groupIndex === 0 && h.handlerIndex === 0)).toBe(true)
@@ -725,17 +773,23 @@ describe('finding what is installed', () => {
725
773
  )
726
774
 
727
775
  const stale = codexTrustProblems(installations, env)
728
- expect(stale).toHaveLength(3)
776
+ expect(stale).toHaveLength(5)
729
777
  expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
730
778
 
731
779
  writeFileSync(
732
780
  trustFile,
733
781
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
734
782
  )
735
- expect(codexTrustProblems(installations, env)).toEqual([
736
- expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
737
- expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
738
- ])
783
+ const untrusted = codexTrustProblems(installations, env)
784
+ expect(untrusted).toHaveLength(4)
785
+ expect(untrusted).toEqual(
786
+ expect.arrayContaining([
787
+ expect.stringMatching(/SessionStart.*not trusted.*\/hooks/i),
788
+ expect.stringMatching(/SubagentStart.*not trusted.*\/hooks/i),
789
+ expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
790
+ expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
791
+ ]),
792
+ )
739
793
 
740
794
  writeFileSync(
741
795
  trustFile,
@@ -828,6 +882,34 @@ describe('the OpenCode adapter', () => {
828
882
  expect(source).not.toContain('"permission.ask"')
829
883
  })
830
884
 
885
+ it('adds session activation through OpenCode system context without setup or network', async () => {
886
+ expect(source).toContain('"experimental.chat.system.transform"')
887
+ const generated = (await import(
888
+ `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
889
+ )) as {
890
+ NotifAIPlugin(input: { directory: string; client: object }): Promise<{
891
+ 'experimental.chat.system.transform'?: (
892
+ input: object,
893
+ output: { system: string[] },
894
+ ) => Promise<void>
895
+ }>
896
+ }
897
+ const plugin = await generated.NotifAIPlugin({ directory: '/repo', client: {} })
898
+ const output = { system: ['existing context'] }
899
+
900
+ await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_1' }, output)
901
+
902
+ expect(output.system).toEqual([`existing context\n\n${SESSION_ACTIVATION_CONTEXT}`])
903
+
904
+ const internal = { system: ['internal agent-generation context'] }
905
+ await plugin['experimental.chat.system.transform']?.({}, internal)
906
+ expect(internal.system).toEqual(['internal agent-generation context'])
907
+
908
+ const empty = { system: [] as string[] }
909
+ await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_2' }, empty)
910
+ expect(empty.system).toEqual([SESSION_ACTIVATION_CONTEXT])
911
+ })
912
+
831
913
  it('publishes the session pointer on a user message without injecting content', () => {
832
914
  // The CLI has never emitted `hookSpecificOutput.additionalContext`, so the
833
915
  // injection this used to assert could not fire. The handler earns its place
@@ -1262,4 +1344,41 @@ describe('writing config.toml around what is already there', () => {
1262
1344
  expect(after).toContain('[[hooks.Stop]]')
1263
1345
  expect(after).not.toContain('hooks = {}')
1264
1346
  })
1347
+
1348
+ it('is a no-op when the serialized document already matches the file', () => {
1349
+ const layer = layerFor('notifai-toml-noop-')
1350
+ const file = path.join(layer, 'config.toml')
1351
+ writeFileSync(file, '# keep me\nmodel = "gpt-5.6"\n')
1352
+ applyPlan(file, { ...loadSettings(file), hooks: ours() })
1353
+ const once = readFileSync(file, 'utf8')
1354
+ applyPlan(file, { ...loadSettings(file), hooks: ours() })
1355
+ expect(readFileSync(file, 'utf8')).toBe(once)
1356
+ })
1357
+ })
1358
+
1359
+ describe('emptied Codex representations', () => {
1360
+ it('deletes an empty JSON hooks file instead of writing {}', () => {
1361
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-empty-json-'))
1362
+ const file = path.join(repo, 'hooks.json')
1363
+ applyPlan(file, { hooks: ours() })
1364
+ expect(existsSync(file)).toBe(true)
1365
+ const stripped = removeHooks(loadSettings(file), SCRIPT)
1366
+ applyPlan(file, stripped.document)
1367
+ expect(existsSync(file)).toBe(false)
1368
+ })
1369
+
1370
+ it('keeps a trust-only TOML file and drops empty event tables', () => {
1371
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-trust-only-'))
1372
+ const file = path.join(repo, 'config.toml')
1373
+ writeFileSync(
1374
+ file,
1375
+ `model = "gpt-5.6"\n\n[hooks.state."${file}:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
1376
+ )
1377
+ const stripped = removeHooks(loadSettings(file), SCRIPT)
1378
+ applyPlan(file, stripped.document)
1379
+ const after = readFileSync(file, 'utf8')
1380
+ expect(after).toContain('model = "gpt-5.6"')
1381
+ expect(after).toContain('trusted_hash')
1382
+ expect(after).not.toContain('[[hooks.Stop]]')
1383
+ })
1265
1384
  })
@@ -2,7 +2,10 @@ import {
2
2
  existsSync,
3
3
  lstatSync,
4
4
  readFileSync,
5
+ readdirSync,
5
6
  realpathSync,
7
+ rmdirSync,
8
+ rmSync,
6
9
  statSync,
7
10
  } from 'node:fs'
8
11
  import { createHash } from 'node:crypto'
@@ -43,8 +46,7 @@ const OPENCODE_EVENTS = [
43
46
  * `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
44
47
  * We default a hook-empty layer to inline config and preserve whichever single
45
48
  * 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.
49
+ * never both.
48
50
  *
49
51
  * Cursor's native format is flat and lower-camel-cased, while OpenCode's
50
52
  * extension point is a JavaScript plugin module. Each therefore has a bounded
@@ -251,14 +253,37 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
251
253
  const { adapterPath } = options
252
254
  const commandOptions = commandOptionsFrom(options)
253
255
  return {
256
+ SessionStart: [
257
+ {
258
+ hooks: [
259
+ {
260
+ type: 'command',
261
+ command: hookCommand(adapterPath, 'session-start', options.harness, commandOptions),
262
+ // Activation is local context only: no config, credentials, or network.
263
+ timeout: 5,
264
+ },
265
+ ],
266
+ },
267
+ ],
268
+ SubagentStart: [
269
+ {
270
+ hooks: [
271
+ {
272
+ type: 'command',
273
+ command: hookCommand(adapterPath, 'subagent-start', options.harness, commandOptions),
274
+ timeout: 5,
275
+ },
276
+ ],
277
+ },
278
+ ],
254
279
  UserPromptSubmit: [
255
280
  {
256
281
  hooks: [
257
282
  {
258
283
  type: 'command',
259
284
  command: hookCommand(adapterPath, 'user-prompt-submit', options.harness, commandOptions),
260
- // Claude Code caps UserPromptSubmit at 30s; stay well inside it so
261
- // a slow network can never delay the user's own prompt.
285
+ // Claude Code defaults UserPromptSubmit to 30s. Choose a shorter
286
+ // explicit budget so a slow network cannot delay the user's prompt.
262
287
  timeout: 15,
263
288
  },
264
289
  ],
@@ -299,13 +324,30 @@ interface CursorSettingsDocument {
299
324
  export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
300
325
  const commandOptions = commandOptionsFrom(options)
301
326
  return {
327
+ sessionStart: [
328
+ {
329
+ command: hookCommand(options.adapterPath, 'session-start', 'cursor', commandOptions),
330
+ // Activation only emits local context and must not depend on setup.
331
+ timeout: 5,
332
+ },
333
+ ],
302
334
  beforeSubmitPrompt: [
303
335
  {
304
- command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor', commandOptions),
336
+ command: hookCommand(
337
+ options.adapterPath,
338
+ 'user-prompt-submit',
339
+ 'cursor',
340
+ commandOptions,
341
+ ),
305
342
  timeout: 15,
306
343
  },
307
344
  ],
308
345
  stop: [
346
+ {
347
+ command: hookCommand(options.adapterPath, 'activation-stop', 'cursor', commandOptions),
348
+ timeout: 5,
349
+ loop_limit: 1,
350
+ },
309
351
  {
310
352
  command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
311
353
  timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
@@ -527,24 +569,15 @@ export interface CodexLayerInspection {
527
569
  }
528
570
 
529
571
  /**
530
- * Where Notifai writes in this Codex layer: wherever Notifai already is, and
531
- * inline `config.toml` otherwise.
572
+ * Where Notifai writes in this Codex layer: wherever Notifai already is, then
573
+ * whichever single hook representation the layer already uses, and inline
574
+ * `config.toml` for a hook-empty or already-mixed layer.
532
575
  *
533
576
  * 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.
543
- *
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.
577
+ * and runs every matching handler. Reusing the one populated representation is
578
+ * the generic layer contract: it avoids creating Codex's dual-representation
579
+ * warning and, for a managed layer, writes through the same authority that
580
+ * persists the layer rather than a sibling file it may regenerate away.
548
581
  *
549
582
  * Staying put where Notifai already is also keeps reinstalls idempotent and
550
583
  * preserves the `[hooks.state]` trust hash, which Codex keys by file path and
@@ -559,7 +592,13 @@ export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection
559
592
  const ourJsonEvents = ourHandlerEvents(jsonDocument)
560
593
  const ourTomlEvents = ourHandlerEvents(tomlDocument)
561
594
  const writeTarget =
562
- ourJsonEvents.length > 0 && ourTomlEvents.length === 0 ? paths.hooksJson : paths.configToml
595
+ ourJsonEvents.length > 0 && ourTomlEvents.length === 0
596
+ ? paths.hooksJson
597
+ : ourTomlEvents.length > 0
598
+ ? paths.configToml
599
+ : jsonEvents.length > 0 && tomlEvents.length === 0
600
+ ? paths.hooksJson
601
+ : paths.configToml
563
602
  return {
564
603
  paths,
565
604
  jsonEvents,
@@ -812,7 +851,7 @@ function isOurCommand(command: string, scriptPath: string): boolean {
812
851
 
813
852
  /** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
814
853
  function isLegacyNotifaiCommand(command: string): boolean {
815
- const hook = `['"]?\\s+hook (?:user-prompt-submit|stop|session-end)\\b`
854
+ const hook = `['"]?\\s+hook (?:session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\\b`
816
855
  return (
817
856
  new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
818
857
  new RegExp(
@@ -963,7 +1002,11 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
963
1002
  if (removed) replaced.push(event)
964
1003
  if (foreign.length > 0) hooks[event] = foreign
965
1004
  }
966
- return { document: { ...existing, hooks }, added: [], replaced, removed: [] }
1005
+ const remaining = Object.keys(hooks).length > 0 ? hooks : undefined
1006
+ const document = { ...existing }
1007
+ if (remaining === undefined) delete document.hooks
1008
+ else document.hooks = remaining
1009
+ return { document, added: [], replaced, removed: [] }
967
1010
  }
968
1011
 
969
1012
  /**
@@ -980,11 +1023,56 @@ export function applyPlan(file: string, document: SettingsDocument | CursorSetti
980
1023
  const body = isTomlSettingsPath(file)
981
1024
  ? tomlBody(file, document as SettingsDocument)
982
1025
  : `${JSON.stringify(document, null, 2)}\n`
1026
+ if (
1027
+ (path.basename(file) === 'hooks.json' && isEmptyJsonDocument(body)) ||
1028
+ (isTomlSettingsPath(file) && body.trim() === '')
1029
+ ) {
1030
+ if (existsSync(file)) rmSync(file, { force: true })
1031
+ return
1032
+ }
1033
+ if (existsSync(file)) {
1034
+ try {
1035
+ if (readOwnedRegularFile(file) === body) return
1036
+ } catch {
1037
+ // A file we cannot re-read still needs the replacement below.
1038
+ }
1039
+ }
983
1040
  atomicWriteFileSync(file, body, {
984
1041
  requireCurrentUserOwner: true,
985
1042
  })
986
1043
  }
987
1044
 
1045
+ function isEmptyJsonDocument(body: string): boolean {
1046
+ try {
1047
+ const parsed: unknown = JSON.parse(body)
1048
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
1049
+ } catch {
1050
+ return false
1051
+ }
1052
+ }
1053
+
1054
+ /** Delete an emptied Codex hooks.json and a now-empty layer directory. */
1055
+ export function cleanupEmptiedCodexLayer(paths: CodexLayerPaths): void {
1056
+ if (existsSync(paths.hooksJson)) {
1057
+ try {
1058
+ const document = loadSettings(paths.hooksJson)
1059
+ const keys = Object.keys(document)
1060
+ const hooksEmpty = document.hooks === undefined || Object.keys(document.hooks).length === 0
1061
+ if (keys.every((key) => key === 'hooks') && hooksEmpty) {
1062
+ rmSync(paths.hooksJson, { force: true })
1063
+ }
1064
+ } catch {
1065
+ // Leave a file uninstall could not parse; the caller already reported it.
1066
+ }
1067
+ }
1068
+ if (!existsSync(paths.dir)) return
1069
+ try {
1070
+ if (readdirSync(paths.dir).length === 0) rmdirSync(paths.dir)
1071
+ } catch {
1072
+ // A concurrently replaced directory is not ours to fight.
1073
+ }
1074
+ }
1075
+
988
1076
  /**
989
1077
  * A TOML file rewritten around its `[hooks]` tables, not over them.
990
1078
  *
@@ -1243,7 +1331,7 @@ export function codexTrustProblems(
1243
1331
  * evidence that it has (and is itself worth reporting).
1244
1332
  */
1245
1333
  function isNotifaiCommand(command: string): boolean {
1246
- return / hook (user-prompt-submit|stop|session-end)\b/.test(command)
1334
+ return / hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(command)
1247
1335
  }
1248
1336
 
1249
1337
  /** Every place either harness would read a Notifai handler from. */
@@ -18,7 +18,7 @@
18
18
  * The menu leads with whatever is actually in the way. Someone who is not
19
19
  * signed in is offered sign-in first; someone with no device that can receive
20
20
  * is offered a device; everyone else is offered the test notification, because
21
- * seeing the thing arrive on your phone is what makes the product real.
21
+ * seeing the thing arrive on your device is what makes the product real.
22
22
  *
23
23
  * Detail is earned, not given. A settings group shows fourteen one-line
24
24
  * summaries, and the paragraph explaining a key appears once the reader has
@@ -365,7 +365,11 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
365
365
  if (!signedIn) {
366
366
  options.push({ value: 'account', label: 'Sign in', hint: 'approve this machine in your browser' })
367
367
  } else if (!hasDevice) {
368
- options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your iPhone' })
368
+ options.push({
369
+ value: 'devices',
370
+ label: 'Add a device',
371
+ hint: 'install a supported Companion App',
372
+ })
369
373
  } else {
370
374
  options.push({
371
375
  value: 'test',
@@ -414,8 +418,8 @@ async function testNotificationScreen(deps: CommandDeps): Promise<void> {
414
418
 
415
419
  const body = await clack.text({
416
420
  message: 'Body',
417
- placeholder: 'If you can read this on your phone, it works.',
418
- defaultValue: 'If you can read this on your phone, it works.',
421
+ placeholder: 'If you can read this on your device, it works.',
422
+ defaultValue: 'If you can read this on your device, it works.',
419
423
  })
420
424
  if (cancelled(body)) return
421
425
 
@@ -457,8 +461,8 @@ async function devicesScreen(deps: CommandDeps): Promise<void> {
457
461
  if (devices.length === 0) {
458
462
  clack.note(
459
463
  'No devices are registered yet.\n\n' +
460
- 'Install the Notifai Companion App on your iPhone, sign in with the\n' +
461
- 'same Account, and allow notifications when it asks.',
464
+ 'Install a supported Notifai Companion App, sign in with the same\n' +
465
+ 'Account, and allow notifications when it asks.',
462
466
  'No devices',
463
467
  )
464
468
  return
@@ -697,7 +701,7 @@ async function chooseLayer(): Promise<'global' | 'project' | 'local' | null> {
697
701
  options: [
698
702
  { value: 'global', label: 'This machine', hint: 'every project you work on here' },
699
703
  { value: 'project', label: 'This project, shared', hint: '.notifai/config.toml — commit it' },
700
- { value: 'local', label: 'This project, just me', hint: 'config.local.toml — keep it gitignored' },
704
+ { value: 'local', label: 'This project, just me', hint: 'stored on this machine, not in the repo' },
701
705
  ],
702
706
  initialValue: 'global',
703
707
  })
@@ -733,8 +737,9 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
733
737
  const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
734
738
  clack.note(
735
739
  'When an agent registers a question, it appears in your terminal first.\n' +
736
- 'After the grace below, it goes to your iPhone as a notification\n' +
737
- 'with real answer buttons, and your answer comes back to the agent —\n' +
740
+ 'After the grace below, it goes to your Companion devices as a\n' +
741
+ 'notification with a supported native or in-app answer surface, and\n' +
742
+ 'your answer comes back to the agent —\n' +
738
743
  'whether or not you are still at this machine.\n\n' +
739
744
  [
740
745
  row('Wired to', wiringSummary(hooks)),
@@ -14,6 +14,7 @@ import {
14
14
  LOG_SCHEMA_VERSION,
15
15
  activeLogPath,
16
16
  archiveLogPaths,
17
+ argvFlagNames,
17
18
  createLogger,
18
19
  logsDiskUsage,
19
20
  nullLogger,
@@ -182,9 +183,71 @@ describe('redaction', () => {
182
183
  })
183
184
 
184
185
  it('truncates a long string instead of carrying it whole', () => {
185
- const shaped = shape({ body: 'x'.repeat(1000) }) as { body: string }
186
- expect(shaped.body.length).toBeLessThan(500)
187
- expect(shaped.body).toContain('+600 chars')
186
+ const shaped = shape({ detail: 'x'.repeat(1000) }) as { detail: string }
187
+ expect(shaped.detail.length).toBeLessThan(500)
188
+ expect(shaped.detail).toContain('+600 chars')
189
+ })
190
+
191
+ it('never persists notification content, answers, or raw argv at debug', () => {
192
+ const env = sandbox()
193
+ const logger = createLogger({ env, settings: { level: 'debug' } })
194
+ logger.debug('cli.start', {
195
+ argv: ['send', '--title', 'SECRET_TITLE', '--body', 'SECRET_BODY'],
196
+ title: 'SECRET_TITLE',
197
+ body: 'SECRET_BODY',
198
+ question: 'SECRET_QUESTION',
199
+ text: 'SECRET_REPLY',
200
+ answers: ['SECRET_ANSWER'],
201
+ choices: ['SECRET_CHOICE'],
202
+ })
203
+
204
+ const raw = readFileSync(activeLogPath(env), 'utf8')
205
+ expect(raw).not.toContain('SECRET_TITLE')
206
+ expect(raw).not.toContain('SECRET_BODY')
207
+ expect(raw).not.toContain('SECRET_QUESTION')
208
+ expect(raw).not.toContain('SECRET_REPLY')
209
+ expect(raw).not.toContain('SECRET_ANSWER')
210
+ expect(raw).not.toContain('SECRET_CHOICE')
211
+ const record = JSON.parse(raw) as LogRecord
212
+ expect(record.data).toMatchObject({
213
+ title_chars: 'SECRET_TITLE'.length,
214
+ body_chars: 'SECRET_BODY'.length,
215
+ question_chars: 'SECRET_QUESTION'.length,
216
+ text_chars: 'SECRET_REPLY'.length,
217
+ answers_count: 1,
218
+ choices_count: 1,
219
+ argv_count: 5,
220
+ flags: ['--title', '--body'],
221
+ })
222
+ expect(record.data).not.toHaveProperty('argv')
223
+ expect(record.data).not.toHaveProperty('title')
224
+ })
225
+
226
+ it('strips inline --name=value assignments so notification content is never logged', () => {
227
+ const inlineTitle = 'private title words'
228
+ const inlineBody = 'private body words'
229
+ expect(
230
+ argvFlagNames(['send', `--title=${inlineTitle}`, `--body=${inlineBody}`, '--reply', '--']),
231
+ ).toEqual(['--title', '--body', '--reply'])
232
+
233
+ const env = sandbox()
234
+ const logger = createLogger({ env, settings: { level: 'debug' } })
235
+ logger.debug('cli.start', {
236
+ argv: ['send', `--title=${inlineTitle}`, `--body=${inlineBody}`, 'plain'],
237
+ })
238
+
239
+ const raw = readFileSync(activeLogPath(env), 'utf8')
240
+ expect(raw).not.toContain(inlineTitle)
241
+ expect(raw).not.toContain(inlineBody)
242
+ expect(shape({ argv: ['send', `--title=${inlineTitle}`, '--choice=yes'] })).toEqual({
243
+ flags: ['--title', '--choice'],
244
+ argv_count: 3,
245
+ })
246
+ const record = JSON.parse(raw) as LogRecord
247
+ expect(record.data).toMatchObject({
248
+ flags: ['--title', '--body'],
249
+ argv_count: 4,
250
+ })
188
251
  })
189
252
 
190
253
  it('refuses to recurse for ever into a cyclic object', () => {