@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
@@ -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, 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,67 @@ 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
+ /**
117
+ * Claude Code's Stop handler returns instantly and the same process lives on
118
+ * out of band as the escalation waiter, so this `timeout` bounds a background
119
+ * process rather than the user's turn.
120
+ *
121
+ * It is always declared, and declared far above the longest wait a waiter can
122
+ * take. Claude's default is 600 s and the kill is **silent** — the backgrounded
123
+ * process simply vanishes, no error is reported anywhere, and the answer the
124
+ * user already gave is never delivered. Sitting anywhere near that boundary
125
+ * turns a slow reply into a lost one, so this does not: an explicit value
126
+ * raises the ceiling (verified against a 660 s wait under an hour-long
127
+ * declaration), and an hour is several times the waiter's own ceiling.
128
+ */
129
+ export const CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS = 3600
130
+
131
+ /**
132
+ * The declared process budget for a host that holds its turn while the waiter
133
+ * runs: the waiter's own ceiling plus a minute of host headroom, as one plain
134
+ * number rather than a budget carved into reserves.
135
+ *
136
+ * Codex is deliberately absent from this. It owns its Stop timeout today, and
137
+ * declaring one would change the serialized definition Codex hashes into
138
+ * `trusted_hash` — which silently stops every Notifai handler from running
139
+ * until the user re-approves them in `/hooks`.
140
+ */
141
+ export const BLOCKING_STOP_TIMEOUT_SECONDS = 540
116
142
 
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)
143
+ /**
144
+ * The turn-end handler, whose shape is the whole per-harness difference.
145
+ *
146
+ * Claude Code takes the answer over its own inbox socket, so its Stop hook is
147
+ * `async: true`: it returns immediately, the terminal is never held, and the
148
+ * waiter finishes out of band. Every other host still delivers by printing a
149
+ * continuation to this process's stdout, so its Stop hook blocks and needs a
150
+ * declared ceiling above the wait — except Codex, which owns that number
151
+ * itself and whose definition must stay byte-stable for its trust store.
152
+ */
153
+ function stopHandler(adapterPath: string, harness: Harness | undefined): HookHandler {
154
+ const command = hookCommand(adapterPath, 'stop', harness)
155
+ if (harness === 'claude-code') {
156
+ return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true }
157
+ }
158
+ if (harness === 'codex') return { type: 'command', command }
159
+ return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS }
120
160
  }
121
161
 
122
162
  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
