@raidiant/notifai 3.0.1 → 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 (71) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +12 -4
  3. package/dist/commands.d.ts +27 -10
  4. package/dist/commands.js +429 -152
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +20 -11
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +12 -1
  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 +84 -12
  17. package/dist/hooks.js +433 -125
  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/invocation-context.d.ts +42 -0
  25. package/dist/invocation-context.js +130 -0
  26. package/dist/invocation-context.js.map +1 -0
  27. package/dist/logging.d.ts +8 -0
  28. package/dist/logging.js +8 -0
  29. package/dist/logging.js.map +1 -1
  30. package/dist/main.js +84 -49
  31. package/dist/main.js.map +1 -1
  32. package/dist/opencode-plugin.d.ts +8 -0
  33. package/dist/opencode-plugin.js +24 -162
  34. package/dist/opencode-plugin.js.map +1 -1
  35. package/dist/send.d.ts +38 -7
  36. package/dist/send.js +97 -41
  37. package/dist/send.js.map +1 -1
  38. package/dist/ui/help.d.ts +0 -1
  39. package/dist/ui/help.js +6 -3
  40. package/dist/ui/help.js.map +1 -1
  41. package/dist/ui/theme.d.ts +0 -15
  42. package/dist/ui/theme.js +2 -18
  43. package/dist/ui/theme.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/commands.test.ts +501 -63
  46. package/src/commands.ts +547 -187
  47. package/src/config-schema.test.ts +1 -1
  48. package/src/config-schema.ts +22 -11
  49. package/src/config.test.ts +178 -18
  50. package/src/config.ts +14 -1
  51. package/src/file-lock.test.ts +41 -0
  52. package/src/file-lock.ts +59 -9
  53. package/src/harnesses.test.ts +37 -29
  54. package/src/harnesses.ts +1 -4
  55. package/src/hooks.test.ts +500 -58
  56. package/src/hooks.ts +523 -169
  57. package/src/install-hooks.test.ts +232 -12
  58. package/src/install-hooks.ts +241 -37
  59. package/src/interactive.ts +1 -1
  60. package/src/invocation-context.test.ts +253 -0
  61. package/src/invocation-context.ts +176 -0
  62. package/src/logging-process-worker.test.ts +8 -2
  63. package/src/logging.test.ts +33 -18
  64. package/src/logging.ts +11 -0
  65. package/src/main.ts +119 -54
  66. package/src/opencode-plugin.ts +24 -164
  67. package/src/question-caps.test.ts +49 -0
  68. package/src/send.ts +144 -50
  69. package/src/skill.test.ts +197 -120
  70. package/src/ui/help.ts +8 -3
  71. package/src/ui/theme.ts +2 -16
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
@@ -262,18 +293,27 @@ const send = program
262
293
  .summary('Send a notification')
263
294
  .description('Send a notification')
264
295
  .optionsGroup(SEND_GROUP.content)
265
- .requiredOption('--title <title>', 'notification title')
266
- .requiredOption('--body <body>', 'notification body')
267
- .option('--subtitle <subtitle>', 'a line between the title and the body')
268
- .option('--detail <markdown>', 'long-form markdown shown only in the app, never on the banner')
269
- .option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
270
- .option('--image <path|url|media_id>', 'upload or attach an image')
296
+ .requiredOption('--title <title>', 'brief title whose substance is immediately understandable')
297
+ .option('--body <markdown>', 'canonical Markdown body')
298
+ .option('--body-file <path>', 'read the canonical Markdown body from a file (use - for stdin)')
299
+ .option('--subtitle <subtitle>', 'a plain-text line between the title and body')
300
+ .option(
301
+ '--image <path|url|media_id>',
302
+ 'upload or attach an image in collection order (repeatable, maximum 8)',
303
+ (v: string, all: string[] = []) => [...all, v],
304
+ )
305
+ .option(
306
+ '--image-alt <text>',
307
+ 'alt text paired with --image occurrences by position (repeatable)',
308
+ (v: string, all: string[] = []) => [...all, v],
309
+ )
271
310
  .optionsGroup(SEND_GROUP.routing)
272
- .option('--kind <kind>', 'what this is: update (default) | done | question (requires --reply)')
273
- .option('--project <id>', 'project identifier, e.g. my-app (lazily registered)')
274
- .option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[]) => [...all, v], [])
311
+ .option('--kind <kind>', 'what this is (required): update | done | failed | blocked — question is set by --reply')
312
+ .option('--project <id>', 'project identifier override (otherwise configured or inferred)')
313
+ .option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
275
314
  .option('--all', 'target all routable devices (overrides configured devices)')
276
- .option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
315
+ .option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
316
+ .option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
277
317
  .option('--event <event>', 'agent event name, e.g. tests_passed')
278
318
  .optionsGroup(SEND_GROUP.presentation)
