@raidiant/notifai 0.3.0 → 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 (80) 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 +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/client.test.ts +12 -0
  52. package/src/client.ts +72 -4
  53. package/src/commands.test.ts +891 -43
  54. package/src/commands.ts +1133 -332
  55. package/src/config-schema.ts +51 -9
  56. package/src/config.test.ts +6 -5
  57. package/src/config.ts +85 -16
  58. package/src/file-lock-process-worker.test.ts +64 -0
  59. package/src/file-lock.test.ts +279 -0
  60. package/src/file-lock.ts +318 -0
  61. package/src/harnesses.test.ts +48 -0
  62. package/src/harnesses.ts +59 -0
  63. package/src/hook-adapter.test.ts +187 -0
  64. package/src/hook-adapter.ts +212 -0
  65. package/src/hook-input.test.ts +39 -0
  66. package/src/hook-input.ts +72 -0
  67. package/src/hooks.test.ts +1318 -240
  68. package/src/hooks.ts +1046 -296
  69. package/src/install-hooks.test.ts +90 -41
  70. package/src/install-hooks.ts +259 -74
  71. package/src/interactive.ts +3 -3
  72. package/src/logging-process-worker.test.ts +39 -0
  73. package/src/logging.test.ts +453 -0
  74. package/src/logging.ts +639 -0
  75. package/src/main.ts +113 -32
  76. package/src/opencode-plugin.ts +15 -16
  77. package/src/session-end-log-lock-worker.test.ts +35 -0
  78. package/src/skill.test.ts +11 -0
  79. package/src/ui/config-view.test.ts +4 -2
  80. package/src/ui/config-view.ts +3 -0
@@ -1,12 +1,20 @@
1
1
  import {
2
2
  existsSync,
3
+ lstatSync,
3
4
  readFileSync,
5
+ realpathSync,
4
6
  statSync,
5
7
  } from 'node:fs'
8
+ import { createHash } from 'node:crypto'
6
9
  import os from 'node:os'
7
10
  import path from 'node:path'
11
+ import { parse as parseToml } from 'smol-toml'
8
12
  import { atomicWriteFileSync } from './atomic-file.js'
13
+ import { hookAdapterPath } from './hook-adapter.js'
9
14
  import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
15
+ import { HARNESSES, HOOK_TIMING, type Harness } from './harnesses.js'
16
+
17
+ export { HARNESSES, type Harness } from './harnesses.js'
10
18
 
11
19
  /**
12
20
  * The three joints the OpenCode plugin hooks into, as (harness event, the
@@ -32,13 +40,12 @@ const OPENCODE_EVENTS = [
32
40
  * adapter instead of being forced through this shared document shape.
33
41
  */
34
42
 
35
- export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
36
- export type Harness = (typeof HARNESSES)[number]
37
-
38
43
  export interface HookHandler {
39
44
  type: 'command'
40
45
  command: string
41
46
  timeout?: number
47
+ async?: boolean
48
+ statusMessage?: string
42
49
  }
43
50
 