- )
163
+ const { adapterPath } = options
131
164
  return {
132
165
  UserPromptSubmit: [
133
166
  {
134
167
  hooks: [
135
168
  {
136
169
  type: 'command',
137
- command: hookCommand(execPath, scriptPath, 'user-prompt-submit', options.harness),
170
+ command: hookCommand(adapterPath, 'user-prompt-submit', options.harness),
138
171
  // Claude Code caps UserPromptSubmit at 30s; stay well inside it so
139
172
  // a slow network can never delay the user's own prompt.
140
173
  timeout: 15,
@@ -142,23 +175,13 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
142
175
  ],
143
176
  },
144
177
  ],
145
- Stop: [
146
- {
147
- hooks: [
148
- {
149
- type: 'command',
150
- command: hookCommand(execPath, scriptPath, 'stop', options.harness),
151
- timeout: blockingTimeout,
152
- },
153
- ],
154
- },
155
- ],
178
+ Stop: [{ hooks: [stopHandler(adapterPath, options.harness)] }],
156
179
  SessionEnd: [
157
180
  {
158
181
  hooks: [
159
182
  {
160
183
  type: 'command',
161
- command: hookCommand(execPath, scriptPath, 'session-end', options.harness),
184
+ command: hookCommand(adapterPath, 'session-end', options.harness),
162
185
  // Both harnesses give SessionEnd a ~1-3s budget, so this handler
163
186
  // only touches local state.
164
187
  timeout: 3,
@@ -185,21 +208,17 @@ interface CursorSettingsDocument {
185
208
 
186
209
  /** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
187
210
  export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
188
- const blockingTimeout = blockingHookTimeoutSeconds(
189
- options.graceSeconds,
190
- options.replyTimeoutSeconds,
191
- )
192
211
  return {
193
212
  beforeSubmitPrompt: [
194
213
  {
195
- command: hookCommand(options.execPath, options.scriptPath, 'user-prompt-submit', 'cursor'),
214
+ command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor'),
196
215
  timeout: 15,
197
216
  },
198
217
  ],
199
218
  stop: [
200
219
  {
201
- command: hookCommand(options.execPath, options.scriptPath, 'stop', 'cursor'),
202
- timeout: blockingTimeout,
220
+ command: hookCommand(options.adapterPath, 'stop', 'cursor'),
221
+ timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
203
222
  // A continuation may register a real follow-up question. Match the
204
223
  // session-state cap so those chains are useful but never unbounded.
205
224
  loop_limit: 3,
@@ -207,7 +226,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
207
226
  ],
208
227
  sessionEnd: [
209
228
  {
210
- command: hookCommand(options.execPath, options.scriptPath, 'session-end', 'cursor'),
229
+ command: hookCommand(options.adapterPath, 'session-end', 'cursor'),
211
230
  timeout: 3,
212
231
  },
213
232
  ],
@@ -225,24 +244,32 @@ export function settingsFile(
225
244
  cwd: string,
226
245
  env: NodeJS.ProcessEnv = process.env,
227
246
  ): 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')
247
+ switch (harness) {
248
+ // OpenCode has no settings document to merge into — its adapter is a
249
+ // generated plugin module, so it owns a whole file instead.
250
+ case 'opencode':
251
+ return opencodePluginPath(global, cwd, env)
252
+ case 'cursor': {
253
+ const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
254
+ return global
255
+ ? path.join(home, '.cursor', 'hooks.json')
256
+ : path.join(cwd, '.cursor', 'hooks.json')
257
+ }
258
+ case 'claude-code':
259
+ return global
260
+ ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
261
+ : path.join(cwd, '.claude', 'settings.local.json')
262
+ case 'codex':
263
+ return global
264
+ ? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
265
+ : path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
266
+ default:
267
+ return assertNeverHarness(harness)
242
268
  }
243
- return global
244
- ? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
245
- : path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
269
+ }
270
+
271
+ function assertNeverHarness(harness: never): never {
272
+ throw new Error(`Unsupported harness: ${String(harness)}`)
246
273
  }
247
274
 
248
275
  /**
@@ -403,8 +430,9 @@ interface SettingsDocument {
403
430
 
404
431
  function readCursorSettings(file: string): CursorSettingsDocument {
405
432
  if (!existsSync(file)) return { version: 1 }
433
+ const source = readOwnedRegularFile(file)
406
434
  try {
407
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
435
+ const parsed: unknown = JSON.parse(source)
408
436
  return typeof parsed === 'object' && parsed !== null
409
437
  ? (parsed as CursorSettingsDocument)
410
438
  : { version: 1 }
@@ -418,7 +446,24 @@ function isOurCommand(command: string, scriptPath: string): boolean {
418
446
  return (
419
447
  command.includes(`${scriptPath}' hook `) ||
420
448
  command.includes(`${scriptPath}" hook `) ||
421
- command.includes(`${scriptPath} hook `)
449
+ command.includes(`${scriptPath} hook `) ||
450
+ isLegacyNotifaiCommand(command)
451
+ )
452
+ }
453
+
454
+ /** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
455
+ function isLegacyNotifaiCommand(command: string): boolean {
456
+ const hook = `['"]?\\s+hook (?:user-prompt-submit|stop|session-end)\\b`
457
+ return (
458
+ new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
459
+ new RegExp(
460
+ `(?:^|[/\\\\])(?:notifai-public|notifai)[/\\\\]apps[/\\\\]cli[/\\\\]dist[/\\\\]main\\.js${hook}`,
461
+ 'i',
462
+ ).test(command) ||
463
+ new RegExp(
464
+ `[/\\\\]node_modules[/\\\\]@raidiant[/\\\\]notifai[/\\\\]dist[/\\\\]main\\.js${hook}`,
465
+ 'i',
466
+ ).test(command)
422
467
  )
423
468
  }
424
469
 
@@ -463,8 +508,9 @@ export function removeCursorHooks(
463
508
 
464
509
  function readSettings(file: string): SettingsDocument {
465
510
  if (!existsSync(file)) return {}
511
+ const source = readOwnedRegularFile(file)
466
512
  try {
467
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
513
+ const parsed: unknown = JSON.parse(source)
468
514
  return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
469
515
  } catch {
470
516
  throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
@@ -563,7 +609,9 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
563
609
  * support.
564
610
  */
565
611
  export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
566
- atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`)
612
+ atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`, {
613
+ requireCurrentUserOwner: true,
614
+ })
567
615
  }
568
616
 
569
617
  export function loadSettings(file: string): SettingsDocument {
@@ -588,6 +636,8 @@ export interface InstalledHandler {
588
636
  command: string
589
637
  /** Declared process lifetime, used to detect config/install drift. */
590
638
  timeout?: number
639
+ async?: boolean
640
+ statusMessage?: string
591
641
  }
592
642
 
593
643
  export interface Installation {
@@ -599,6 +649,112 @@ export interface Installation {
599
649
  problems?: string[]
600
650
  }
601
651
 
652
+ /** Codex's canonical event spelling inside its persisted trust keys. */
653
+ function codexEventName(event: string): string {
654
+ return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
655
+ }
656
+
657
+ /** Recursively sort object keys before hashing, matching Codex's canonical JSON. */
658
+ function canonicalValue(value: unknown): unknown {
659
+ if (Array.isArray(value)) return value.map(canonicalValue)
660
+ if (typeof value !== 'object' || value === null) return value
661
+ return Object.fromEntries(
662
+ Object.entries(value as Record<string, unknown>)
663
+ .sort(([left], [right]) => left.localeCompare(right))
664
+ .map(([key, child]) => [key, canonicalValue(child)]),
665
+ )
666
+ }
667
+
668
+ /**
669
+ * Best-effort mirror of the identity current Codex builds compare with
670
+ * `hooks.state.*.trusted_hash`. This persisted format is diagnostic evidence,
671
+ * not an API: Codex's `/hooks` UI remains authoritative and Notifai never
672
+ * writes the trust store.
673
+ */
674
+ export function codexHookIdentityHash(handler: InstalledHandler): string {
675
+ const command = {
676
+ type: 'command',
677
+ command: handler.command,
678
+ timeout: Math.max(1, handler.timeout ?? 600),
679
+ async: handler.async ?? false,
680
+ ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
681
+ }
682
+ const normalized = canonicalValue({
683
+ event_name: codexEventName(handler.event),
684
+ hooks: [command],
685
+ })
686
+ return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`
687
+ }
688
+
689
+ function codexTrustState(env: NodeJS.ProcessEnv): Record<string, unknown> {
690
+ const file = path.join(configHome(env, 'CODEX_HOME', '.codex'), 'config.toml')
691
+ if (!existsSync(file)) return {}
692
+ try {
693
+ const parsed = parseToml(readFileSync(file, 'utf8')) as Record<string, unknown>
694
+ const hooks = parsed['hooks']
695
+ if (typeof hooks !== 'object' || hooks === null) return {}
696
+ const state = (hooks as Record<string, unknown>)['state']
697
+ return typeof state === 'object' && state !== null ? state as Record<string, unknown> : {}
698
+ } catch {
699
+ return {}
700
+ }
701
+ }
702
+
703
+ /** Best-effort mirror of current Codex's persisted key for one handler. */
704
+ export function codexTrustKey(
705
+ installation: Installation,
706
+ handler: InstalledHandler,
707
+ ): string {
708
+ let source = installation.file
709
+ try {
710
+ source = path.join(realpathSync(path.dirname(source)), path.basename(source))
711
+ } catch {
712
+ // Codex falls back to the logical source path when canonicalization fails,
713
+ // so the diagnostic must do the same.
714
+ }
715
+ return `${source}:${codexEventName(handler.event)}:${handler.groupIndex}:${handler.handlerIndex}`
716
+ }
717
+
718
+ /**
719
+ * Trust defects that make installed Codex handlers look present while Codex
720
+ * skips them. Trust is user-owned; the supported repair is Codex's `/hooks`
721
+ * review UI, never writing the trust store on the user's behalf. Because the
722
+ * persisted shape is not a public Codex contract, this diagnosis can drift;
723
+ * `/hooks` remains the source of truth.
724
+ */
725
+ export function codexTrustProblems(
726
+ installations: Installation[],
727
+ env: NodeJS.ProcessEnv = process.env,
728
+ ): string[] {
729
+ const state = codexTrustState(env)
730
+ return installations
731
+ .filter((installation) => installation.harness === 'codex')
732
+ .flatMap((installation) =>
733
+ installation.handlers.flatMap((handler) => {
734
+ const key = codexTrustKey(installation, handler)
735
+ const entry = state[key]
736
+ const trustedHash =
737
+ typeof entry === 'object' && entry !== null
738
+ ? (entry as Record<string, unknown>)['trusted_hash']
739
+ : undefined
740
+ const currentHash = codexHookIdentityHash(handler)
741
+ if (
742
+ typeof entry === 'object' &&
743
+ entry !== null &&
744
+ (entry as Record<string, unknown>)['enabled'] === false
745
+ ) {
746
+ return [
747
+ `${handler.event} in ${installation.file} is disabled in Codex; open \`/hooks\` and enable the Notifai handler`,
748
+ ]
749
+ }
750
+ if (trustedHash === currentHash) return []
751
+ return [
752
+ `${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`,
753
+ ]
754
+ }),
755
+ )
756
+ }
757
+
602
758
  /**
603
759
  * Any Notifai handler, whatever checkout wrote it.
604
760
  *
@@ -615,7 +771,11 @@ function isNotifaiHandler(handler: HookHandler): boolean {
615
771
  }
616
772
 
617
773
  /** Every place either harness would read a Notifai handler from. */
618
- export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.env): Installation[] {
774
+ export function findInstallations(
775
+ cwd: string,
776
+ env: NodeJS.ProcessEnv = process.env,
777
+ adapterHome?: string,
778
+ ): Installation[] {
619
779
  const found: Installation[] = []
620
780
  for (const harness of HARNESSES) {
621
781
  for (const global of [false, true]) {
@@ -625,15 +785,29 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
625
785
  // is reported as one installation covering all three events rather than
626
786
  // parsed for handlers.
627
787
  if (harness === 'opencode') {
628
- const target = opencodePluginTarget(readFileSync(file, 'utf8'))
788
+ let source: string
789
+ try {
790
+ source = readOwnedRegularFile(file)
791
+ } catch {
792
+ continue
793
+ }
794
+ const target = opencodePluginTarget(source)
629
795
  if (target === null) continue
796
+ const problems = [
797
+ ...(!target.current
798
+ ? ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`']
799
+ : []),
800
+ ...(target.adapter !== hookAdapterPath(adapterHome)
801
+ ? [
802
+ 'OpenCode still names a mutable CLI or runtime path; rerun `notifai hooks install --harness opencode`',
803
+ ]
804
+ : []),
805
+ ]
630
806
  found.push({
631
807
  harness,
632
808
  file,
633
809
  global,
634
- ...(!target.current
635
- ? { problems: ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`'] }
636
- : {}),
810
+ ...(problems.length > 0 ? { problems } : {}),
637
811
  // Reported as the command line the plugin will actually run, not as
638
812
  // the plugin's own path: every check downstream asks which build a
639
813
  // handler invokes, and for a module that answer lives inside it.
@@ -641,7 +815,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
641
815
  event,
642
816
  groupIndex: 0,
643
817
  handlerIndex: 0,
644
- command: hookCommand(target.exec, target.script, hookEvent),
818
+ command: hookCommand(target.adapter, hookEvent, 'opencode'),
645
819
  ...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
646
820
  })),
647
821
  })
@@ -656,7 +830,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
656
830
  }
657
831
  const handlers = locateCursorHandlers(document)
658
832
  if (handlers.length > 0) {
659
- const problems = harnessMarkerProblems(harness, handlers)
833
+ const problems = harnessMarkerProblems(harness, handlers, adapterHome)
660
834
  found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
661
835
  }
662
836
  continue
@@ -669,7 +843,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
669
843
  }