279
319
  .option('--collapse-key <key>', 'replace earlier notifications with the same key')
@@ -283,22 +323,26 @@ const send = program
283
323
  .optionsGroup(SEND_GROUP.reply)
284
324
  .option('--reply', 'enable the inline reply action and block for the answer')
285
325
  .option('--reply-timeout <seconds>', 'how long to wait for a reply (default: 900)', (v: string) => Number(v))
286
- .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
+ )
287
331
  .option(
288
332
  '--reply-choice <label>',
289
333
  'with --reply, ask a closed question; repeat the flag once per answer (2-6)',
290
- (v: string, all: string[]) => [...all, v], [],
334
+ (v: string, all: string[] = []) => [...all, v],
291
335
  )
292
336
  .option('--reply-multi', 'with --reply-choice, several answers may be selected')
293
337
  // Kept registered, and always a usage error with --reply, so the caller gets
294
338
  // a message pointing at `ask` instead of "unknown option".
295
339
  .option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
296
340
  .optionsGroup(SEND_GROUP.advanced)
297
- .option('--sound <sound>', 'override kind profile / saved config: default | done | attention | alert | none')
298
- .option('--level <level>', 'override kind profile / saved config: passive | active | time_sensitive')
341
+ .option('--sound <sound>', 'override saved sound: default | done | attention | alert | none')
342
+ .option('--level <level>', 'override saved interruption level: passive | active | time_sensitive')
299
343
  .option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
300
344
  .option('--no-wait', 'return immediately after acceptance')
301
- .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])
302
346
  .option('--idempotency-key <key>', 'safe-retry key (default: random)')
303
347
  .option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
304
348
  .option('--json', 'print the full submission receipt as JSON')
@@ -308,24 +352,22 @@ const send = program
308
352
  const wait = typeof opts['wait'] === 'number' ? opts['wait'] : undefined
309
353
  const noBlock = opts['block'] === false
310
354
  const sendOpts = { ...opts }
