@raidiant/notifai 0.2.1 → 0.4.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 (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
@@ -27,6 +27,9 @@ import {
27
27
  codexLayerDir,
28
28
  codexProjectRoot,
29
29
  findInstallations,
30
+ codexHookIdentityHash,
31
+ codexTrustKey,
32
+ codexTrustProblems,
30
33
  handlerEvent,
31
34
  hookCommand,
32
35
  mergeHooks,
@@ -38,9 +41,10 @@ import {
38
41
 
39
42
  const SCRIPT = '/opt/notifai/dist/main.js'
40
43
  const EXEC = '/usr/local/bin/node'
44
+ const ADAPTER = '/Users/test/.notifai/bin/hook-adapter'
41
45
 
42
46
  function ours(): HookConfig {
43
- return buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 240, graceSeconds: 0 })
47
+ return buildHookConfig({ adapterPath: ADAPTER })
44
48
  }
45
49
 
46
50
  describe('choice labels', () => {
@@ -71,21 +75,21 @@ describe('choice labels', () => {
71
75
  })
72
76
 
73
77
  describe('hook config', () => {
74
- it('invokes the CLI by absolute path so a hook shell without our PATH still works', () => {
75
- expect(hookCommand(EXEC, SCRIPT, 'stop')).toBe(`'${EXEC}' '${SCRIPT}' hook stop --owner notifai`)
78
+ it('invokes one stable adapter so a sparse hook PATH still works', () => {
79
+ expect(hookCommand(ADAPTER, 'stop')).toBe(`'${ADAPTER}' hook stop --owner notifai`)
76
80
  })
77
81
 
78
82
  it('single-quotes so a path cannot become a command', () => {
79
83
  // Double quotes still expand $() and backticks, so a checkout path with
80
84
  // shell syntax was execution on every hook event.
81
- const cmd = hookCommand('/usr/bin/node', '/tmp/$(touch /tmp/pwned)/main.js', 'stop')
82
- expect(cmd).toBe("'/usr/bin/node' '/tmp/$(touch /tmp/pwned)/main.js' hook stop --owner notifai")
83
- expect(hookCommand('/usr/bin/node', "/it's/here.js", 'stop')).toContain(`'/it'\\''s/here.js'`)
85
+ const cmd = hookCommand('/tmp/$(touch /tmp/pwned)/hook-adapter', 'stop')
86
+ expect(cmd).toBe("'/tmp/$(touch /tmp/pwned)/hook-adapter' hook stop --owner notifai")
87
+ expect(hookCommand("/it's/here", 'stop')).toContain(`'/it'\\''s/here'`)
84
88
  })
85
89
 
86
- it('gives blocking hooks headroom over our own wait, and SessionEnd almost none', () => {
87
- const config = buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 240, graceSeconds: 0 })
88
- expect(config['Stop']?.[0]?.hooks[0]?.timeout).toBe(300)
90
+ it('uses one fixed blocking process budget rather than accepting mutable timing inputs', () => {
91
+ const config = buildHookConfig({ adapterPath: ADAPTER })
92
+ expect(config['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
89
93
  // Claude Code caps UserPromptSubmit at 30s; Codex gives SessionEnd 1-3s.
90
94
  expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
91
95
  expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
@@ -93,10 +97,7 @@ describe('hook config', () => {
93
97
 
94
98
  it('stamps the exact harness on every generated command', () => {
95
99
  const config = buildHookConfig({
96
- execPath: EXEC,
97
- scriptPath: SCRIPT,
98
- replyTimeoutSeconds: 240,
99
- graceSeconds: 0,
100
+ adapterPath: ADAPTER,
100
101
  harness: 'claude-code',
101
102
  })
102
103
  expect(
@@ -106,6 +107,16 @@ describe('hook config', () => {
106
107
  .every((handler) => handler.command.includes('--harness claude-code')),
107
108
  ).toBe(true)
108
109
  })
110
+
111
+ it('lets Codex own Stop timeout while keeping short fixed lifecycle budgets', () => {
112
+ const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
113
+ const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
114
+
115
+ expect(codex['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
116
+ expect(codex['Stop']?.[0]?.hooks[0]?.timeout).toBeUndefined()
117
+ expect(codex['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
118
+ expect(claude['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
119
+ })
109
120
  })
110
121
 
111
122
  describe('merging into existing settings', () => {
@@ -130,11 +141,11 @@ describe('merging into existing settings', () => {
130
141
  const once = mergeHooks({}, ours(), SCRIPT)
131
142
  const twice = mergeHooks(
132
143
  once.document,
133
- buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 60, graceSeconds: 0 }),
144
+ buildHookConfig({ adapterPath: ADAPTER }),
134
145
  SCRIPT,
135
146
  )
136
147
  expect(twice.document.hooks?.['Stop']).toHaveLength(1)
137
- expect(twice.document.hooks?.['Stop']?.[0]?.hooks[0]?.timeout).toBe(120)
148
+ expect(twice.document.hooks?.['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
138
149
  expect(twice.replaced).toContain('Stop')
139
150
  })
140
151
 
@@ -292,9 +303,9 @@ describe('upgrading past a dropped event', () => {
292
303
  const stale = {
293
304
  hooks: {
294
305
  PermissionRequest: [
295
- { hooks: [{ type: 'command' as const, command: hookCommand(EXEC, SCRIPT, 'permission-request') }] },
306
+ { hooks: [{ type: 'command' as const, command: hookCommand(ADAPTER, 'permission-request') }] },
296
307
  ],
297
- Stop: [{ hooks: [{ type: 'command' as const, command: hookCommand(EXEC, SCRIPT, 'stop') }] }],
308
+ Stop: [{ hooks: [{ type: 'command' as const, command: hookCommand(ADAPTER, 'stop') }] }],
298
309
  },
299
310
  }
300
311
 
@@ -311,7 +322,7 @@ describe('upgrading past a dropped event', () => {
311
322
  PermissionRequest: [
312
323
  {
313
324
  hooks: [
314
- { type: 'command' as const, command: hookCommand(EXEC, SCRIPT, 'permission-request') },
325
+ { type: 'command' as const, command: hookCommand(ADAPTER, 'permission-request') },
315
326
  { type: 'command' as const, command: 'their-own-tool --check' },
316
327
  ],
317
328
  },
@@ -393,10 +404,49 @@ describe('finding what is installed', () => {
393
404
  it('reads the event a handler actually invokes, not the key it sits under', () => {
394
405
  // These diverge exactly when an upgrade drops an event, which is the case
395
406
  // worth detecting.
396
- expect(handlerEvent(hookCommand(EXEC, SCRIPT, 'permission-request'))).toBe('permission-request')
407
+ expect(handlerEvent(hookCommand(ADAPTER, 'permission-request'))).toBe('permission-request')
397
408
  expect(handlerEvent('unrelated --tool')).toBeNull()
398
409
  })
399
410
 
411
+ it('detects when Codex still trusts an older definition of an installed hook', () => {
412
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-'))
413
+ const codexHome = path.join(cwd, 'codex-home')
414
+ const hookFile = path.join(cwd, '.codex', 'hooks.json')
415
+ mkdirSync(path.dirname(hookFile), { recursive: true })
416
+ applyPlan(hookFile, { hooks: ours() })
417
+ const installations = findInstallations(cwd, { CODEX_HOME: codexHome })
418
+ const codex = installations.find((installation) => installation.harness === 'codex')
419
+ const stop = codex?.handlers.find((handler) => handler.event === 'Stop')
420
+ expect(stop).toBeDefined()
421
+
422
+ mkdirSync(codexHome, { recursive: true })
423
+ writeFileSync(
424
+ path.join(codexHome, 'config.toml'),
425
+ `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "sha256:obsolete"\n`,
426
+ )
427
+
428
+ const stale = codexTrustProblems(installations, { CODEX_HOME: codexHome })
429
+ expect(stale).toHaveLength(3)
430
+ expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
431
+
432
+ writeFileSync(
433
+ path.join(codexHome, 'config.toml'),
434
+ `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
435
+ )
436
+ expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual([
437
+ expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
438
+ expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
439
+ ])
440
+
441
+ writeFileSync(
442
+ path.join(codexHome, 'config.toml'),
443
+ `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\nenabled = false\n`,
444
+ )
445
+ expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual(
446
+ expect.arrayContaining([expect.stringMatching(/Stop.*disabled.*\/hooks/i)]),
447
+ )
448
+ })
449
+
400
450
  })
401
451
 
402
452
  /**
@@ -405,7 +455,7 @@ describe('finding what is installed', () => {
405
455
  * the harness ran both, and one question produced two notifications.
406
456
  */
407
457
  describe('two checkouts', () => {
408
- const OTHER = '/Users/rafael/other-checkout/apps/cli/dist/main.js'
458
+ const OTHER = '/Users/rafael/notifai-public/apps/cli/dist/main.js'
409
459
 
410
460
  function documentWith(command: string): SettingsDocument {
411
461
  return { hooks: { Stop: [{ matcher: '*', hooks: [{ type: 'command', command, timeout: 300 }] }] } }
@@ -416,26 +466,26 @@ describe('two checkouts', () => {
416
466
  }
417
467
 
418
468
  it('replaces a handler another checkout installed instead of running beside it', () => {
419
- const existing = documentWith(hookCommand(EXEC, OTHER, 'stop'))
469
+ const existing = documentWith(hookCommand('/other/notifai/hook-adapter', 'stop'))
420
470
  const merged = mergeHooks(
421
471
  existing,
422
- buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 240, graceSeconds: 0 }),
472
+ buildHookConfig({ adapterPath: ADAPTER }),
423
473
  SCRIPT,
424
474
  )
425
475
  const commands = stopCommands(merged.document)
426
476
  expect(commands.filter((c) => c.includes('hook stop'))).toHaveLength(1)
427
- expect(commands[0]).toContain(SCRIPT)
428
- expect(commands.join(' ')).not.toContain(OTHER)
477
+ expect(commands[0]).toContain(ADAPTER)
478
+ expect(commands.join(' ')).not.toContain('/other/notifai/hook-adapter')
429
479
  })
430
480
 
431
481
  it('uninstalls a handler another checkout installed', () => {
432
- const removed = removeHooks(documentWith(hookCommand(EXEC, OTHER, 'stop')), SCRIPT)
482
+ const removed = removeHooks(documentWith(hookCommand('/other/notifai/hook-adapter', 'stop')), SCRIPT)
433
483
  expect(stopCommands(removed.document)).toHaveLength(0)
434
484
  })
435
485
 
436
486
  it('still recognises an install that predates the marker', () => {
437
- // Same path, no marker: the form written by an older build.
438
- const legacy = documentWith(`'${EXEC}' '${SCRIPT}' hook stop`)
487
+ // Different checkout, no marker: the form written by an older build.
488
+ const legacy = documentWith(`'${EXEC}' '${OTHER}' hook stop`)
439
489
  expect(stopCommands(removeHooks(legacy, SCRIPT).document)).toHaveLength(0)
440
490
  })
441
491
 
@@ -443,6 +493,11 @@ describe('two checkouts', () => {
443
493
  const foreign = documentWith('/usr/local/bin/my-own-thing --stop')
444
494
  expect(stopCommands(removeHooks(foreign, SCRIPT).document)).toHaveLength(1)
445
495
  })
496
+
497
+ it("preserves a foreign project's generic dist/main.js hook", () => {
498
+ const foreign = documentWith("'/usr/local/bin/node' '/opt/foreign/dist/main.js' hook stop")
499
+ expect(stopCommands(removeHooks(foreign, SCRIPT).document)).toHaveLength(1)
500
+ })
446
501
  })
447
502
 
448
503
  /**
@@ -451,8 +506,7 @@ describe('two checkouts', () => {
451
506
  */
452
507
  describe('the OpenCode adapter', () => {
453
508
  const source = opencodePluginSource({
454
- execPath: EXEC,
455
- scriptPath: SCRIPT,
509
+ adapterPath: ADAPTER,
456
510
  timeoutSeconds: 240,
457
511
  })
458
512
 
@@ -460,8 +514,9 @@ describe('the OpenCode adapter', () => {
460
514
  // The whole point: presence, escalation and retirement stay in the CLI.
461
515
  expect(source).toContain('"hook", event')
462
516
  expect(source).toContain('"--harness", "opencode"')
463
- expect(source).toContain(JSON.stringify(SCRIPT))
464
- expect(source).toContain(JSON.stringify(EXEC))
517
+ expect(source).toContain(JSON.stringify(ADAPTER))
518
+ expect(source).not.toContain(JSON.stringify(SCRIPT))
519
+ expect(source).not.toContain(JSON.stringify(EXEC))
465
520
  })
466
521
 
467
522
  it('wires the three question lifecycle joints and leaves permissions alone', () => {
@@ -504,27 +559,21 @@ describe('the OpenCode adapter', () => {
504
559
  expect(settingsFile('opencode', false, '/repo', {})).toContain('notifai.js')
505
560
  })
506
561
 
507
- it('reports the build it will run, not its own path', () => {
508
- // `doctor` asks every handler which build it invokes. A plugin is a module
509
- // rather than a command line, so answering with the plugin's own path made
510
- // an ordinary OpenCode install look like a second checkout and turned the
511
- // duplicate check red on a healthy machine.
562
+ it('reports the same stable adapter identity as command-hook harnesses', () => {
512
563
  expect(opencodePluginTarget(source)).toEqual({
513
- exec: EXEC,
514
- script: SCRIPT,
564
+ adapter: ADAPTER,
515
565
  current: true,
516
566
  timeoutSeconds: 240,
517
567
  })
518
568
  expect(opencodePluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
519
- exec: EXEC,
520
- script: SCRIPT,
569
+ adapter: ADAPTER,
521
570
  current: false,
522
571
  timeoutSeconds: 240,
523
572
  })
524
573
  expect(opencodePluginTarget('export const SomeoneElse = () => ({})')).toBeNull()
525
574
  // Ours, but with the constants edited beyond recognition: no answer beats
526
575
  // a wrong one, since the caller compares these for equality.
527
- expect(opencodePluginTarget(`${OPENCODE_PLUGIN_MARKER}\nconst EXEC = whatever\n`)).toBeNull()
576
+ expect(opencodePluginTarget(`${OPENCODE_PLUGIN_MARKER}\nconst ADAPTER = whatever\n`)).toBeNull()
528
577
  })
529
578
  })
530
579