@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.
- package/CHANGELOG.md +73 -0
- package/README.md +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +84 -12
- package/dist/hooks.js +433 -125
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +38 -7
- package/dist/send.js +97 -41
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- 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
|
-
|
|
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>', '
|
|
266
|
-
.
|
|
267
|
-
.option('--
|
|
268
|
-
.option('--
|
|
269
|
-
.option(
|
|
270
|
-
|
|
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
|
|
273
|
-
.option('--project <id>', 'project identifier
|
|
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
|
|
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(
|
|
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
|
|
298
|
-
.option('--level <level>', 'override
|
|
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
|
-
//
|
|
312
|
-
|
|
313
|
-
delete sendOpts[
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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['
|
|
368
|
+
sendOpts['body'] = readFileSync(bodyFile === '-' ? 0 : bodyFile, 'utf8')
|
|
327
369
|
} catch (err) {
|
|
328
|
-
deps.io.err(`Could not read ${
|
|
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
|
|
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
|
-
.
|
|
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
|
|
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
|
|
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('--
|
|
391
|
-
.option('--
|
|
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(
|
|
394
|
-
|
|
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
|
-
|
|
398
|
-
|
|
452
|
+
json?: boolean
|
|
453
|
+
body?: string
|
|
454
|
+
bodyFile?: string
|
|
399
455
|
form?: string
|
|
400
|
-
|
|
456
|
+
image?: string[]
|
|
457
|
+
imageAlt?: string[]
|
|
458
|
+
project?: string
|
|
459
|
+
sessionId?: string
|
|
460
|
+
sessionLabel?: string
|
|
401
461
|
}) => {
|
|
402
|
-
let
|
|
403
|
-
if (typeof opts.
|
|
404
|
-
if (
|
|
405
|
-
deps.io.err('Pass either --
|
|
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
|
-
|
|
469
|
+
body = readFileSync(opts.bodyFile === '-' ? 0 : opts.bodyFile, 'utf8')
|
|
410
470
|
} catch (err) {
|
|
411
|
-
deps.io.err(`Could not read ${opts.
|
|
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
|
-
//
|
|
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
|
-
...(
|
|
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')
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
303
|
-
|
|
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
|
+
})
|