@raidiant/notifai 4.0.0 → 5.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 (66) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +5 -0
  3. package/dist/commands.d.ts +10 -1
  4. package/dist/commands.js +174 -58
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +14 -5
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +11 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +72 -5
  17. package/dist/hooks.js +407 -79
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/logging.d.ts +8 -0
  25. package/dist/logging.js +8 -0
  26. package/dist/logging.js.map +1 -1
  27. package/dist/main.js +44 -17
  28. package/dist/main.js.map +1 -1
  29. package/dist/opencode-plugin.d.ts +8 -0
  30. package/dist/opencode-plugin.js +24 -162
  31. package/dist/opencode-plugin.js.map +1 -1
  32. package/dist/send.d.ts +6 -0
  33. package/dist/send.js +25 -14
  34. package/dist/send.js.map +1 -1
  35. package/dist/ui/help.d.ts +0 -1
  36. package/dist/ui/help.js +6 -3
  37. package/dist/ui/help.js.map +1 -1
  38. package/dist/ui/theme.d.ts +0 -15
  39. package/dist/ui/theme.js +2 -18
  40. package/dist/ui/theme.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/commands.test.ts +185 -51
  43. package/src/commands.ts +198 -56
  44. package/src/config-schema.test.ts +1 -1
  45. package/src/config-schema.ts +16 -5
  46. package/src/config.test.ts +41 -9
  47. package/src/config.ts +13 -0
  48. package/src/file-lock.test.ts +41 -0
  49. package/src/file-lock.ts +59 -9
  50. package/src/harnesses.test.ts +37 -29
  51. package/src/harnesses.ts +1 -4
  52. package/src/hooks.test.ts +358 -21
  53. package/src/hooks.ts +457 -97
  54. package/src/install-hooks.test.ts +232 -12
  55. package/src/install-hooks.ts +241 -37
  56. package/src/interactive.ts +1 -1
  57. package/src/logging-process-worker.test.ts +8 -2
  58. package/src/logging.test.ts +33 -18
  59. package/src/logging.ts +11 -0
  60. package/src/main.ts +57 -23
  61. package/src/opencode-plugin.ts +24 -164
  62. package/src/question-caps.test.ts +49 -0
  63. package/src/send.ts +32 -16
  64. package/src/skill.test.ts +199 -146
  65. package/src/ui/help.ts +8 -3
  66. package/src/ui/theme.ts +2 -16
@@ -132,7 +132,7 @@ export function routingHookActions(input: {
132
132
  }
133
133
 
134
134
  options.push(
135
- { value: 'settings', label: 'Change presence settings', hint: 'when a question may leave this terminal' },
135
+ { value: 'settings', label: 'Change question settings', hint: 'when a question may leave this terminal' },
136
136
  { value: 'back', label: '← Back' },
137
137
  )
138
138
  return options
@@ -19,7 +19,10 @@ if (worker === undefined) {
19
19
  const maxBytes = Number(process.env['NOTIFAI_ROTATION_MAX_BYTES'])
20
20
  const maxFiles = Number(process.env['NOTIFAI_ROTATION_MAX_FILES'])
21
21
  writeFileSync(readyPath, 'ready')
22
- const startDeadline = Date.now() + 10_000
22
+ // The parent's absolute deadline, not a countdown of this worker's own:
23
+ // the barrier fails for everyone at one instant, so a worker that started
24
+ // early cannot walk out on a parent still waiting for its last sibling.
25
+ const startDeadline = Number(process.env['NOTIFAI_ROTATION_DEADLINE'])
23
26
  while (!existsSync(startPath)) {
24
27
  if (Date.now() >= startDeadline) throw new Error('rotation parent never released the barrier')
25
28
  await new Promise((resolve) => setTimeout(resolve, 2))
@@ -33,7 +36,10 @@ if (worker === undefined) {
33
36
  logger.info('cli.end', { worker, sequence, pad: 'p'.repeat(140) })
34
37
  await new Promise((resolve) => setTimeout(resolve, 1))
35
38
  }
36
- expect(logger.enabled).toBe(true)
39
+ // The sink switches itself off for the rest of a process the first time a
40
+ // write throws, so this is the whole point of the worker: every record it
41
+ // was given reached the shared file through the cross-process lock.
42
+ expect(logger.enabled, 'the shared-log sink switched itself off mid-run').toBe(true)
37
43
  })
38
44
  })
