@raidiant/notifai 0.5.0 → 1.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 (74) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +11 -4
  3. package/dist/claude-wake.d.ts +101 -0
  4. package/dist/claude-wake.js +397 -0
  5. package/dist/claude-wake.js.map +1 -0
  6. package/dist/codex-wake.d.ts +84 -0
  7. package/dist/codex-wake.js +238 -0
  8. package/dist/codex-wake.js.map +1 -0
  9. package/dist/commands.d.ts +31 -11
  10. package/dist/commands.js +679 -232
  11. package/dist/commands.js.map +1 -1
  12. package/dist/config-schema.d.ts +3 -3
  13. package/dist/config-schema.js +11 -49
  14. package/dist/config-schema.js.map +1 -1
  15. package/dist/config.d.ts +23 -45
  16. package/dist/config.js +61 -39
  17. package/dist/config.js.map +1 -1
  18. package/dist/harnesses.d.ts +4 -13
  19. package/dist/harnesses.js +12 -17
  20. package/dist/harnesses.js.map +1 -1
  21. package/dist/hook-adapter.d.ts +10 -1
  22. package/dist/hook-adapter.js +79 -10
  23. package/dist/hook-adapter.js.map +1 -1
  24. package/dist/hooks.d.ts +119 -65
  25. package/dist/hooks.js +330 -285
  26. package/dist/hooks.js.map +1 -1
  27. package/dist/install-hooks.d.ts +76 -24
  28. package/dist/install-hooks.js +301 -125
  29. package/dist/install-hooks.js.map +1 -1
  30. package/dist/interactive.d.ts +32 -1
  31. package/dist/interactive.js +165 -51
  32. package/dist/interactive.js.map +1 -1
  33. package/dist/main.js +14 -21
  34. package/dist/main.js.map +1 -1
  35. package/dist/native-skills.d.ts +1 -1
  36. package/dist/native-skills.js +23 -58
  37. package/dist/native-skills.js.map +1 -1
  38. package/dist/pack-feedback-slice.d.ts +35 -0
  39. package/dist/pack-feedback-slice.js +126 -0
  40. package/dist/pack-feedback-slice.js.map +1 -0
  41. package/dist/readiness.d.ts +20 -0
  42. package/dist/readiness.js +24 -0
  43. package/dist/readiness.js.map +1 -1
  44. package/dist/release.d.ts +31 -0
  45. package/dist/release.js +52 -0
  46. package/dist/release.js.map +1 -0
  47. package/dist/ui/theme.d.ts +1 -1
  48. package/package.json +11 -10
  49. package/src/commands.test.ts +386 -36
  50. package/src/commands.ts +473 -182
  51. package/src/config-schema.ts +2 -17
  52. package/src/config.test.ts +29 -4
  53. package/src/config.ts +67 -11
  54. package/src/harnesses.ts +7 -0
  55. package/src/hook-adapter.test.ts +43 -1
  56. package/src/hook-adapter.ts +83 -11
  57. package/src/hooks.test.ts +4 -5
  58. package/src/install-hooks.test.ts +154 -2
  59. package/src/install-hooks.ts +231 -44
  60. package/src/interactive.test.ts +338 -0
  61. package/src/interactive.ts +221 -49
  62. package/src/main.ts +14 -20
  63. package/src/native-skills.test.ts +75 -0
  64. package/src/native-skills.ts +31 -75
  65. package/src/pack-feedback-slice.test.ts +212 -0
  66. package/src/pack-feedback-slice.ts +187 -0
  67. package/src/readiness.test.ts +25 -0
  68. package/src/readiness.ts +38 -0
  69. package/src/release.ts +51 -0
  70. package/src/skill.test.ts +43 -0
  71. package/src/ui/config-view.test.ts +5 -4
  72. package/dist/idle.d.ts +0 -16
  73. package/dist/idle.js +0 -44
  74. package/dist/idle.js.map +0 -1
@@ -1,12 +1,12 @@
1
- import { existsSync, lstatSync, readFileSync, realpathSync, statSync, } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { parse as parseToml } from 'smol-toml';
5
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
6
  import { atomicWriteFileSync } from './atomic-file.js';