311
- // Same empty-collector normalisation as `ask`.
312
- if (Array.isArray(sendOpts['replyChoice']) && sendOpts['replyChoice'].length === 0) {
313
- delete sendOpts['replyChoice']
355
+ // Commander collectors default to []; an empty list means "not passed".
356
+ for (const key of ['replyChoice', 'image', 'imageAlt']) {
357
+ if (Array.isArray(sendOpts[key]) && sendOpts[key].length === 0) delete sendOpts[key]
314
358
  }
315
359
  delete sendOpts['block']
316
- // Long-form detail is usually a build log or a diff summary, which nobody
317
- // wants to shell-escape onto a command line.
318
- const detailFile = sendOpts['detailFile']
319
- delete sendOpts['detailFile']
320
- if (typeof detailFile === 'string') {
321
- if (sendOpts['detail'] !== undefined) {
322
- deps.io.err('Pass either --detail or --detail-file, not both.')
360
+ const bodyFile = sendOpts['bodyFile']
361
+ delete sendOpts['bodyFile']
362
+ if (typeof bodyFile === 'string') {
363
+ if (sendOpts['body'] !== undefined) {
364
+ deps.io.err('Pass either --body or --body-file, not both.')
323
365
  process.exit(2)
324
366
  }
325
367
  try {
326
- sendOpts['detail'] = readFileSync(detailFile === '-' ? 0 : detailFile, 'utf8')
368
+ sendOpts['body'] = readFileSync(bodyFile === '-' ? 0 : bodyFile, 'utf8')
327
369
  } catch (err) {
328
- deps.io.err(`Could not read ${detailFile}: ${String(err)}`)
370
+ deps.io.err(`Could not read ${bodyFile}: ${String(err)}`)
329
371
  process.exit(2)
330
372
  }
331
373
  }
@@ -337,8 +379,8 @@ const send = program
337
379
 
338
380
  send.addHelpText(
339
381
  'after',
340
- `\nKind profiles apply automatically. Pass --sound or --level only to override them or saved user config:\n update sound none level passive\n done sound done level passive\n question sound attention level active (--reply)\n`,
341
- )
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
+ )
342
384
 
343
385
  program
344
386
  .command('replies [request_id]')
@@ -360,9 +402,9 @@ program
360
402
  .description(
361
403
  'Record the required Agent Acknowledgement for a replied-to notification request; never prompts',
362
404
  )
363
- .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')
364
406
  .option('--json', 'machine-readable output')
365
- .action(async (requestId: string, opts: { text: string; json?: boolean }) => {
407
+ .action(async (requestId: string, opts: { text?: string; json?: boolean }) => {
366
408
  process.exit(await acknowledgeCommand(deps, requestId, opts))
367
409
  })
368
410
 
@@ -380,35 +422,53 @@ program
380
422
  .command('ask [question]')
381
423
  .helpGroup(GROUP.agent)
382
424
  .summary('Register a question, then end the turn')
383
- .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')
384
426
  .option(
385
427
  '--choice <label>',
386
428
  'answers to offer instead of free text; repeat the flag once per answer (2-6)',
387
- (v: string, all: string[]) => [...all, v], [],
429
+ (v: string, all: string[] = []) => [...all, v],
388
430
  )
389
431
  .option('--multi', 'with --choice, several answers may be selected')
390
- .option('--detail <markdown>', 'long-form context shown only in the app, never on the banner')
391
- .option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
432
+ .option('--json', 'machine-readable output')
433
+ .option('--body <markdown>', 'optional Markdown context appended after the question block')
434
+ .option('--body-file <path>', 'read optional Markdown context from a file (use - for stdin)')
392
435
  .option('--form <path>', 'ask several questions as one form; JSON file (use - for stdin)')
393
- .option('--session <id>', 'session id (default: the session working in this directory, else $NOTIFAI_SESSION)')
394
- .action((question: string | undefined, opts: {
436
+ .option(
437
+ '--image <path|url|media_id>',
438
+ 'upload or attach an image in collection order (repeatable, maximum 8)',
439
+ (v: string, all: string[] = []) => [...all, v],
440
+ )
441
+ .option(
442
+ '--image-alt <text>',
443
+ 'alt text paired with --image occurrences by position (repeatable)',
444
+ (v: string, all: string[] = []) => [...all, v],
445
+ )
446
+ .option('--project <id>', 'project identifier override (otherwise configured or inferred)')
447
+ .option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
448
+ .option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
449
+ .action(async (question: string | undefined, opts: {
395
450
  choice?: string[]
396
451
  multi?: boolean
397
- detail?: string
398
- detailFile?: string
452
+ json?: boolean
453
+ body?: string
454
+ bodyFile?: string
399
455
  form?: string
400
- session?: string
456
+ image?: string[]
457
+ imageAlt?: string[]
458
+ project?: string
459
+ sessionId?: string
460
+ sessionLabel?: string
401
461
  }) => {
402
- let detail = opts.detail
403
- if (typeof opts.detailFile === 'string') {
404
- if (detail !== undefined) {
405
- deps.io.err('Pass either --detail or --detail-file, not both.')
462
+ let body = opts.body
463
+ if (typeof opts.bodyFile === 'string') {
464
+ if (body !== undefined) {
465
+ deps.io.err('Pass either --body or --body-file, not both.')
406
466
  process.exit(2)
407
467
  }
408
468
  try {
409
- detail = readFileSync(opts.detailFile === '-' ? 0 : opts.detailFile, 'utf8')
469
+ body = readFileSync(opts.bodyFile === '-' ? 0 : opts.bodyFile, 'utf8')
410
470
  } catch (err) {
411
- deps.io.err(`Could not read ${opts.detailFile}: ${String(err)}`)
471
+ deps.io.err(`Could not read ${opts.bodyFile}: ${String(err)}`)
412
472
  process.exit(2)
413
473
  }
414
474
  }
@@ -421,15 +481,20 @@ program
421
481
  process.exit(2)
422
482
  }
423
483
  }
424
- // commander's collector defaults to []; an empty list means "not asked".
484
+ // Commander collectors default to []; an empty list means "not passed".
425
485
  const flags: Parameters<typeof askCommand>[2] = {
426
- ...(opts.session !== undefined ? { session: opts.session } : {}),
427
486
  ...(opts.choice?.length ? { choice: opts.choice } : {}),
428
487
  ...(opts.multi ? { multi: true } : {}),
429
- ...(detail !== undefined ? { detail } : {}),
488
+ ...(body !== undefined ? { body } : {}),
430
489
  ...(form !== undefined ? { form } : {}),
490
+ ...(opts.image?.length ? { image: opts.image } : {}),
491
+ ...(opts.imageAlt?.length ? { imageAlt: opts.imageAlt } : {}),
492
+ ...(opts.project !== undefined ? { project: opts.project } : {}),
493
+ ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
494
+ ...(opts.sessionLabel !== undefined ? { sessionLabel: opts.sessionLabel } : {}),
495
+ ...(opts.json === true ? { json: true } : {}),
431
496
  }
432
- process.exit(askCommand(deps, question, flags))
497
+ process.exit(await askCommand(deps, question, flags))
433
498
  })
434
499
 
435
500
  program
@@ -497,7 +562,7 @@ program
497
562
  .option('--all', 'lift the default limit, up to a hard cap')
498
563
  .option('--since <when>', 'only records newer than this: 10m, 2h, 1d, or an ISO 8601 instant')
499
564
  .option('--level <level>', 'minimum severity: error | info | debug (error shows only failures)')
500
- .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])
501
566
  .option('--run <id>', 'everything one command invocation did')
502
567
  .option('--request <id>', 'everything about one notification request')
503
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
+ })