44
51
  export interface HookGroup {
@@ -58,18 +65,17 @@ export interface InstallPlan {
58
65
  }
59
66
 
60
67
  /**
61
- * The command each hook runs. Uses the absolute interpreter and script path
62
- * rather than the bare binary name: hooks inherit a login-ish environment, and
63
- * a `notifai` that is only on an interactive shell's PATH fails silently.
68
+ * The command each hook runs. Harness definitions know only the stable
69
+ * user-level adapter. Mutable Node, package-manager, version, and checkout
70
+ * paths live behind that seam and never enter a trusted hook identity.
64
71
  */
65
72
  export function hookCommand(
66
- execPath: string,
67
- scriptPath: string,
73
+ adapterPath: string,
68
74
  event: string,
69
75
  harness?: Harness,
70
76
  ): string {
71
77
  return (
72
- `${quote(execPath)} ${quote(scriptPath)} hook ${event} ${OWNER_MARKER}` +
78
+ `${quote(adapterPath)} hook ${event} ${OWNER_MARKER}` +
73
79
  (harness === undefined ? '' : ` --harness ${harness}`)
74
80
  )
75
81
  }
@@ -101,40 +107,41 @@ function quote(value: string): string {
101
107
  }
102
108
 
103
109
  export interface BuildOptions {
104
- execPath: string
105
- scriptPath: string
106
- /** Seconds a blocking hook may wait for a companion-device answer. */
107
- replyTimeoutSeconds: number
108
- /** Seconds the question waits in the terminal before it is pushed at all. */
109
- graceSeconds: number
110
+ /** Stable user-level executable installed by the hook adapter module. */
111
+ adapterPath: string
110
112
  /** The installed adapter stamps its exact harness into project pointers. */
111
113
  harness?: Harness
112
114
  }
113
115
 
114
- /** Command-hook harnesses kill at 600s; generated adapters use the same safe ceiling. */
115
- const HOOK_CEILING_SECONDS = 590
116
+ /** Generated blocking adapters grant the owner budget one fixed minute of host headroom. */
117
+ const HOOK_CEILING_SECONDS = HOOK_TIMING.totalSeconds + HOOK_TIMING.hostHeadroomSeconds
116
118
 
117
- /** Grace, reply wait, and teardown headroom for any blocking turn-end adapter. */
118
- export function blockingHookTimeoutSeconds(graceSeconds: number, replyTimeoutSeconds: number): number {
119
- return Math.min(HOOK_CEILING_SECONDS, graceSeconds + replyTimeoutSeconds + 60)
119
+ /**
120
+ * Stable process budget for a blocking turn-end adapter.
121
+ *
122
+ * Codex trusts the exact serialized hook definition. Deriving this timeout
123
+ * from mutable question preferences meant changing `ask_grace_seconds`
124
+ * silently invalidated Stop trust while the other two handlers kept running.
125
+ * The runtime already fits grace and reply waiting inside the harness ceiling;
126
+ * the installed identity therefore stays at that ceiling across preferences.
127
+ */
128
+ export function blockingHookTimeoutSeconds(): number {
129
+ return HOOK_CEILING_SECONDS
120
130
  }
121
131
 
122
132
  export function buildHookConfig(options: BuildOptions): HookConfig {
123
- const { execPath, scriptPath } = options
124
- // Stop can spend the grace window AND then the reply wait, so the declared
125
- // timeout must cover both. Headroom on top so the harness never kills the
126
- // hook first; a killed hook loses the answer the user already gave.
127
- const blockingTimeout = blockingHookTimeoutSeconds(
128
- options.graceSeconds,
129
- options.replyTimeoutSeconds,
130
- )
133
+ const { adapterPath } = options
134
+ const hostOwnsStopTimeout = options.harness === 'codex'
135
+ // Runtime preferences fit inside this fixed process budget without becoming
136
+ // part of the serialized definition Codex asks the user to trust.
137
+ const blockingTimeout = blockingHookTimeoutSeconds()
131
138
  return {
132
139
  UserPromptSubmit: [
133
140
  {
134
141
  hooks: [
135
142
  {
136
143
  type: 'command',
137
- command: hookCommand(execPath, scriptPath, 'user-prompt-submit', options.harness),
144
+ command: hookCommand(adapterPath, 'user-prompt-submit', options.harness),
138
145
  // Claude Code caps UserPromptSubmit at 30s; stay well inside it so
139
146
  // a slow network can never delay the user's own prompt.
140
147
  timeout: 15,
@@ -147,8 +154,8 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
147
154
  hooks: [
148
155
  {
149
156
  type: 'command',
150
- command: hookCommand(execPath, scriptPath, 'stop', options.harness),
151
- timeout: blockingTimeout,
157
+ command: hookCommand(adapterPath, 'stop', options.harness),
158
+ ...(hostOwnsStopTimeout ? {} : { timeout: blockingTimeout }),
152
159
  },
153
160
  ],
154
161
  },
@@ -158,7 +165,7 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
158
165
  hooks: [
159
166
  {
160
167
  type: 'command',
161
- command: hookCommand(execPath, scriptPath, 'session-end', options.harness),
168
+ command: hookCommand(adapterPath, 'session-end', options.harness),
162
169
  // Both harnesses give SessionEnd a ~1-3s budget, so this handler
163
170
  // only touches local state.
164
171
  timeout: 3,
@@ -185,20 +192,17 @@ interface CursorSettingsDocument {
185
192
 
186
193
  /** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
187
194
  export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
188
- const blockingTimeout = blockingHookTimeoutSeconds(
189
- options.graceSeconds,
190
- options.replyTimeoutSeconds,
191
- )
195
+ const blockingTimeout = blockingHookTimeoutSeconds()
192
196
  return {
193
197
  beforeSubmitPrompt: [
194
198
  {
195
- command: hookCommand(options.execPath, options.scriptPath, 'user-prompt-submit', 'cursor'),
199
+ command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor'),
196
200
  timeout: 15,
197
201
  },
198
202
  ],
199
203
  stop: [
200
204
  {
201
- command: hookCommand(options.execPath, options.scriptPath, 'stop', 'cursor'),
205
+ command: hookCommand(options.adapterPath, 'stop', 'cursor'),
202
206
  timeout: blockingTimeout,
203
207
  // A continuation may register a real follow-up question. Match the
204
208
  // session-state cap so those chains are useful but never unbounded.
@@ -207,7 +211,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
207
211
  ],
208
212
  sessionEnd: [
209
213
  {
210
- command: hookCommand(options.execPath, options.scriptPath, 'session-end', 'cursor'),
214
+ command: hookCommand(options.adapterPath, 'session-end', 'cursor'),
211
215
  timeout: 3,
212
216
  },
213
217
  ],
@@ -225,24 +229,32 @@ export function settingsFile(
225
229
  cwd: string,
226
230
  env: NodeJS.ProcessEnv = process.env,
227
231
  ): string {
228
- // OpenCode has no settings document to merge into — its adapter is a
229
- // generated plugin module, so it owns a whole file rather than a handler
230
- // inside one. `opencodePluginPath` is its equivalent.
231
- if (harness === 'opencode') return opencodePluginPath(global, cwd, env)
232
- if (harness === 'cursor') {
233
- const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
234
- return global
235
- ? path.join(home, '.cursor', 'hooks.json')
236
- : path.join(cwd, '.cursor', 'hooks.json')
237
- }
238
- if (harness === 'claude-code') {
239
- return global
240
- ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
241
- : path.join(cwd, '.claude', 'settings.local.json')
232
+ switch (harness) {
233
+ // OpenCode has no settings document to merge into — its adapter is a
234
+ // generated plugin module, so it owns a whole file instead.
235
+ case 'opencode':
236
+ return opencodePluginPath(global, cwd, env)
237
+ case 'cursor': {
238
+ const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
239
+ return global
240
+ ? path.join(home, '.cursor', 'hooks.json')
241
+ : path.join(cwd, '.cursor', 'hooks.json')
242
+ }
243
+ case 'claude-code':
244
+ return global
245
+ ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
246
+ : path.join(cwd, '.claude', 'settings.local.json')
247
+ case 'codex':
248
+ return global
249
+ ? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
250
+ : path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
251
+ default:
252
+ return assertNeverHarness(harness)
242
253
  }
243
- return global
244
- ? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
245
- : path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
254
+ }
255
+
256
+ function assertNeverHarness(harness: never): never {
257
+ throw new Error(`Unsupported harness: ${String(harness)}`)
246
258
  }
247
259
 
248
260
  /**
@@ -403,8 +415,9 @@ interface SettingsDocument {
403
415
 
404
416
  function readCursorSettings(file: string): CursorSettingsDocument {
405
417
  if (!existsSync(file)) return { version: 1 }
418
+ const source = readOwnedRegularFile(file)
406
419
  try {
407
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
420
+ const parsed: unknown = JSON.parse(source)
408
421
  return typeof parsed === 'object' && parsed !== null
409
422
  ? (parsed as CursorSettingsDocument)
410
423
  : { version: 1 }
@@ -418,7 +431,24 @@ function isOurCommand(command: string, scriptPath: string): boolean {
418
431
  return (
419
432
  command.includes(`${scriptPath}' hook `) ||
420
433
  command.includes(`${scriptPath}" hook `) ||
421
- command.includes(`${scriptPath} hook `)
434
+ command.includes(`${scriptPath} hook `) ||
435
+ isLegacyNotifaiCommand(command)
436
+ )
437
+ }
438
+
439
+ /** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
440
+ function isLegacyNotifaiCommand(command: string): boolean {
441
+ const hook = `['"]?\\s+hook (?:user-prompt-submit|stop|session-end)\\b`
442
+ return (
443
+ new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
444
+ new RegExp(
445
+ `(?:^|[/\\\\])(?:notifai-public|notifai)[/\\\\]apps[/\\\\]cli[/\\\\]dist[/\\\\]main\\.js${hook}`,
446
+ 'i',
447
+ ).test(command) ||
448
+ new RegExp(
449
+ `[/\\\\]node_modules[/\\\\]@raidiant[/\\\\]notifai[/\\\\]dist[/\\\\]main\\.js${hook}`,
450
+ 'i',
451
+ ).test(command)
422
452
  )
423
453
  }
424
454
 
@@ -463,8 +493,9 @@ export function removeCursorHooks(
463
493
 
464
494
  function readSettings(file: string): SettingsDocument {
465
495
  if (!existsSync(file)) return {}
496
+ const source = readOwnedRegularFile(file)
466
497
  try {
467
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
498
+ const parsed: unknown = JSON.parse(source)
468
499
  return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
469
500
  } catch {
470
501
  throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
@@ -563,7 +594,9 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
563
594
  * support.
564
595
  */
565
596
  export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
566
- atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`)
597
+ atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`, {
598
+ requireCurrentUserOwner: true,
599
+ })
567
600
  }
568
601
 
569
602
  export function loadSettings(file: string): SettingsDocument {
@@ -588,6 +621,8 @@ export interface InstalledHandler {
588
621
  command: string
589
622
  /** Declared process lifetime, used to detect config/install drift. */
590
623
  timeout?: number
624
+ async?: boolean
625
+ statusMessage?: string
591
626
  }
592
627
 
593
628
  export interface Installation {
@@ -599,6 +634,112 @@ export interface Installation {
599
634
  problems?: string[]
600
635
  }
601
636
 
637
+ /** Codex's canonical event spelling inside its persisted trust keys. */
638
+ function codexEventName(event: string): string {
639
+ return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
640
+ }
641
+
642
+ /** Recursively sort object keys before hashing, matching Codex's canonical JSON. */
643
+ function canonicalValue(value: unknown): unknown {
644
+ if (Array.isArray(value)) return value.map(canonicalValue)
645
+ if (typeof value !== 'object' || value === null) return value
646
+ return Object.fromEntries(
647
+ Object.entries(value as Record<string, unknown>)
648
+ .sort(([left], [right]) => left.localeCompare(right))
649
+ .map(([key, child]) => [key, canonicalValue(child)]),
650
+ )
651
+ }
652
+
653
+ /**
654
+ * Best-effort mirror of the identity current Codex builds compare with
655
+ * `hooks.state.*.trusted_hash`. This persisted format is diagnostic evidence,
656
+ * not an API: Codex's `/hooks` UI remains authoritative and Notifai never
657
+ * writes the trust store.
658
+ */
659
+ export function codexHookIdentityHash(handler: InstalledHandler): string {
660
+ const command = {
661
+ type: 'command',
662
+ command: handler.command,
663
+ timeout: Math.max(1, handler.timeout ?? 600),
664
+ async: handler.async ?? false,
665
+ ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
666
+ }
667
+ const normalized = canonicalValue({
668
+ event_name: codexEventName(handler.event),
669
+ hooks: [command],
670
+ })
671
+ return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`
672
+ }
673
+
674
+ function codexTrustState(env: NodeJS.ProcessEnv): Record<string, unknown> {
675
+ const file = path.join(configHome(env, 'CODEX_HOME', '.codex'), 'config.toml')
676
+ if (!existsSync(file)) return {}
677
+ try {
678
+ const parsed = parseToml(readFileSync(file, 'utf8')) as Record<string, unknown>
679
+ const hooks = parsed['hooks']
680
+ if (typeof hooks !== 'object' || hooks === null) return {}
681
+ const state = (hooks as Record<string, unknown>)['state']
682
+ return typeof state === 'object' && state !== null ? state as Record<string, unknown> : {}
683
+ } catch {
684
+ return {}
685
+ }
686
+ }
687
+
688
+ /** Best-effort mirror of current Codex's persisted key for one handler. */
689
+ export function codexTrustKey(
690
+ installation: Installation,
691
+ handler: InstalledHandler,
692
+ ): string {
693
+ let source = installation.file
694
+ try {
695
+ source = path.join(realpathSync(path.dirname(source)), path.basename(source))
696
+ } catch {
697
+ // Codex falls back to the logical source path when canonicalization fails,
698
+ // so the diagnostic must do the same.
699
+ }
700
+ return `${source}:${codexEventName(handler.event)}:${handler.groupIndex}:${handler.handlerIndex}`
701
+ }
702
+
703
+ /**
704
+ * Trust defects that make installed Codex handlers look present while Codex
705
+ * skips them. Trust is user-owned; the supported repair is Codex's `/hooks`
706
+ * review UI, never writing the trust store on the user's behalf. Because the
707
+ * persisted shape is not a public Codex contract, this diagnosis can drift;
708
+ * `/hooks` remains the source of truth.
709
+ */
710
+ export function codexTrustProblems(
711
+ installations: Installation[],
712
+ env: NodeJS.ProcessEnv = process.env,
713
+ ): string[] {
714
+ const state = codexTrustState(env)
715
+ return installations
716
+ .filter((installation) => installation.harness === 'codex')
717
+ .flatMap((installation) =>
718
+ installation.handlers.flatMap((handler) => {
719
+ const key = codexTrustKey(installation, handler)
720
+ const entry = state[key]
721
+ const trustedHash =
722
+ typeof entry === 'object' && entry !== null
723
+ ? (entry as Record<string, unknown>)['trusted_hash']
724
+ : undefined
725
+ const currentHash = codexHookIdentityHash(handler)
726
+ if (
727
+ typeof entry === 'object' &&
728
+ entry !== null &&
729
+ (entry as Record<string, unknown>)['enabled'] === false
730
+ ) {
731
+ return [
732
+ `${handler.event} in ${installation.file} is disabled in Codex; open \`/hooks\` and enable the Notifai handler`,
733
+ ]
734
+ }
735
+ if (trustedHash === currentHash) return []
736
+ return [
737
+ `${handler.event} in ${installation.file} is ${typeof trustedHash === 'string' ? 'changed since it was trusted' : 'not trusted'}; open \`/hooks\` in Codex and approve the Notifai handler`,
738
+ ]
739
+ }),
740
+ )
741
+ }
742
+
602
743
  /**
603
744
  * Any Notifai handler, whatever checkout wrote it.
604
745
  *
@@ -615,7 +756,11 @@ function isNotifaiHandler(handler: HookHandler): boolean {
615
756
  }
616
757
 
617
758
  /** Every place either harness would read a Notifai handler from. */
618
- export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.env): Installation[] {
759
+ export function findInstallations(
760
+ cwd: string,
761
+ env: NodeJS.ProcessEnv = process.env,
762
+ adapterHome?: string,
763
+ ): Installation[] {
619
764
  const found: Installation[] = []
620
765
  for (const harness of HARNESSES) {
621
766
  for (const global of [false, true]) {
@@ -625,15 +770,29 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
625
770
  // is reported as one installation covering all three events rather than
626
771
  // parsed for handlers.
627
772
  if (harness === 'opencode') {
628
- const target = opencodePluginTarget(readFileSync(file, 'utf8'))
773
+ let source: string
774
+ try {
775
+ source = readOwnedRegularFile(file)
776
+ } catch {
777
+ continue
778
+ }
779
+ const target = opencodePluginTarget(source)
629
780
  if (target === null) continue
781
+ const problems = [
782
+ ...(!target.current
783
+ ? ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`']
784
+ : []),
785
+ ...(target.adapter !== hookAdapterPath(adapterHome)
786
+ ? [
787
+ 'OpenCode still names a mutable CLI or runtime path; rerun `notifai hooks install --harness opencode`',
788
+ ]
789
+ : []),
790
+ ]
630
791
  found.push({
631
792
  harness,
632
793
  file,
633
794
  global,
634
- ...(!target.current
635
- ? { problems: ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`'] }
636
- : {}),
795
+ ...(problems.length > 0 ? { problems } : {}),
637
796
  // Reported as the command line the plugin will actually run, not as
638
797
  // the plugin's own path: every check downstream asks which build a
639
798
  // handler invokes, and for a module that answer lives inside it.
@@ -641,7 +800,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
641
800
  event,
642
801
  groupIndex: 0,
643
802
  handlerIndex: 0,
644
- command: hookCommand(target.exec, target.script, hookEvent),
803
+ command: hookCommand(target.adapter, hookEvent, 'opencode'),
645
804
  ...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
646
805
  })),
647
806
  })
@@ -656,7 +815,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
656
815
  }
657
816
  const handlers = locateCursorHandlers(document)
658
817
  if (handlers.length > 0) {
659
- const problems = harnessMarkerProblems(harness, handlers)
818
+ const problems = harnessMarkerProblems(harness, handlers, adapterHome)
660
819
  found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
661
820
  }
662
821
  continue
@@ -669,7 +828,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
669
828
  }
670
829
  const handlers = locateHandlers(document)
671
830
  if (handlers.length > 0) {
672
- const problems = harnessMarkerProblems(harness, handlers)
831
+ const problems = harnessMarkerProblems(harness, handlers, adapterHome)
673
832
  found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
674
833
  }
675
834
  }
@@ -677,12 +836,24 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
677
836
  return found
678
837
  }
679
838
 
680
- function harnessMarkerProblems(harness: Harness, handlers: InstalledHandler[]): string[] {
681
- return handlers.every((handler) => handler.command.includes(`--harness ${harness}`))
682
- ? []
683
- : [
684
- `installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
685
- ]
839
+ function harnessMarkerProblems(
840
+ harness: Harness,
841
+ handlers: InstalledHandler[],
842
+ adapterHome?: string,
843
+ ): string[] {
844
+ const problems: string[] = []
845
+ if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
846
+ problems.push(
847
+ `installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
848
+ )
849
+ }
850
+ const expected = `${quote(hookAdapterPath(adapterHome))} hook `
851
+ if (!handlers.every((handler) => handler.command.startsWith(expected))) {
852
+ problems.push(
853
+ 'installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter',
854
+ )
855
+ }
856
+ return problems
686
857
  }