7
7
  import { hookAdapterPath } from './hook-adapter.js';
8
8
  import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js';
9
- import { HARNESSES, HOOK_TIMING } from './harnesses.js';
9
+ import { HARNESSES } from './harnesses.js';
10
10
  export { HARNESSES } from './harnesses.js';
11
11
  /**
12
12
  * The three joints the OpenCode plugin hooks into, as (harness event, the
@@ -51,26 +51,52 @@ export const OWNER_MARKER = '--owner notifai';
51
51
  function quote(value) {
52
52
  return `'${value.replaceAll("'", `'\\''`)}'`;
53
53
  }
54
- /** Generated blocking adapters grant the owner budget one fixed minute of host headroom. */
55
- const HOOK_CEILING_SECONDS = HOOK_TIMING.totalSeconds + HOOK_TIMING.hostHeadroomSeconds;
56
54
  /**
57
- * Stable process budget for a blocking turn-end adapter.
55
+ * Claude Code's Stop handler returns instantly and the same process lives on
56
+ * out of band as the escalation waiter, so this `timeout` bounds a background
57
+ * process rather than the user's turn.
58
58
  *
59
- * Codex trusts the exact serialized hook definition. Deriving this timeout
60
- * from mutable question preferences meant changing `ask_grace_seconds`
61
- * silently invalidated Stop trust while the other two handlers kept running.
62
- * The runtime already fits grace and reply waiting inside the harness ceiling;
63
- * the installed identity therefore stays at that ceiling across preferences.
59
+ * It is always declared, and declared far above the longest wait a waiter can
60
+ * take. Claude's default is 600 s and the kill is **silent** — the backgrounded
61
+ * process simply vanishes, no error is reported anywhere, and the answer the
62
+ * user already gave is never delivered. Sitting anywhere near that boundary
63
+ * turns a slow reply into a lost one, so this does not: an explicit value
64
+ * raises the ceiling (verified against a 660 s wait under an hour-long
65
+ * declaration), and an hour is several times the waiter's own ceiling.
64
66
  */
65
- export function blockingHookTimeoutSeconds() {
66
- return HOOK_CEILING_SECONDS;
67
+ export const CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS = 3600;
68
+ /**
69
+ * The declared process budget for a host that holds its turn while the waiter
70
+ * runs: the waiter's own ceiling plus a minute of host headroom, as one plain
71
+ * number rather than a budget carved into reserves.
72
+ *
73
+ * Codex is deliberately absent from this. It owns its Stop timeout today, and
74
+ * declaring one would change the serialized definition Codex hashes into
75
+ * `trusted_hash` — which silently stops every Notifai handler from running
76
+ * until the user re-approves them in `/hooks`.
77
+ */
78
+ export const BLOCKING_STOP_TIMEOUT_SECONDS = 540;
79
+ /**
80
+ * The turn-end handler, whose shape is the whole per-harness difference.
81
+ *
82
+ * Claude Code takes the answer over its own inbox socket, so its Stop hook is
83
+ * `async: true`: it returns immediately, the terminal is never held, and the
84
+ * waiter finishes out of band. Every other host still delivers by printing a
85
+ * continuation to this process's stdout, so its Stop hook blocks and needs a
86
+ * declared ceiling above the wait — except Codex, which owns that number
87
+ * itself and whose definition must stay byte-stable for its trust store.
88
+ */
89
+ function stopHandler(adapterPath, harness) {
90
+ const command = hookCommand(adapterPath, 'stop', harness);
91
+ if (harness === 'claude-code') {
92
+ return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true };
93
+ }
94
+ if (harness === 'codex')
95
+ return { type: 'command', command };
96
+ return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS };
67
97
  }
