@raidiant/notifai 0.3.0 → 0.5.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 (87) hide show
  1. package/README.md +19 -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 +56 -7
  9. package/dist/commands.js +980 -318
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +1 -1
  12. package/dist/config-schema.js +46 -8
  13. package/dist/config-schema.js.map +1 -1
  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 +1 -1
  36. package/dist/interactive.js +3 -3
  37. package/dist/interactive.js.map +1 -1
  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 +97 -36
  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/config-view.js +3 -0
  47. package/dist/ui/config-view.js.map +1 -1
  48. package/dist/ui/theme.d.ts +1 -1
  49. package/package.json +3 -2
  50. package/src/atomic-file.ts +86 -7
  51. package/src/claude-wake.test.ts +320 -0
  52. package/src/claude-wake.ts +489 -0
  53. package/src/client.test.ts +12 -0
  54. package/src/client.ts +72 -4
  55. package/src/codex-wake.test.ts +253 -0
  56. package/src/codex-wake.ts +288 -0
  57. package/src/commands.test.ts +1261 -77
  58. package/src/commands.ts +1482 -375
  59. package/src/config-schema.test.ts +3 -3
  60. package/src/config-schema.ts +57 -44
  61. package/src/config.test.ts +22 -16
  62. package/src/config.ts +81 -66
  63. package/src/file-lock-process-worker.test.ts +64 -0
  64. package/src/file-lock.test.ts +279 -0
  65. package/src/file-lock.ts +318 -0
  66. package/src/harnesses.test.ts +34 -0
  67. package/src/harnesses.ts +57 -0
  68. package/src/hook-adapter.test.ts +187 -0
  69. package/src/hook-adapter.ts +212 -0
  70. package/src/hook-input.test.ts +39 -0
  71. package/src/hook-input.ts +72 -0
  72. package/src/hooks.test.ts +1985 -417
  73. package/src/hooks.ts +1338 -461
  74. package/src/install-hooks.test.ts +131 -41
  75. package/src/install-hooks.ts +284 -84
  76. package/src/interactive.ts +3 -8
  77. package/src/logging-process-worker.test.ts +39 -0
  78. package/src/logging.test.ts +453 -0
  79. package/src/logging.ts +639 -0
  80. package/src/main.ts +113 -32
  81. package/src/opencode-plugin.ts +15 -16
  82. package/src/session-end-log-lock-worker.test.ts +35 -0
  83. package/src/skill.test.ts +34 -0
  84. package/src/ui/config-view.test.ts +9 -7
  85. package/src/ui/config-view.ts +3 -0
  86. package/src/idle.test.ts +0 -32
  87. package/src/idle.ts +0 -41
@@ -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,22 @@ 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)
93
+ expect(config['Stop']?.[0]?.hooks[0]?.async).toBeUndefined()
89
94
  // Claude Code caps UserPromptSubmit at 30s; Codex gives SessionEnd 1-3s.
90
95
  expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
91
96
  expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