687
858
 
688
859
  function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandler[] {
@@ -715,6 +886,8 @@ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
715
886
  handlerIndex,
716
887
  command: handler.command,
717
888
  ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
889
+ ...(handler.async === undefined ? {} : { async: handler.async }),
890
+ ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
718
891
  })
719
892
  }
720
893
  })
@@ -727,3 +900,15 @@ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
727
900
  export function handlerEvent(command: string): string | null {
728
901
  return / hook ([a-z-]+)/.exec(command)?.[1] ?? null
729
902
  }
903
+
904
+ function readOwnedRegularFile(file: string): string {
905
+ const stat = lstatSync(file)
906
+ if (stat.isSymbolicLink() || !stat.isFile()) {
907
+ throw new Error(`${file} is not a regular file; refusing to read it.`)
908
+ }
909
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
910
+ if (uid !== undefined && stat.uid !== uid) {
911
+ throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
912
+ }
913
+ return readFileSync(file, 'utf8')
914
+ }
@@ -593,9 +593,9 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
593
593
  const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
594
594
  clack.note(
595
595
  'When an agent registers a question, it appears in your terminal first.\n' +
596
- 'If you are still here, it stays here. Once you step away, it goes to\n' +
597
- 'your phone and Mac as a notification with real answer buttons, and\n' +
598
- 'your answer comes back to the agent.\n\n' +
596
+ 'After the grace below, it goes to your phone and Mac as a notification\n' +
597
+ 'with real answer buttons, and your answer comes back to the agent. Turn\n' +
598
+ 'on “Only when away” if local activity should keep it here instead.\n\n' +
599
599
  [
600
600
  row('Wired to', wiringSummary(hooks)),
601
601
  row('Send', config.ask_notifications.value ? 'yes' : 'no — questions stay in the terminal'),
@@ -0,0 +1,39 @@
1
+ import { existsSync, writeFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createLogger } from './logging.js'
4
+
5
+ const worker = process.env['NOTIFAI_ROTATION_WORKER']
6
+
7
+ if (worker === undefined) {
8
+ describe('rotation process worker', () => {
9
+ it('stays dormant outside the concurrency regression', () => {
10
+ expect(worker).toBeUndefined()
11
+ })
12
+ })
13
+ } else {
14
+ describe('rotation process worker', () => {
15
+ it('writes one share of an overlapping log stream', async () => {
16
+ const readyPath = process.env['NOTIFAI_ROTATION_READY']!
17
+ const startPath = process.env['NOTIFAI_ROTATION_START']!
18
+ const records = Number(process.env['NOTIFAI_ROTATION_RECORDS'])
19
+ const maxBytes = Number(process.env['NOTIFAI_ROTATION_MAX_BYTES'])
20
+ const maxFiles = Number(process.env['NOTIFAI_ROTATION_MAX_FILES'])
21
+ writeFileSync(readyPath, 'ready')
22
+ const startDeadline = Date.now() + 10_000
23
+ while (!existsSync(startPath)) {
24
+ if (Date.now() >= startDeadline) throw new Error('rotation parent never released the barrier')
25
+ await new Promise((resolve) => setTimeout(resolve, 2))
26
+ }
27
+
28
+ const logger = createLogger({
29
+ runId: `r_worker_${worker}`,
30
+ settings: { maxBytes, maxFiles },
31
+ })
32
+ for (let sequence = 0; sequence < records; sequence += 1) {
33
+ logger.info('cli.end', { worker, sequence, pad: 'p'.repeat(140) })
34
+ await new Promise((resolve) => setTimeout(resolve, 1))
35
+ }
36
+ expect(logger.enabled).toBe(true)
37
+ })
38
+ })
39
+ }