39
45
  }
@@ -35,22 +35,30 @@ function lines(env: NodeJS.ProcessEnv): LogRecord[] {
35
35
  .map((line) => JSON.parse(line) as LogRecord)
36
36
  }
37
37
 
38
- async function waitUntil(predicate: () => boolean, timeoutMs = 10_000): Promise<void> {
39
- const deadline = Date.now() + timeoutMs
38
+ async function waitUntil(predicate: () => boolean, deadline: number): Promise<void> {
40
39
  while (!predicate()) {
41
40
  if (Date.now() >= deadline) throw new Error('timed out waiting for rotation workers')
42
41
  await new Promise((resolve) => setTimeout(resolve, 5))
43
42
  }
44
43
  }
45
44
 
46
- function rotationWorker(
47
- env: NodeJS.ProcessEnv,
48
- worker: number,
49
- records: number,
50
- maxBytes: number,
51
- maxFiles: number,
52
- startPath: string,
53
- ): { readyPath: string; done: Promise<void> } {
45
+ interface RotationRun {
46
+ env: NodeJS.ProcessEnv
47
+ worker: number
48
+ records: number
49
+ maxBytes: number
50
+ maxFiles: number
51
+ startPath: string
52
+ /**
53
+ * When the rendezvous has failed, as an absolute instant every participant
54
+ * reads the same way. A per-side countdown would let the first worker to
55
+ * report ready abandon a parent that is still waiting for the last one.
56
+ */
57
+ deadline: number
58
+ }
59
+
60
+ function rotationWorker(run: RotationRun): { readyPath: string; done: Promise<void> } {
61
+ const { env, worker } = run
54
62
  const readyPath = path.join(env['XDG_STATE_HOME']!, `rotation-ready-${worker}`)
55
63
  const vitest = path.join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs')
56
64
  const child = spawn(
@@ -62,11 +70,12 @@ function rotationWorker(
62
70
  ...process.env,
63
71
  ...env,
64
72
  NOTIFAI_ROTATION_WORKER: String(worker),
65
- NOTIFAI_ROTATION_RECORDS: String(records),
66
- NOTIFAI_ROTATION_MAX_BYTES: String(maxBytes),
67
- NOTIFAI_ROTATION_MAX_FILES: String(maxFiles),
73
+ NOTIFAI_ROTATION_RECORDS: String(run.records),
74
+ NOTIFAI_ROTATION_MAX_BYTES: String(run.maxBytes),
75
+ NOTIFAI_ROTATION_MAX_FILES: String(run.maxFiles),
68
76
  NOTIFAI_ROTATION_READY: readyPath,
69
- NOTIFAI_ROTATION_START: startPath,
77
+ NOTIFAI_ROTATION_START: run.startPath,
78
+ NOTIFAI_ROTATION_DEADLINE: String(run.deadline),
70
79
  },
71
80
  stdio: ['ignore', 'pipe', 'pipe'],
72
81
  },
@@ -263,11 +272,15 @@ describe('rotation', () => {
263
272
  const maxBytes = 4_000
264
273
  const maxFiles = 100
265
274
  const startPath = path.join(env['XDG_STATE_HOME']!, 'rotation-start')
275
+ // Four cold Vitest processes have to reach the barrier before any of them
276
+ // writes, and a busy CI host schedules them far apart. The budget is shared,
277
+ // so it bounds the whole rendezvous rather than each side of it.
278
+ const deadline = Date.now() + 20_000
266
279
  const children = Array.from({ length: workers }, (_, worker) =>
267
- rotationWorker(env, worker, recordsPerWorker, maxBytes, maxFiles, startPath),
280
+ rotationWorker({ env, worker, records: recordsPerWorker, maxBytes, maxFiles, startPath, deadline }),
268
281
  )
269
282
 
270
- await waitUntil(() => children.every(({ readyPath }) => existsSync(readyPath)))
283
+ await waitUntil(() => children.every(({ readyPath }) => existsSync(readyPath)), deadline)
271
284
  writeFileSync(startPath, 'go')
272
285
  await Promise.all(children.map(({ done }) => done))
273
286
 
@@ -293,8 +306,10 @@ describe('rotation', () => {
293
306
  // name race may lose or duplicate one.
294
307
  expect(retainedRecords).toBe(workers * recordsPerWorker)
295
308
  expect(seen.size).toBe(workers * recordsPerWorker)
296
- expect([...seen.values()].every((count) => count === 1)).toBe(true)
297
- })
309
+ // Name the offending records: a bare boolean would report a duplication bug
310
+ // as "expected false to be true" and leave the next reader nothing to go on.
311
+ expect([...seen].filter(([, count]) => count !== 1)).toEqual([])
312
+ }, 90_000)
298
313
  })
299
314
 
300
315
  describe('failure isolation', () => {
package/src/logging.ts CHANGED
@@ -61,6 +61,17 @@ export const LOG_SCHEMA_VERSION = 1
61
61
  export { LOG_LEVELS, type LogLevel }
62
62
  export type RecordLevel = Exclude<LogLevel, 'off'>
63
63
 
64
+ /**
65
+ * The levels a record can actually carry.
66
+ *
67
+ * `off` is a setting that stops writing, never a severity anything is written
68
+ * at — so offering it as a retrieval filter accepted a query that could only
69
+ * ever match nothing, and then blamed the result on too narrow a search.
70
+ */
71
+ export const RECORD_LEVELS: readonly RecordLevel[] = LOG_LEVELS.filter(
72
+ (level): level is RecordLevel => level !== 'off',
73
+ )
74
+
64
75
  const LEVEL_RANK: Record<LogLevel, number> = { off: 0, error: 1, info: 2, debug: 3 }
65
76
 
66
77
  /**
package/src/main.ts CHANGED
@@ -124,8 +124,6 @@ const program = new Command('notifai')
124
124
  // it. Without this, adding that default would silently break
125
125
  // `notifai help send` for everyone who reaches for it before `--help`.
126
126
  .helpCommand(true)
127
- // Where the implicit help command lands; every other command names its own.
128
- .commandsGroup(GROUP.help)
129
127
  .hook('preAction', (_program, actionCommand) => {
130
128
  logger.bind({ cmd: commandPath(actionCommand) })
131
129
  // SessionEnd uses the hook policy shared with commands.ts: local cleanup
@@ -156,7 +154,40 @@ const program = new Command('notifai')
156
154
  * interactive tree cost nothing to the paths that never draw them — `send`,
157
155
  * `ask`, and the hook that runs in front of every prompt the user types.
158
156
  */
159
- program.action(async () => {
157
+ /**
158
+ * Commander exits 1 for every usage error it detects itself, which contradicts
159
+ * the exit vocabulary this CLI documents and every hand-written usage error it
160
+ * returns. A caller branching on exit codes should not have to know which layer
161
+ * rejected it. Help and version stay successful.
162
+ */
163
+ program.exitOverride((err) => {
164
+ process.exit(err.exitCode === 0 ? 0 : 2)
165
+ })
166
+
167
+ /**
168
+ * An unrecognised subcommand reaches the root as an excess argument, and
169
+ * "expected 0 arguments but got 1" tells a caller nothing about what it typed
170
+ * or what exists. Naming it — with the nearest real command when there is one —
171
+ * turns a dead end into one more try.
172
+ */
173
+ program.usage('[options] [command]')
174
+ program.argument('[command]').action(async (command?: string) => {
175
+ if (command !== undefined) {
176
+ const names = program.commands.map((cmd) => cmd.name())
177
+ const near = names.filter(
178
+ (name) =>
179
+ name.startsWith(command.slice(0, 2)) ||
180
+ command.startsWith(name.slice(0, 2)) ||
181
+ name.includes(command) ||
182
+ command.includes(name),
183
+ )
184
+ deps.io.err(
185
+ `Unknown command "${command}".` +
186
+ (near.length > 0 ? ` Did you mean: ${near.slice(0, 3).join(', ')}?` : '') +
187
+ ' Run notifai --help for the full list.',
188
+ )
189
+ process.exit(2)
190
+ }
160
191
  if (deps.io.interactive !== true) {
161
192
  program.outputHelp()
162
193
  return
@@ -269,19 +300,17 @@ const send = program
269
300
  .option(
270
301
  '--image <path|url|media_id>',
271
302
  'upload or attach an image in collection order (repeatable, maximum 8)',
272
- (v: string, all: string[]) => [...all, v],
273
- [],
303
+ (v: string, all: string[] = []) => [...all, v],
274
304
  )
275
305
  .option(
276
306
  '--image-alt <text>',
277
307
  'alt text paired with --image occurrences by position (repeatable)',
278
- (v: string, all: string[]) => [...all, v],
279
- [],
308
+ (v: string, all: string[] = []) => [...all, v],
280
309
  )
281
310
  .optionsGroup(SEND_GROUP.routing)
282
- .option('--kind <kind>', 'what this is: update (default) | done | failed | blocked | question (requires --reply)')
311
+ .option('--kind <kind>', 'what this is (required): update | done | failed | blocked — question is set by --reply')
283
312
  .option('--project <id>', 'project identifier override (otherwise configured or inferred)')
284
- .option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[]) => [...all, v], [])
313
+ .option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
285
314
  .option('--all', 'target all routable devices (overrides configured devices)')
286
315
  .option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
287
316
  .option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
@@ -294,11 +323,15 @@ const send = program
294
323
  .optionsGroup(SEND_GROUP.reply)
295
324
  .option('--reply', 'enable the inline reply action and block for the answer')
296
325
  .option('--reply-timeout <seconds>', 'how long to wait for a reply (default: 900)', (v: string) => Number(v))
297
- .option('--reply-window <seconds>', 'how long the server accepts a reply (default: 3600)', (v: string) => Number(v))
326
+ .option(
327
+ '--reply-window <seconds>',
328
+ 'how long an answer is still accepted, 60-259200 (default: reply_window_seconds, a day)',
329
+ (v: string) => Number(v),
330
+ )
298
331
  .option(
299
332
  '--reply-choice <label>',
300
333
  'with --reply, ask a closed question; repeat the flag once per answer (2-6)',
301
- (v: string, all: string[]) => [...all, v], [],
334
+ (v: string, all: string[] = []) => [...all, v],
302
335
  )
303
336
  .option('--reply-multi', 'with --reply-choice, several answers may be selected')
304
337
  // Kept registered, and always a usage error with --reply, so the caller gets
@@ -309,7 +342,7 @@ const send = program
309
342
  .option('--level <level>', 'override saved interruption level: passive | active | time_sensitive')
310
343
  .option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
311
344
  .option('--no-wait', 'return immediately after acceptance')
312
- .option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
345
+ .option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[] = []) => [...all, v])
313
346
  .option('--idempotency-key <key>', 'safe-retry key (default: random)')
314
347
  .option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
315
348
  .option('--json', 'print the full submission receipt as JSON')
@@ -346,8 +379,8 @@ const send = program
346
379
 
347
380
  send.addHelpText(
348
381
  'after',
349
- `\nKind describes status in the Companion Apps; it never chooses banner sound or interruption level.\nWithout --sound/--level or saved preferences, those fields are omitted for the destination to handle.\n`,
350
- )
382
+ `\nKind is required, and it selects the sound the notification arrives with: done rings the completion chime, failed the most insistent tone, blocked and question a distinct attention tone, update the standard one.\nAn explicit --sound or --level, and the user's saved preference, both outrank that default.\n`,
383
+ )
351
384
 
352
385
  program
353
386
  .command('replies [request_id]')
@@ -369,9 +402,9 @@ program
369
402
  .description(
370
403
  'Record the required Agent Acknowledgement for a replied-to notification request; never prompts',
371
404
  )
372
- .requiredOption('--text <text>', 'concrete work you will do because of the reply')
405
+ .option('--text <text>', 'concrete work you will do because of the reply')
373
406
  .option('--json', 'machine-readable output')
374
- .action(async (requestId: string, opts: { text: string; json?: boolean }) => {
407
+ .action(async (requestId: string, opts: { text?: string; json?: boolean }) => {
375
408
  process.exit(await acknowledgeCommand(deps, requestId, opts))
376
409
  })
377
410
 
@@ -389,27 +422,26 @@ program
389
422
  .command('ask [question]')
390
423
  .helpGroup(GROUP.agent)
391
424
  .summary('Register a question, then end the turn')
392
- .description('Register a question for the turn-end hook to route under your presence settings')
425
+ .description('Register a question for the turn-end hook to route to your devices, subject to your question-routing settings')
393
426
  .option(
394
427
  '--choice <label>',
395
428
  'answers to offer instead of free text; repeat the flag once per answer (2-6)',
396
- (v: string, all: string[]) => [...all, v], [],
429
+ (v: string, all: string[] = []) => [...all, v],
397
430
  )
398
431
  .option('--multi', 'with --choice, several answers may be selected')
432
+ .option('--json', 'machine-readable output')
399
433
  .option('--body <markdown>', 'optional Markdown context appended after the question block')
400
434
  .option('--body-file <path>', 'read optional Markdown context from a file (use - for stdin)')
401
435
  .option('--form <path>', 'ask several questions as one form; JSON file (use - for stdin)')
402
436
  .option(
403
437
  '--image <path|url|media_id>',
404
438
  'upload or attach an image in collection order (repeatable, maximum 8)',
405
- (v: string, all: string[]) => [...all, v],
406
- [],
439
+ (v: string, all: string[] = []) => [...all, v],
407
440
  )
408
441
  .option(
409
442
  '--image-alt <text>',
410
443
  'alt text paired with --image occurrences by position (repeatable)',
411
- (v: string, all: string[]) => [...all, v],
412
- [],
444
+ (v: string, all: string[] = []) => [...all, v],
413
445
  )
414
446
  .option('--project <id>', 'project identifier override (otherwise configured or inferred)')
415
447
  .option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
@@ -417,6 +449,7 @@ program
417
449
  .action(async (question: string | undefined, opts: {
418
450
  choice?: string[]
419
451
  multi?: boolean
452
+ json?: boolean
420
453
  body?: string
421
454
  bodyFile?: string
422
455
  form?: string
@@ -459,6 +492,7 @@ program
459
492
  ...(opts.project !== undefined ? { project: opts.project } : {}),
460
493
  ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
461
494
  ...(opts.sessionLabel !== undefined ? { sessionLabel: opts.sessionLabel } : {}),
495
+ ...(opts.json === true ? { json: true } : {}),
462
496
  }
463
497
  process.exit(await askCommand(deps, question, flags))
464
498
  })
@@ -528,7 +562,7 @@ program
528
562
  .option('--all', 'lift the default limit, up to a hard cap')
529
563
  .option('--since <when>', 'only records newer than this: 10m, 2h, 1d, or an ISO 8601 instant')
530
564
  .option('--level <level>', 'minimum severity: error | info | debug (error shows only failures)')
531
- .option('--event <name>', 'only this event; repeatable', (v: string, all: string[]) => [...all, v], [])
565
+ .option('--event <name>', 'only this event; repeatable', (v: string, all: string[] = []) => [...all, v])
532
566
  .option('--run <id>', 'everything one command invocation did')
533
567
  .option('--request <id>', 'everything about one notification request')
534
568
  .option('--session <id>', 'only this harness session')
@@ -31,7 +31,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
31
31
  export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
32
32
 
33
33
  /** Bump when an installed generated file must be rewritten to remain functional. */
34
- const OPENCODE_ADAPTER_VERSION = 5
34
+ const OPENCODE_ADAPTER_VERSION = 6
35
35
 
36
36
  export function opencodePluginDir(
37
37
  global: boolean,
@@ -75,150 +75,32 @@ export interface OpencodePluginOptions {
75
75
  * hand that to the CLI on stdin. It deliberately fails open: an adapter error
76
76
  * never changes OpenCode's own behavior.
77
77
  */
78
- export function opencodePluginSource(options: OpencodePluginOptions): string {
79
- if (hookHostPlatform(options.platform) === 'win32') {
80
- return win32OpencodePluginSource(options)
81
- }
82
- return posixOpencodePluginSource(options)
83
- }
84
-
85
- function posixOpencodePluginSource(options: OpencodePluginOptions): string {
86
- const { adapterPath, timeoutSeconds } = options
87
- return `${OPENCODE_PLUGIN_MARKER}
88
- // Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
89
- // the next install; change the CLI instead, which is where the logic lives.
90
- import { spawn } from "node:child_process"
91
-
92
- const ADAPTER = ${JSON.stringify(adapterPath)}
93
- const TIMEOUT_MS = ${timeoutSeconds * 1000}
94
- const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
95
-
96
78
  /**
97
- * Runs \`notifai hook <event>\`, feeding it the same JSON envelope the other
98
- * harnesses send. Resolves to stdout, or null if anything at all went wrong.
79
+ * The plugin body, which is the same on every platform except for how it
80
+ * reaches the adapter.
81
+ *
82
+ * It was two near-identical copies — about a hundred and twenty lines each,
83
+ * differing only in the spawn call — so every fix to the plugin had to be made
84
+ * twice or silently apply to one platform.
99
85
  */
100
- function runHook(event, envelope) {
101
- return new Promise((resolve) => {
102
- let child
103
- try {
104
- child = spawn(ADAPTER, ["hook", event, "--owner", "notifai", "--harness", "opencode"], {
105
- env: process.env,
106
- shell: false,
107
- stdio: ["pipe", "pipe", "pipe"],
108
- })
109
- } catch {
110
- resolve(null)
111
- return
112
- }
113
-
114
- let stdout = ""
115
- let settled = false
116
- const finish = (value) => {
117
- if (settled) return
118
- settled = true
119
- clearTimeout(timer)
120
- resolve(value)
121
- }
122
- const timer = setTimeout(() => {
123
- child.kill("SIGTERM")
124
- finish(null)
125
- }, TIMEOUT_MS)
126
-
127
- child.stdout?.setEncoding("utf-8")
128
- child.stdout?.on("data", (chunk) => {
129
- stdout += chunk
130
- })
131
- // Hook stderr is diagnostics, not plugin output; discard it here.
132
- child.stderr?.resume()
133
- child.on("error", () => finish(null))
134
- child.on("close", () => finish(stdout))
135
-
136
- try {
137
- child.stdin?.end(JSON.stringify(envelope))
138
- } catch {
139
- finish(null)
140
- }
141
- })
142
- }
143
-
144
- function additionalContext(stdout) {
145
- if (typeof stdout !== "string" || stdout.trim() === "") return null
146
- try {
147
- const parsed = JSON.parse(stdout)
148
- const context = parsed?.hookSpecificOutput?.additionalContext
149
- return typeof context === "string" && context.length > 0 ? context : null
150
- } catch {
151
- return null
152
- }
153
- }
154
-
155
- export const NotifAIPlugin = async ({ directory }) => {
156
- const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
157
-
158
- return {
159
- /** Exact active-harness identity for shell commands such as \`notifai ask\`. */
160
- "shell.env": async (input, output) => {
161
- output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
162
- const sessionID = input?.sessionID
163
- if (typeof sessionID === "string" && sessionID.length > 0) {
164
- output.env.NOTIFAI_ACTIVE_SESSION_ID = sessionID
165
- }
166
- },
167
-
168
- /**
169
- * Presence. The user typing is the signal every harness shares, and it is
170
- * also what publishes the project -> session pointer \`notifai ask\` reads.
171
- */
172
- "chat.message": async (input, output) => {
173
- const stdout = await runHook("user-prompt-submit", {
174
- session_id: input?.sessionID ?? input?.info?.sessionID,
175
- cwd,
176
- hook_event_name: "UserPromptSubmit",
177
- })
178
- const context = additionalContext(stdout)
179
- if (context !== null) {
180
- output.parts.push({
181
- id: "prt_notifai_" + Date.now(),
182
- sessionID: input.sessionID,
183
- messageID: output.message.id,
184
- type: "text",
185
- text: context,
186
- synthetic: true,
187
- })
188
- }
189
- },
190
-
191
- /** Session lifecycle events are delivered through OpenCode's event bus. */
192
- event: async ({ event }) => {
193
- if (event?.type === "session.idle") {
194
- await runHook("stop", {
195
- session_id: event?.properties?.sessionID,
196
- cwd,
197
- hook_event_name: "Stop",
198
- })
199
- } else if (event?.type === "session.deleted") {
200
- await runHook("session-end", {
201
- session_id: event?.properties?.info?.id,
202
- cwd,
203
- hook_event_name: "SessionEnd",
204
- })
205
- }
206
- },
207
- }
208
- }
209
- `
210
- }
211
-
212
- function win32OpencodePluginSource(options: OpencodePluginOptions): string {
86
+ export function opencodePluginSource(options: OpencodePluginOptions): string {
213
87
  const { adapterPath, timeoutSeconds } = options
214
- const nodePath = options.nodePath ?? process.execPath
88
+ // Windows cannot execute the adapter script directly, so it goes through the
89
+ // registered Node executable and the console window is suppressed.
90
+ const win32 = hookHostPlatform(options.platform) === 'win32'
91
+ const nodeConstant = win32
92
+ ? `const NODE = ${JSON.stringify(options.nodePath ?? process.execPath)}\n`
93
+ : ''
94
+ const spawnArguments = win32
95
+ ? 'NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"]'
96
+ : 'ADAPTER, ["hook", event, "--owner", "notifai", "--harness", "opencode"]'
97
+ const windowsHide = win32 ? '\n windowsHide: true,' : ''
215
98
  return `${OPENCODE_PLUGIN_MARKER}
216
99
  // Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
217
100
  // the next install; change the CLI instead, which is where the logic lives.
218
101
  import { spawn } from "node:child_process"
219
102
 
220
- const NODE = ${JSON.stringify(nodePath)}
221
- const ADAPTER = ${JSON.stringify(adapterPath)}
103
+ ${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
222
104
  const TIMEOUT_MS = ${timeoutSeconds * 1000}
223
105
  const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
224
106
 
@@ -230,10 +112,9 @@ function runHook(event, envelope) {
230
112
  return new Promise((resolve) => {
231
113
  let child
232
114
  try {
233
- child = spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"], {
115
+ child = spawn(${spawnArguments}, {
234
116
  env: process.env,
235
- shell: false,
236
- windowsHide: true,
117
+ shell: false,${windowsHide}
237
118
  stdio: ["pipe", "pipe", "pipe"],
238
119
  })
239
120
  } catch {
@@ -271,17 +152,6 @@ function runHook(event, envelope) {
271
152
  })
272
153
  }
273
154
 
274
- function additionalContext(stdout) {
275
- if (typeof stdout !== "string" || stdout.trim() === "") return null
276
- try {
277
- const parsed = JSON.parse(stdout)
278
- const context = parsed?.hookSpecificOutput?.additionalContext
279
- return typeof context === "string" && context.length > 0 ? context : null
280
- } catch {
281
- return null
282
- }
283
- }
284
-
285
155
  export const NotifAIPlugin = async ({ directory }) => {
286
156
  const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
287
157
 
@@ -299,23 +169,13 @@ export const NotifAIPlugin = async ({ directory }) => {
299
169
  * Presence. The user typing is the signal every harness shares, and it is
300
170
  * also what publishes the project -> session pointer \`notifai ask\` reads.
301
171
  */
302
- "chat.message": async (input, output) => {
303
- const stdout = await runHook("user-prompt-submit", {
172
+ /** Publishes the session pointer; the CLI returns no content to inject. */
173
+ "chat.message": async (input) => {
174
+ await runHook("user-prompt-submit", {
304
175
  session_id: input?.sessionID ?? input?.info?.sessionID,
305
176
  cwd,
306
177
  hook_event_name: "UserPromptSubmit",
307
178
  })
308
- const context = additionalContext(stdout)
309
- if (context !== null) {
310
- output.parts.push({
311
- id: "prt_notifai_" + Date.now(),
312
- sessionID: input.sessionID,
313
- messageID: output.message.id,
314
- type: "text",
315
- text: context,
316
- synthetic: true,
317
- })
318
- }
319
179
  },
320
180
 
321
181
  /** Session lifecycle events are delivered through OpenCode's event bus. */
@@ -0,0 +1,49 @@
1
+ import { mkdtempSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { MAX_LIVE_QUESTIONS, MAX_PENDING_QUESTIONS, registerQuestion } from './hooks.js'
6
+
7
+ /**
8
+ * `registerQuestion` decides how many questions one session may hold, and these
9
+ * exercise it directly against a bare state directory — no harness, no server,
10
+ * no clock. They live apart from `hooks.test.ts` because nothing here needs
11
+ * what that file spends its setup on.
12
+ */
13
+ const state = (): NodeJS.ProcessEnv => {
14
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-caps-'))
15
+ return { HOME: dir, XDG_STATE_HOME: path.join(dir, 'state') } as NodeJS.ProcessEnv
16
+ }
17
+
18
+ describe('how many questions one session may hold', () => {
19
+ it('stops an agent registering a fifth question nobody has asked yet', () => {
20
+ const env = state()
21
+ for (let i = 0; i < MAX_PENDING_QUESTIONS; i += 1) {
22
+ registerQuestion('loop', env, { question: `Q${i}?` })
23
+ }
24
+ expect(() => registerQuestion('loop', env, { question: 'One more?' })).toThrow(
25
+ /already waiting to be asked/,
26
+ )
27
+ })
28
+
29
+ it('does not count a question the user simply has not answered yet', () => {
30
+ // An answer stays accepted for a day. Counting questions already on the
31
+ // user's devices against the loop guard would let a patient user stop the
32
+ // agent from asking anything at all.
33
+ const env = state()
34
+ for (let i = 0; i < MAX_PENDING_QUESTIONS; i += 1) {
35
+ registerQuestion('live', env, { question: `Asked ${i}?`, request_id: `req_${i}` })
36
+ }
37
+ expect(() => registerQuestion('live', env, { question: 'A new one?' })).not.toThrow()
38
+ })
39
+
40
+ it('still bounds what one session can put on the lock screen', () => {
41
+ const env = state()
42
+ for (let i = 0; i < MAX_LIVE_QUESTIONS; i += 1) {
43
+ registerQuestion('flood', env, { question: `Asked ${i}?`, request_id: `req_${i}` })
44
+ }
45
+ expect(() => registerQuestion('flood', env, { question: 'One too many?' })).toThrow(
46
+ /already open/,
47
+ )
48
+ })
49
+ })