@@ -93,10 +98,7 @@ describe('hook config', () => {
93
98
 
94
99
  it('stamps the exact harness on every generated command', () => {
95
100
  const config = buildHookConfig({
96
- execPath: EXEC,
97
- scriptPath: SCRIPT,
98
- replyTimeoutSeconds: 240,
99
- graceSeconds: 0,
101
+ adapterPath: ADAPTER,
100
102
  harness: 'claude-code',
101
103
  })
102
104
  expect(
@@ -106,6 +108,56 @@ describe('hook config', () => {
106
108
  .every((handler) => handler.command.includes('--harness claude-code')),
107
109
  ).toBe(true)
108
110
  })
111
+
112
+ it('lets Codex own Stop timeout while keeping short fixed lifecycle budgets', () => {
113
+ const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
114
+
115
+ expect(codex['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
116
+ expect(codex['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
117
+ // Codex hashes the exact serialized definition into `trusted_hash`, so its
118
+ // Stop handler stays a bare blocking command: declaring a timeout would
119
+ // invalidate the user's approval and the handler would silently stop.
120
+ expect(codex['Stop']?.[0]?.hooks[0]).toEqual({
121
+ type: 'command',
122
+ command: hookCommand(ADAPTER, 'stop', 'codex'),
123
+ })
124
+ })
125
+
126
+ it('gives Claude Code an asynchronous Stop with an explicit waiter budget', () => {
127
+ const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
128
+
129
+ // `async: true` is what frees the turn: the handler returns at once and the
130
+ // same process lives on as the waiter. The explicit timeout is what keeps
131
+ // that process alive — Claude kills a background hook at its own 600s
132
+ // default and reports nothing, so a wait near that boundary loses answers.
133
+ expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
134
+ type: 'command',
135
+ command: hookCommand(ADAPTER, 'stop', 'claude-code'),
136
+ timeout: 3600,
137
+ async: true,
138
+ })
139
+ expect(claude['UserPromptSubmit']?.[0]?.hooks[0]?.async).toBeUndefined()
140
+ expect(claude['SessionEnd']?.[0]?.hooks[0]?.async).toBeUndefined()
141
+ })
142
+
143
+ it('leaves exactly one handler per event when an old blocking shape is reinstalled over', () => {
144
+ const old = mergeHooks({}, buildHookConfig({ adapterPath: ADAPTER }), SCRIPT)
145
+ const migrated = mergeHooks(
146
+ old.document,
147
+ buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' }),
148
+ SCRIPT,
149
+ )
150
+
151
+ for (const event of ['UserPromptSubmit', 'Stop', 'SessionEnd']) {
152
+ const groups = migrated.document.hooks?.[event] ?? []
153
+ expect(groups.flatMap((group) => group.hooks)).toHaveLength(1)
154
+ }
155
+ expect(migrated.document.hooks?.['Stop']?.[0]?.hooks[0]).toMatchObject({
156
+ timeout: 3600,
157
+ async: true,
158
+ })
159
+ expect(migrated.replaced.sort()).toEqual(['SessionEnd', 'Stop', 'UserPromptSubmit'])
160
+ })
109
161
  })
110
162
 
111
163
  describe('merging into existing settings', () => {
@@ -130,11 +182,11 @@ describe('merging into existing settings', () => {
130
182
  const once = mergeHooks({}, ours(), SCRIPT)
131
183
  const twice = mergeHooks(
132
184
  once.document,
133
- buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 60, graceSeconds: 0 }),
185
+ buildHookConfig({ adapterPath: ADAPTER }),
134
186
  SCRIPT,
135
187
  )
136
188
  expect(twice.document.hooks?.['Stop']).toHaveLength(1)
137
- expect(twice.document.hooks?.['Stop']?.[0]?.hooks[0]?.timeout).toBe(120)
189
+ expect(twice.document.hooks?.['Stop']?.[0]?.hooks[0]?.timeout).toBe(540)
138
190
  expect(twice.replaced).toContain('Stop')
139
191
  })
140
192
 
@@ -292,9 +344,9 @@ describe('upgrading past a dropped event', () => {
292
344
  const stale = {
293
345
  hooks: {
294
346
  PermissionRequest: [
295
- { hooks: [{ type: 'command' as const, command: hookCommand(EXEC, SCRIPT, 'permission-request') }] },
347
+ { hooks: [{ type: 'command' as const, command: hookCommand(ADAPTER, 'permission-request') }] },
296
348
  ],
297
- Stop: [{ hooks: [{ type: 'command' as const, command: hookCommand(EXEC, SCRIPT, 'stop') }] }],
349
+ Stop: [{ hooks: [{ type: 'command' as const, command: hookCommand(ADAPTER, 'stop') }] }],
298
350
  },
299
351
  }
300
352
 
@@ -311,7 +363,7 @@ describe('upgrading past a dropped event', () => {
311
363
  PermissionRequest: [
312
364
  {
313
365
  hooks: [
314
- { type: 'command' as const, command: hookCommand(EXEC, SCRIPT, 'permission-request') },
366
+ { type: 'command' as const, command: hookCommand(ADAPTER, 'permission-request') },
315
367
  { type: 'command' as const, command: 'their-own-tool --check' },
316
368
  ],
317
369
  },
@@ -393,10 +445,49 @@ describe('finding what is installed', () => {
393
445
  it('reads the event a handler actually invokes, not the key it sits under', () => {
394
446
  // These diverge exactly when an upgrade drops an event, which is the case
395
447
  // worth detecting.
396
- expect(handlerEvent(hookCommand(EXEC, SCRIPT, 'permission-request'))).toBe('permission-request')
448
+ expect(handlerEvent(hookCommand(ADAPTER, 'permission-request'))).toBe('permission-request')
397
449
  expect(handlerEvent('unrelated --tool')).toBeNull()
398
450
  })
399
451
 
452
+ it('detects when Codex still trusts an older definition of an installed hook', () => {
453
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-'))
454
+ const codexHome = path.join(cwd, 'codex-home')
455
+ const hookFile = path.join(cwd, '.codex', 'hooks.json')
456
+ mkdirSync(path.dirname(hookFile), { recursive: true })
457
+ applyPlan(hookFile, { hooks: ours() })
458
+ const installations = findInstallations(cwd, { CODEX_HOME: codexHome })
459
+ const codex = installations.find((installation) => installation.harness === 'codex')
460
+ const stop = codex?.handlers.find((handler) => handler.event === 'Stop')
461
+ expect(stop).toBeDefined()
462
+
463
+ mkdirSync(codexHome, { recursive: true })
464
+ writeFileSync(
465
+ path.join(codexHome, 'config.toml'),
466
+ `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "sha256:obsolete"\n`,
467
+ )
468
+
469
+ const stale = codexTrustProblems(installations, { CODEX_HOME: codexHome })
470
+ expect(stale).toHaveLength(3)
471
+ expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
472
+
473
+ writeFileSync(
474
+ path.join(codexHome, 'config.toml'),
475
+ `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
476
+ )
477
+ expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual([
478
+ expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
479
+ expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
480
+ ])
481
+
482
+ writeFileSync(
483
+ path.join(codexHome, 'config.toml'),
484
+ `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\nenabled = false\n`,
485
+ )
486
+ expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual(
487
+ expect.arrayContaining([expect.stringMatching(/Stop.*disabled.*\/hooks/i)]),
488
+ )
489
+ })
490
+
400
491
  })
401
492
 
402
493
  /**
@@ -405,7 +496,7 @@ describe('finding what is installed', () => {
405
496
  * the harness ran both, and one question produced two notifications.
406
497
  */
407
498
  describe('two checkouts', () => {
408
- const OTHER = '/Users/rafael/other-checkout/apps/cli/dist/main.js'
499
+ const OTHER = '/Users/rafael/notifai-public/apps/cli/dist/main.js'
409
500
 
410
501
  function documentWith(command: string): SettingsDocument {
411
502
  return { hooks: { Stop: [{ matcher: '*', hooks: [{ type: 'command', command, timeout: 300 }] }] } }
@@ -416,26 +507,26 @@ describe('two checkouts', () => {
416
507
  }
417
508
 
418
509
  it('replaces a handler another checkout installed instead of running beside it', () => {
419
- const existing = documentWith(hookCommand(EXEC, OTHER, 'stop'))
510
+ const existing = documentWith(hookCommand('/other/notifai/hook-adapter', 'stop'))
420
511
  const merged = mergeHooks(
421
512
  existing,
422
- buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 240, graceSeconds: 0 }),
513
+ buildHookConfig({ adapterPath: ADAPTER }),
423
514
  SCRIPT,
424
515
  )
425
516
  const commands = stopCommands(merged.document)
426
517
  expect(commands.filter((c) => c.includes('hook stop'))).toHaveLength(1)
427
- expect(commands[0]).toContain(SCRIPT)
428
- expect(commands.join(' ')).not.toContain(OTHER)
518
+ expect(commands[0]).toContain(ADAPTER)
519
+ expect(commands.join(' ')).not.toContain('/other/notifai/hook-adapter')
429
520
  })
430
521
 
431
522
  it('uninstalls a handler another checkout installed', () => {
432
- const removed = removeHooks(documentWith(hookCommand(EXEC, OTHER, 'stop')), SCRIPT)
523
+ const removed = removeHooks(documentWith(hookCommand('/other/notifai/hook-adapter', 'stop')), SCRIPT)
433
524
  expect(stopCommands(removed.document)).toHaveLength(0)
434
525
  })
435
526
 
436
527
  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`)
528
+ // Different checkout, no marker: the form written by an older build.
529
+ const legacy = documentWith(`'${EXEC}' '${OTHER}' hook stop`)
439
530
  expect(stopCommands(removeHooks(legacy, SCRIPT).document)).toHaveLength(0)
440
531
  })
441
532
 
@@ -443,6 +534,11 @@ describe('two checkouts', () => {
443
534
  const foreign = documentWith('/usr/local/bin/my-own-thing --stop')
444
535
  expect(stopCommands(removeHooks(foreign, SCRIPT).document)).toHaveLength(1)
445
536
  })
537
+
538
+ it("preserves a foreign project's generic dist/main.js hook", () => {
539
+ const foreign = documentWith("'/usr/local/bin/node' '/opt/foreign/dist/main.js' hook stop")
540
+ expect(stopCommands(removeHooks(foreign, SCRIPT).document)).toHaveLength(1)
541
+ })
446
542
  })
447
543
 
448
544
  /**
@@ -451,8 +547,7 @@ describe('two checkouts', () => {
451
547
  */
452
548
  describe('the OpenCode adapter', () => {
453
549
  const source = opencodePluginSource({
454
- execPath: EXEC,
455
- scriptPath: SCRIPT,
550
+ adapterPath: ADAPTER,
456
551
  timeoutSeconds: 240,
457
552
  })
458
553
 
@@ -460,8 +555,9 @@ describe('the OpenCode adapter', () => {
460
555
  // The whole point: presence, escalation and retirement stay in the CLI.
461
556
  expect(source).toContain('"hook", event')
462
557
  expect(source).toContain('"--harness", "opencode"')
463
- expect(source).toContain(JSON.stringify(SCRIPT))
464
- expect(source).toContain(JSON.stringify(EXEC))
558
+ expect(source).toContain(JSON.stringify(ADAPTER))
559
+ expect(source).not.toContain(JSON.stringify(SCRIPT))
560
+ expect(source).not.toContain(JSON.stringify(EXEC))
465
561
  })
466
562
 
467
563
  it('wires the three question lifecycle joints and leaves permissions alone', () => {
@@ -504,27 +600,21 @@ describe('the OpenCode adapter', () => {
504
600
  expect(settingsFile('opencode', false, '/repo', {})).toContain('notifai.js')
505
601
  })
506
602
 
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.
603
+ it('reports the same stable adapter identity as command-hook harnesses', () => {
512
604
  expect(opencodePluginTarget(source)).toEqual({
513
- exec: EXEC,
514
- script: SCRIPT,
605
+ adapter: ADAPTER,
515
606
  current: true,
516
607
  timeoutSeconds: 240,
517
608
  })
518
609
  expect(opencodePluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
519
- exec: EXEC,
520
- script: SCRIPT,
610
+ adapter: ADAPTER,
521
611
  current: false,
522
612
  timeoutSeconds: 240,
523
613
  })
524
614
  expect(opencodePluginTarget('export const SomeoneElse = () => ({})')).toBeNull()
525
615
  // Ours, but with the constants edited beyond recognition: no answer beats
526
616
  // a wrong one, since the caller compares these for equality.
527
- expect(opencodePluginTarget(`${OPENCODE_PLUGIN_MARKER}\nconst EXEC = whatever\n`)).toBeNull()
617
+ expect(opencodePluginTarget(`${OPENCODE_PLUGIN_MARKER}\nconst ADAPTER = whatever\n`)).toBeNull()
528
618
  })
529
619
  })
530
620