@raidiant/notifai 8.0.0 → 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 (145) hide show
  1. package/CHANGELOG.md +41 -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-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. 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. */
@@ -701,7 +701,7 @@ async function chooseLayer(): Promise<'global' | 'project' | 'local' | null> {
701
701
  options: [
702
702
  { value: 'global', label: 'This machine', hint: 'every project you work on here' },
703
703
  { value: 'project', label: 'This project, shared', hint: '.notifai/config.toml — commit it' },
704
- { 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' },
705
705
  ],
706
706
  initialValue: 'global',
707
707
  })
@@ -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', () => {
package/src/logging.ts CHANGED
@@ -80,7 +80,7 @@ const LEVEL_RANK: Record<LogLevel, number> = { off: 0, error: 1, info: 2, debug:
80
80
  * existing one, and keep each event answering exactly one question.
81
81
  */
82
82
  export const LOG_EVENTS = [
83
- /** A CLI invocation began: which command, which arguments, which version. */
83
+ /** A CLI invocation began: which command, which flag names, which version. */
84
84
  'cli.start',
85
85
  /** It ended: exit code and how long it took. */
86
86
  'cli.end',
@@ -90,6 +90,8 @@ export const LOG_EVENTS = [
90
90
  'config.resolved',
91
91
  /** One HTTP call to the service: method, path, status, duration. */
92
92
  'http.call',
93
+ /** A send crossed its ambiguous network boundary with this retry identity. */
94
+ 'send.attempt',
93
95
  /** A notification was accepted by the service. */
94
96
  'send.submitted',
95
97
  /** What became of it per device. */
@@ -108,6 +110,8 @@ export const LOG_EVENTS = [
108
110
  'hook.gate',
109
111
  /** A question reached the user's devices. */
110
112
  'hook.pushed',
113
+ /** A retirement close was attempted, and whether the server proved it. */
114
+ 'hook.retirement',
111
115
  /** The hook handed an answer back to the agent. */
112
116
  'hook.answer',
113
117
  /** The hook finished, with or without a decision. */
@@ -155,6 +159,10 @@ const MAX_DEPTH = 4
155
159
  */
156
160
  const SECRET_KEY_PATTERN = /secret|token|password|authorization|credential|verifier|api[_-]?key/i
157
161
  const MACHINE_TOKEN_PATTERN = /nfm_[A-Za-z0-9._-]{6,}/g
162
+ /** Notification and question content: persist length, never the words. */
163
+ const CONTENT_STRING_KEYS = new Set(['title', 'subtitle', 'body', 'text', 'question'])
164
+ /** Choice labels, reply answers, and raw argv: persist counts, never values. */
165
+ const CONTENT_LIST_KEYS = new Set(['answers', 'choices', 'argv'])
158
166
 
159
167
  export const REDACTED = '[redacted]'
160
168
 
@@ -207,6 +215,22 @@ function redactString(value: string): string {
207
215
  return `${scrubbed.slice(0, MAX_STRING_CHARS)}…(+${scrubbed.length - MAX_STRING_CHARS} chars)`
208
216
  }
209
217
 
218
+ /**
219
+ * Flag names only — argv values are notification content and user text.
220
+ *
221
+ * `--title=SECRET` must become `--title`. Filtering on a `--` prefix is not
222
+ * enough: inline assignment would otherwise persist the value.
223
+ */
224
+ export function argvFlagNames(argv: readonly string[]): string[] {
225
+ const names: string[] = []
226
+ for (const token of argv) {
227
+ if (!token.startsWith('--') || token === '--') continue
228
+ const eq = token.indexOf('=')
229
+ names.push(eq === -1 ? token : token.slice(0, eq))
230
+ }
231
+ return names
232
+ }
233
+
210
234
  /**
211
235
  * Shape a value for the record: drop what must never be written, truncate what
212
236
  * would flood a reader, and refuse to recurse for ever into a cyclic object.
@@ -226,7 +250,26 @@ export function shape(value: unknown, depth = 0): unknown {
226
250
  const out: Record<string, unknown> = {}
227
251
  for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
228
252
  if (nested === undefined) continue
229
- out[key] = SECRET_KEY_PATTERN.test(key) ? REDACTED : shape(nested, depth + 1)
253
+ if (SECRET_KEY_PATTERN.test(key)) {
254
+ out[key] = REDACTED
255
+ continue
256
+ }
257
+ if (CONTENT_STRING_KEYS.has(key) && typeof nested === 'string') {
258
+ out[`${key}_chars`] = nested.length
259
+ continue
260
+ }
261
+ if (key === 'argv' && Array.isArray(nested)) {
262
+ out.flags = argvFlagNames(
263
+ nested.filter((token): token is string => typeof token === 'string'),
264
+ )
265
+ out.argv_count = nested.length
266
+ continue
267
+ }
268
+ if (CONTENT_LIST_KEYS.has(key) && Array.isArray(nested)) {
269
+ out[`${key}_count`] = nested.length
270
+ continue
271
+ }
272
+ out[key] = shape(nested, depth + 1)
230
273
  }
231
274
  return out
232
275
  }