670
844
  const handlers = locateHandlers(document)
671
845
  if (handlers.length > 0) {
672
- const problems = harnessMarkerProblems(harness, handlers)
846
+ const problems = harnessMarkerProblems(harness, handlers, adapterHome)
673
847
  found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
674
848
  }
675
849
  }
@@ -677,12 +851,24 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
677
851
  return found
678
852
  }
679
853
 
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
- ]
854
+ function harnessMarkerProblems(
855
+ harness: Harness,
856
+ handlers: InstalledHandler[],
857
+ adapterHome?: string,
858
+ ): string[] {
859
+ const problems: string[] = []
860
+ if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
861
+ problems.push(
862
+ `installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
863
+ )
864
+ }
865
+ const expected = `${quote(hookAdapterPath(adapterHome))} hook `
866
+ if (!handlers.every((handler) => handler.command.startsWith(expected))) {
867
+ problems.push(
868
+ 'installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter',
869
+ )
870
+ }
871
+ return problems
686
872
  }
687
873
 
688
874
  function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandler[] {
@@ -715,6 +901,8 @@ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
715
901
  handlerIndex,
716
902
  command: handler.command,
717
903
  ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
904
+ ...(handler.async === undefined ? {} : { async: handler.async }),
905
+ ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
718
906
  })
719
907
  }
720
908
  })
@@ -727,3 +915,15 @@ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
727
915
  export function handlerEvent(command: string): string | null {
728
916
  return / hook ([a-z-]+)/.exec(command)?.[1] ?? null
729
917
  }
918
+
919
+ function readOwnedRegularFile(file: string): string {
920
+ const stat = lstatSync(file)
921
+ if (stat.isSymbolicLink() || !stat.isFile()) {
922
+ throw new Error(`${file} is not a regular file; refusing to read it.`)
923
+ }
924
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
925
+ if (uid !== undefined && stat.uid !== uid) {
926
+ throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
927
+ }
928
+ return readFileSync(file, 'utf8')
929
+ }
@@ -593,17 +593,12 @@ 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 —\n' +
598
+ 'whether or not you are still at this machine.\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'),
602
- row(
603
- 'Only when away',
604
- config.require_idle.value ? 'yes' : 'no — reach me even while I am working',
605
- ),
606
- row('Away after', formatValue('away_after_seconds', config.away_after_seconds.value)),
607
602
  row('Grace', formatValue('ask_grace_seconds', config.ask_grace_seconds.value)),
608
603
  ].join('\n'),
609
604
  'Question routing',
@@ -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
+ }