68
98
  export function buildHookConfig(options) {
69
99
  const { adapterPath } = options;
70
- const hostOwnsStopTimeout = options.harness === 'codex';
71
- // Runtime preferences fit inside this fixed process budget without becoming
72
- // part of the serialized definition Codex asks the user to trust.
73
- const blockingTimeout = blockingHookTimeoutSeconds();
74
100
  return {
75
101
  UserPromptSubmit: [
76
102
  {
@@ -85,17 +111,7 @@ export function buildHookConfig(options) {
85
111
  ],
86
112
  },
87
113
  ],
88
- Stop: [
89
- {
90
- hooks: [
91
- {
92
- type: 'command',
93
- command: hookCommand(adapterPath, 'stop', options.harness),
94
- ...(hostOwnsStopTimeout ? {} : { timeout: blockingTimeout }),
95
- },
96
- ],
97
- },
98
- ],
114
+ Stop: [{ hooks: [stopHandler(adapterPath, options.harness)] }],
99
115
  SessionEnd: [
100
116
  {
101
117
  hooks: [
@@ -113,7 +129,6 @@ export function buildHookConfig(options) {
113
129
  }
114
130
  /** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
115
131
  export function buildCursorHookConfig(options) {
116
- const blockingTimeout = blockingHookTimeoutSeconds();
117
132
  return {
118
133
  beforeSubmitPrompt: [
119
134
  {
@@ -124,7 +139,7 @@ export function buildCursorHookConfig(options) {
124
139
  stop: [
125
140
  {
126
141
  command: hookCommand(options.adapterPath, 'stop', 'cursor'),
127
- timeout: blockingTimeout,
142
+ timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
128
143
  // A continuation may register a real follow-up question. Match the
129
144
  // session-state cap so those chains are useful but never unbounded.
130
145
  loop_limit: 3,
@@ -160,9 +175,7 @@ export function settingsFile(harness, global, cwd, env = process.env) {
160
175
  ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
161
176
  : path.join(cwd, '.claude', 'settings.local.json');
162
177
  case 'codex':
163
- return global
164
- ? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
165
- : path.join(codexProjectRoot(cwd), '.codex', 'hooks.json');
178
+ return inspectCodexLayer(codexLayerPaths(global, cwd, env)).writeTarget;
166
179
  default:
167
180
  return assertNeverHarness(harness);
168
181
  }
@@ -264,6 +277,133 @@ export function configHome(env, variable, fallback) {
264
277
  const override = env[variable];
265
278
  return override !== undefined && override !== '' ? override : path.join(os.homedir(), fallback);
266
279
  }
280
+ /** The two files Codex will look at for one config layer. */
281
+ export function codexLayerPaths(global, cwd, env = process.env) {
282
+ const dir = global
283
+ ? configHome(env, 'CODEX_HOME', '.codex')
284
+ : path.join(codexProjectRoot(cwd), '.codex');
285
+ return {
286
+ dir,
287
+ hooksJson: path.join(dir, 'hooks.json'),
288
+ configToml: path.join(dir, 'config.toml'),
289
+ };
290
+ }
291
+ /**
292
+ * Which representation this Codex layer already uses.
293
+ *
294
+ * Codex loads `hooks.json` and inline `[hooks]` side by side, runs every
295
+ * matching handler, and warns when both exist. `[hooks.state]` is the trust
296
+ * store, not a hook definition, so it does not count as a representation.
297
+ */
298
+ export function inspectCodexLayer(paths) {
299
+ const jsonDocument = tryLoadSettings(paths.hooksJson);
300
+ const tomlDocument = tryLoadSettings(paths.configToml);
301
+ const jsonEvents = hookEventNames(jsonDocument?.hooks);
302
+ const tomlEvents = hookEventNames(tomlDocument?.hooks);
303
+ const jsonForeign = documentHasForeignHandlers(jsonDocument);
304
+ const tomlForeign = documentHasForeignHandlers(tomlDocument);
305
+ const jsonOurs = documentHasOurHandlers(jsonDocument);
306
+ const tomlOurs = documentHasOurHandlers(tomlDocument);
307
+ const dual = jsonEvents.length > 0 && tomlEvents.length > 0;
308
+ let writeTarget = paths.hooksJson;
309
+ if (tomlEvents.length > 0 && jsonEvents.length === 0)
310
+ writeTarget = paths.configToml;
311
+ else if (jsonEvents.length > 0 && tomlEvents.length === 0)
312
+ writeTarget = paths.hooksJson;
313
+ else if (dual) {
314
+ if (tomlForeign && !jsonForeign)
315
+ writeTarget = paths.configToml;
316
+ else if (jsonForeign && !tomlForeign)
317
+ writeTarget = paths.hooksJson;
318
+ else if (!jsonForeign && !tomlForeign)
319
+ writeTarget = paths.hooksJson;
320
+ else if (tomlOurs && !jsonOurs)
321
+ writeTarget = paths.configToml;
322
+ else if (jsonOurs && !tomlOurs)
323
+ writeTarget = paths.hooksJson;
324
+ else
325
+ writeTarget = paths.hooksJson;
326
+ }
327
+ return {
328
+ paths,
329
+ jsonEvents,
330
+ tomlEvents,
331
+ writeTarget,
332
+ dual,
333
+ overlappingEvents: jsonEvents.filter((event) => tomlEvents.includes(event)),
334
+ canCollapse: dual && !(jsonForeign && tomlForeign),
335
+ };
336
+ }
337
+ /** Every file in this layer Codex might already be reading hooks from. */
338
+ export function hookDefinitionFiles(harness, global, cwd, env = process.env) {
339
+ if (harness !== 'codex')
340
+ return [settingsFile(harness, global, cwd, env)];
341
+ const paths = codexLayerPaths(global, cwd, env);
342
+ return [paths.hooksJson, paths.configToml];
343
+ }
344
+ /**
345
+ * Drop the unused representation after writing the one this layer already had.
346
+ *
347
+ * Only safe when the discarded file has no one else's handlers. `hooks.json`
348
+ * is deleted once it has no events left so Codex stops warning; `config.toml`
349
+ * is never deleted.
350
+ */
351
+ export function collapseUnusedCodexRepresentation(keepFile, scriptPath, paths) {
352
+ const other = keepFile === paths.hooksJson ? paths.configToml : paths.hooksJson;
353
+ if (!existsSync(other))
354
+ return null;
355
+ const document = tryLoadSettings(other);
356
+ if (document === null || documentHasForeignHandlers(document))
357
+ return null;
358
+ const stripped = removeHooks(document, scriptPath);
359
+ if (other === paths.hooksJson && hookEventNames(stripped.document.hooks).length === 0) {
360
+ rmSync(other);
361
+ return other;
362
+ }
363
+ applyPlan(other, stripped.document);
364
+ return other;
365
+ }
366
+ export function codexRepresentationProblems(cwd, env = process.env) {
367
+ return [false, true].flatMap((global) => {
368
+ const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env));
369
+ if (!layer.dual)
370
+ return [];
371
+ const overlap = layer.overlappingEvents.length > 0
372
+ ? ` Matching handlers from both files all run — this layer's ${layer.overlappingEvents.join(', ')} handlers will both fire.`
373
+ : ' Matching handlers from both files all run.';
374
+ const collapse = layer.canCollapse
375
+ ? ` Run \`notifai hooks install --harness codex${global ? ' --global' : ''}\` to collapse onto ${path.basename(layer.writeTarget)}.`
376
+ : ' Both files have other hooks, so Notifai cannot collapse them.';
377
+ return [
378
+ `loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap}${collapse}`,
379
+ ];
380
+ });
381
+ }
382
+ function tryLoadSettings(file) {
383
+ if (!existsSync(file))
384
+ return null;
385
+ try {
386
+ return loadSettings(file);
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ }
392
+ function hookEventNames(hooks) {
393
+ return Object.entries(hooks ?? {})
394
+ .filter(([, value]) => Array.isArray(value))
395
+ .map(([event]) => event);
396
+ }
397
+ function documentHasForeignHandlers(document) {
398
+ if (document === null)
399
+ return false;
400
+ return locateAllHandlers(document).some((handler) => !isNotifaiCommand(handler.command));
401
+ }
402
+ function documentHasOurHandlers(document) {
403
+ if (document === null)
404
+ return false;
405
+ return locateHandlers(document).length > 0;
406
+ }
267
407
  /** Best-effort detection so `hooks install` usually needs no flags. */
268
408
  /**
269
409
  * Harnesses this *project* shows evidence of, in the working directory only.
@@ -292,8 +432,8 @@ function localHarnessEvidence(cwd) {
292
432
  return found;
293
433
  }
294
434
  /** Harnesses installed anywhere on this machine — a much weaker signal. */
295
- function globalHarnessEvidence() {
296
- const home = os.homedir();
435
+ function globalHarnessEvidence(env = process.env) {
436
+ const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir();
297
437
  const found = [];
298
438
  if (existsSync(path.join(home, '.claude')))
299
439
  found.push('claude-code');
@@ -306,27 +446,30 @@ function globalHarnessEvidence() {
306
446
  return found;
307
447
  }
308
448
  /**
309
- * Which harness to wire in this directory, or null when it is genuinely
310
- * unclear.
449
+ * Every supported harness this project or machine shows evidence of, in
450
+ * declared order. Project markers come first, then machine installs that the
451
+ * project did not already name.
311
452
  *
312
- * Project evidence decides, and machine evidence is consulted only when the
313
- * project offers none. The two were previously OR-ed together per harness,
314
- * which meant a developer with several agent tools installed could never get
315
- * a detection at all: every `~/.<tool>` directory contributed a candidate, the
316
- * "exactly one" test never passed, and the answer was null in every repository
317
- * on the machine. Verified on a machine carrying all four — `notifai init
318
- * --hooks` failed in five separate projects, each of which plainly used Claude
319
- * Code.
453
+ * `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
454
+ */
455
+ export function detectedHarnesses(cwd, env = process.env) {
456
+ const seen = new Set([...localHarnessEvidence(cwd), ...globalHarnessEvidence(env)]);
457
+ return HARNESSES.filter((harness) => seen.has(harness));
458
+ }
459
+ /**
460
+ * Which *single* harness to wire when a caller still needs exactly one, or
461
+ * null when it is genuinely unclear.
320
462
  *
321
- * Having a tool installed says nothing about which one *this* project is
322
- * worked in. Ambiguity within the project is still ambiguity, and returns
323
- * null so the caller can ask rather than guess.
463
+ * Project evidence decides, and machine evidence is consulted only when the
464
+ * project offers none. Several markers are not a failure of detection — they
465
+ * are several harnesses. `detectedHarnesses` is the default for install;
466
+ * this helper stays for uninstall and any caller that cannot take a list.
324
467
  */
325
- export function detectHarness(cwd) {
468
+ export function detectHarness(cwd, env = process.env) {
326
469
  const local = localHarnessEvidence(cwd);
327
470
  if (local.length > 0)
328
471
  return local.length === 1 ? local[0] : null;
329
- const global = globalHarnessEvidence();
472
+ const global = globalHarnessEvidence(env);
330
473
  return global.length === 1 ? global[0] : null;
331
474
  }
332
475
  function readCursorSettings(file) {
@@ -446,6 +589,12 @@ export function mergeHooks(existing, incoming, scriptPath) {
446
589
  const replaced = [];
447
590
  const removed = [];
448
591
  for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
592
+ if (!Array.isArray(groups)) {
593
+ // Codex keeps `[hooks.state]` beside event tables. It is not a matcher list.
594
+ ;
595
+ hooks[event] = groups;
596
+ continue;
597
+ }
449
598
  const { groups: foreign, removed: hadOurs } = withoutOurs(groups, scriptPath);
450
599
  if (hadOurs) {
451
600
  if (event in incoming)
@@ -467,6 +616,11 @@ export function removeHooks(existing, scriptPath) {
467
616
  const hooks = {};
468
617
  const replaced = [];
469
618
  for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
619
+ if (!Array.isArray(groups)) {
620
+ ;
621
+ hooks[event] = groups;
622
+ continue;
623
+ }
470
624
  const { groups: foreign, removed } = withoutOurs(groups, scriptPath);
471
625
  if (removed)
472
626
  replaced.push(event);
@@ -486,12 +640,30 @@ export function removeHooks(existing, scriptPath) {
486
640
  * support.
487
641
  */
488
642
  export function applyPlan(file, document) {
489
- atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`, {
643
+ const body = isTomlSettingsPath(file)
644
+ ? `${stringifyToml(document)}\n`
645
+ : `${JSON.stringify(document, null, 2)}\n`;
646
+ atomicWriteFileSync(file, body, {
490
647
  requireCurrentUserOwner: true,
491
648
  });
492
649
  }
493
650
  export function loadSettings(file) {
494
- return readSettings(file);
651
+ return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file);
652
+ }
653
+ function isTomlSettingsPath(file) {
654
+ return file.endsWith('.toml');
655
+ }
656
+ function readTomlSettings(file) {
657
+ if (!existsSync(file))
658
+ return {};
659
+ const source = readOwnedRegularFile(file);
660
+ try {
661
+ const parsed = parseToml(source);
662
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
663
+ }
664
+ catch {
665
+ throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`);
666
+ }
495
667
  }
496
668
  export function loadCursorSettings(file) {
497
669
  return readCursorSettings(file);
@@ -600,85 +772,84 @@ export function codexTrustProblems(installations, env = process.env) {
600
772
  function isNotifaiCommand(command) {
601
773
  return / hook (user-prompt-submit|stop|session-end)\b/.test(command);
602
774
  }
603
- function isNotifaiHandler(handler) {
604
- return isNotifaiCommand(handler.command);
605
- }
606
775
  /** Every place either harness would read a Notifai handler from. */
607
776
  export function findInstallations(cwd, env = process.env, adapterHome) {
608
777
  const found = [];
609
778
  for (const harness of HARNESSES) {
610
779
  for (const global of [false, true]) {
611
- const file = settingsFile(harness, global, cwd, env);
612
- if (!existsSync(file))
613
- continue;
614
- // OpenCode's adapter is a plugin module, not a settings document, so it
615
- // is reported as one installation covering all three events rather than
616
- // parsed for handlers.
617
- if (harness === 'opencode') {
618
- let source;
619
- try {
620
- source = readOwnedRegularFile(file);
621
- }
622
- catch {
780
+ const files = hookDefinitionFiles(harness, global, cwd, env);
781
+ for (const file of files) {
782
+ if (!existsSync(file))
783
+ continue;
784
+ // OpenCode's adapter is a plugin module, not a settings document, so it
785
+ // is reported as one installation covering all three events rather than
786
+ // parsed for handlers.
787
+ if (harness === 'opencode') {
788
+ let source;
789
+ try {
790
+ source = readOwnedRegularFile(file);
791
+ }
792
+ catch {
793
+ continue;
794
+ }
795
+ const target = opencodePluginTarget(source);
796
+ if (target === null)
797
+ continue;
798
+ const problems = [
799
+ ...(!target.current
800
+ ? ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`']
801
+ : []),
802
+ ...(target.adapter !== hookAdapterPath(adapterHome)
803
+ ? [
804
+ 'OpenCode still names a mutable CLI or runtime path; rerun `notifai hooks install --harness opencode`',
805
+ ]
806
+ : []),
807
+ ];
808
+ found.push({
809
+ harness,
810
+ file,
811
+ global,
812
+ ...(problems.length > 0 ? { problems } : {}),
813
+ // Reported as the command line the plugin will actually run, not as
814
+ // the plugin's own path: every check downstream asks which build a
815
+ // handler invokes, and for a module that answer lives inside it.
816
+ handlers: OPENCODE_EVENTS.map(([event, hookEvent]) => ({
817
+ event,
818
+ groupIndex: 0,
819
+ handlerIndex: 0,
820
+ command: hookCommand(target.adapter, hookEvent, 'opencode'),
821
+ ...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
822
+ })),
823
+ });
623
824
  continue;
624
825
  }
625
- const target = opencodePluginTarget(source);
626
- if (target === null)
826
+ if (harness === 'cursor') {
827
+ let document;
828
+ try {
829
+ document = readCursorSettings(file);
830
+ }
831
+ catch {
832
+ continue;
833
+ }
834
+ const handlers = locateCursorHandlers(document);
835
+ if (handlers.length > 0) {
836
+ const problems = harnessMarkerProblems(harness, handlers, adapterHome);
837
+ found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
838
+ }
627
839
  continue;
628
- const problems = [
629
- ...(!target.current
630
- ? ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`']
631
- : []),
632
- ...(target.adapter !== hookAdapterPath(adapterHome)
633
- ? [
634
- 'OpenCode still names a mutable CLI or runtime path; rerun `notifai hooks install --harness opencode`',
635
- ]
636
- : []),
637
- ];
638
- found.push({
639
- harness,
640
- file,
641
- global,
642
- ...(problems.length > 0 ? { problems } : {}),
643
- // Reported as the command line the plugin will actually run, not as
644
- // the plugin's own path: every check downstream asks which build a
645
- // handler invokes, and for a module that answer lives inside it.
646
- handlers: OPENCODE_EVENTS.map(([event, hookEvent]) => ({
647
- event,
648
- groupIndex: 0,
649
- handlerIndex: 0,
650
- command: hookCommand(target.adapter, hookEvent, 'opencode'),
651
- ...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
652
- })),
653
- });
654
- continue;
655
- }
656
- if (harness === 'cursor') {
840
+ }
657
841
  let document;
658
842
  try {
659
- document = readCursorSettings(file);
843
+ document = loadSettings(file);
660
844
  }
661
845
  catch {
662
846
  continue;
663
847
  }
664
- const handlers = locateCursorHandlers(document);
848
+ const handlers = locateHandlers(document);
665
849
  if (handlers.length > 0) {
666
850
  const problems = harnessMarkerProblems(harness, handlers, adapterHome);
667
851
  found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
668
852
  }
669
- continue;
670
- }
671
- let document;
672
- try {
673
- document = readSettings(file);
674
- }
675
- catch {
676
- continue;
677
- }
678
- const handlers = locateHandlers(document);
679
- if (handlers.length > 0) {
680
- const problems = harnessMarkerProblems(harness, handlers, adapterHome);
681
- found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
682
853
  }
683
854
  }
684
855
  }
@@ -712,27 +883,32 @@ function locateCursorHandlers(document) {
712
883
  }
713
884
  return handlers;
714
885
  }
715
- function locateHandlers(document) {
886
+ function locateAllHandlers(document) {
716
887
  const handlers = [];
717
888
  for (const [event, groups] of Object.entries(document.hooks ?? {})) {
889
+ if (!Array.isArray(groups))
890
+ continue;
718
891
  groups.forEach((group, groupIndex) => {
719
892
  group.hooks?.forEach((handler, handlerIndex) => {
720
- if (isNotifaiHandler(handler)) {
721
- handlers.push({
722
- event,
723
- groupIndex,
724
- handlerIndex,
725
- command: handler.command,
726
- ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
727
- ...(handler.async === undefined ? {} : { async: handler.async }),
728
- ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
729
- });
730
- }
893
+ if (typeof handler?.command !== 'string')
894
+ return;
895
+ handlers.push({
896
+ event,
897
+ groupIndex,
898
+ handlerIndex,
899
+ command: handler.command,
900
+ ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
901
+ ...(handler.async === undefined ? {} : { async: handler.async }),
902
+ ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
903
+ });
731
904
  });
732
905
  });
733
906
  }
734
907
  return handlers;
735
908
  }
909
+ function locateHandlers(document) {
910
+ return locateAllHandlers(document).filter((handler) => isNotifaiCommand(handler.command));
911
+ }
736
912
  /** The hook event a handler's command actually invokes, e.g. `stop`. */
737
913
  export function handlerEvent(command) {
738
914
  return / hook ([a-z-]+)/.exec(command)?.[1] ?? null;