@raidiant/notifai 4.0.0 → 6.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 (140) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +7 -2
  3. package/dist/client.d.ts +8 -3
  4. package/dist/client.js +24 -3
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands-auth.d.ts +14 -0
  7. package/dist/commands-auth.js +154 -0
  8. package/dist/commands-auth.js.map +1 -0
  9. package/dist/commands-config.d.ts +31 -0
  10. package/dist/commands-config.js +246 -0
  11. package/dist/commands-config.js.map +1 -0
  12. package/dist/commands-core.d.ts +102 -0
  13. package/dist/commands-core.js +135 -0
  14. package/dist/commands-core.js.map +1 -0
  15. package/dist/commands-devices.d.ts +31 -0
  16. package/dist/commands-devices.js +124 -0
  17. package/dist/commands-devices.js.map +1 -0
  18. package/dist/commands-doctor.d.ts +24 -0
  19. package/dist/commands-doctor.js +1011 -0
  20. package/dist/commands-doctor.js.map +1 -0
  21. package/dist/commands-harness-context.d.ts +61 -0
  22. package/dist/commands-harness-context.js +93 -0
  23. package/dist/commands-harness-context.js.map +1 -0
  24. package/dist/commands-hook-shape.d.ts +17 -0
  25. package/dist/commands-hook-shape.js +37 -0
  26. package/dist/commands-hook-shape.js.map +1 -0
  27. package/dist/commands-hooks.d.ts +115 -0
  28. package/dist/commands-hooks.js +1225 -0
  29. package/dist/commands-hooks.js.map +1 -0
  30. package/dist/commands-init.d.ts +35 -0
  31. package/dist/commands-init.js +580 -0
  32. package/dist/commands-init.js.map +1 -0
  33. package/dist/commands-io.d.ts +2 -0
  34. package/dist/commands-io.js +110 -0
  35. package/dist/commands-io.js.map +1 -0
  36. package/dist/commands-logs.d.ts +30 -0
  37. package/dist/commands-logs.js +201 -0
  38. package/dist/commands-logs.js.map +1 -0
  39. package/dist/commands-send-support.d.ts +44 -0
  40. package/dist/commands-send-support.js +180 -0
  41. package/dist/commands-send-support.js.map +1 -0
  42. package/dist/commands-send.d.ts +33 -0
  43. package/dist/commands-send.js +519 -0
  44. package/dist/commands-send.js.map +1 -0
  45. package/dist/commands-setup-proof.d.ts +15 -0
  46. package/dist/commands-setup-proof.js +53 -0
  47. package/dist/commands-setup-proof.js.map +1 -0
  48. package/dist/commands-skill.d.ts +10 -0
  49. package/dist/commands-skill.js +71 -0
  50. package/dist/commands-skill.js.map +1 -0
  51. package/dist/commands.d.ts +12 -378
  52. package/dist/commands.js +12 -4420
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +15 -6
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +4 -2
  57. package/dist/config.js +13 -0
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.js +13 -3
  60. package/dist/credentials.js.map +1 -1
  61. package/dist/file-lock.d.ts +6 -1
  62. package/dist/file-lock.js +55 -9
  63. package/dist/file-lock.js.map +1 -1
  64. package/dist/harnesses.d.ts +1 -1
  65. package/dist/harnesses.js.map +1 -1
  66. package/dist/hooks.d.ts +74 -7
  67. package/dist/hooks.js +522 -166
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/install-hooks.d.ts +91 -14
  70. package/dist/install-hooks.js +226 -34
  71. package/dist/install-hooks.js.map +1 -1
  72. package/dist/interactive.d.ts +1 -1
  73. package/dist/interactive.js +5 -5
  74. package/dist/interactive.js.map +1 -1
  75. package/dist/logging.d.ts +8 -0
  76. package/dist/logging.js +9 -0
  77. package/dist/logging.js.map +1 -1
  78. package/dist/main.js +45 -18
  79. package/dist/main.js.map +1 -1
  80. package/dist/opencode-plugin.d.ts +8 -0
  81. package/dist/opencode-plugin.js +24 -162
  82. package/dist/opencode-plugin.js.map +1 -1
  83. package/dist/readiness.d.ts +3 -1
  84. package/dist/readiness.js.map +1 -1
  85. package/dist/send.d.ts +6 -0
  86. package/dist/send.js +25 -14
  87. package/dist/send.js.map +1 -1
  88. package/dist/ui/help.d.ts +0 -1
  89. package/dist/ui/help.js +6 -3
  90. package/dist/ui/help.js.map +1 -1
  91. package/dist/ui/theme.d.ts +0 -15
  92. package/dist/ui/theme.js +2 -18
  93. package/dist/ui/theme.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/client.test.ts +90 -1
  96. package/src/client.ts +35 -2
  97. package/src/commands-auth.ts +174 -0
  98. package/src/commands-config.ts +307 -0
  99. package/src/commands-core.ts +240 -0
  100. package/src/commands-devices.ts +150 -0
  101. package/src/commands-doctor.ts +1209 -0
  102. package/src/commands-harness-context.ts +126 -0
  103. package/src/commands-hook-shape.ts +53 -0
  104. package/src/commands-hooks.ts +1586 -0
  105. package/src/commands-init.ts +721 -0
  106. package/src/commands-io.ts +108 -0
  107. package/src/commands-logs.ts +243 -0
  108. package/src/commands-send-support.ts +224 -0
  109. package/src/commands-send.ts +623 -0
  110. package/src/commands-setup-proof.ts +71 -0
  111. package/src/commands-skill.ts +84 -0
  112. package/src/commands.test.ts +677 -89
  113. package/src/commands.ts +58 -5472
  114. package/src/config-schema.test.ts +1 -1
  115. package/src/config-schema.ts +17 -6
  116. package/src/config.test.ts +50 -9
  117. package/src/config.ts +19 -1
  118. package/src/credentials.test.ts +25 -1
  119. package/src/credentials.ts +13 -3
  120. package/src/file-lock.test.ts +41 -0
  121. package/src/file-lock.ts +59 -9
  122. package/src/harnesses.test.ts +37 -29
  123. package/src/harnesses.ts +1 -4
  124. package/src/hooks.test.ts +703 -26
  125. package/src/hooks.ts +590 -182
  126. package/src/install-hooks.test.ts +232 -12
  127. package/src/install-hooks.ts +241 -37
  128. package/src/interactive.test.ts +19 -1
  129. package/src/interactive.ts +5 -5
  130. package/src/logging-process-worker.test.ts +8 -2
  131. package/src/logging.test.ts +48 -20
  132. package/src/logging.ts +12 -0
  133. package/src/main.ts +58 -24
  134. package/src/opencode-plugin.ts +24 -164
  135. package/src/question-caps.test.ts +49 -0
  136. package/src/readiness.ts +3 -1
  137. package/src/send.ts +32 -16
  138. package/src/skill.test.ts +199 -146
  139. package/src/ui/help.ts +8 -3
  140. package/src/ui/theme.ts +2 -16
@@ -58,7 +58,25 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
58
58
  function makeClient(): ApiClient {
59
59
  return {
60
60
  health: async () => true,
61
- capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
61
+ capabilities: async (platform = 'ios') => ({ schema_version: 1, platform }),
62
+ compatibility: async () => ({
63
+ cli: {
64
+ state: 'current',
65
+ reason: 'current',
66
+ affected_operation: null,
67
+ recovery_action: null,
68
+ current_version: '5.0.0',
69
+ current_build: null,
70
+ recommended_version: '5.0.0',
71
+ recommended_build: null,
72
+ minimum_version: null,
73
+ minimum_build: null,
74
+ deprecation: null,
75
+ sunset: null,
76
+ },
77
+ platforms: [],
78
+ server_capabilities: ['answer', 'agent_acknowledgement'],
79
+ }),
62
80
  listDevices: async () => ({ devices: [] }),
63
81
  accessStatus: async () => ({ email: 'rafael@example.test' }),
64
82
  } as unknown as ApiClient
@@ -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
@@ -365,7 +365,7 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
365
365
  if (!signedIn) {
366
366
  options.push({ value: 'account', label: 'Sign in', hint: 'approve this machine in your browser' })
367
367
  } else if (!hasDevice) {
368
- options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your phone or Mac' })
368
+ options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your iPhone' })
369
369
  } else {
370
370
  options.push({
371
371
  value: 'test',
@@ -457,8 +457,8 @@ async function devicesScreen(deps: CommandDeps): Promise<void> {
457
457
  if (devices.length === 0) {
458
458
  clack.note(
459
459
  'No devices are registered yet.\n\n' +
460
- 'Install the Notifai companion app on your iPhone or Mac, sign in with the\n' +
461
- 'same account, and allow notifications when it asks.',
460
+ 'Install the Notifai Companion App on your iPhone, sign in with the\n' +
461
+ 'same Account, and allow notifications when it asks.',
462
462
  'No devices',
463
463
  )
464
464
  return
@@ -733,7 +733,7 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
733
733
  const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
734
734
  clack.note(
735
735
  'When an agent registers a question, it appears in your terminal first.\n' +
736
- 'After the grace below, it goes to your phone and Mac as a notification\n' +
736
+ 'After the grace below, it goes to your iPhone as a notification\n' +
737
737
  'with real answer buttons, and your answer comes back to the agent —\n' +
738
738
  'whether or not you are still at this machine.\n\n' +
739
739
  [
@@ -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', () => {
@@ -403,14 +418,27 @@ describe('reading', () => {
403
418
  expect(records.length).toBeGreaterThan(lines(env).filter((r) => r.event === 'cli.end').length)
404
419
  })
405
420
 
406
- it('skips a damaged line instead of throwing away the file', () => {
421
+ it('skips damaged and unknown-future lines without throwing away the file', () => {
407
422
  const env = sandbox()
408
423
  seed(env)
409
424
  // A half-written line is what a crashed process leaves behind, and a
410
425
  // reader that dies on it loses every good record around it.
411
426
  appendFileSync(activeLogPath(env), '{"v":1,"ts":"2026-0\n')
412
427
  appendFileSync(activeLogPath(env), 'not json at all\n')
413
- expect(readLogRecords(env).records).toHaveLength(3)
428
+ appendFileSync(
429
+ activeLogPath(env),
430
+ `${JSON.stringify({
431
+ v: LOG_SCHEMA_VERSION + 1,
432
+ ts: '2026-08-18T12:00:00.000Z',
433
+ level: 'info',
434
+ event: 'future.event',
435
+ })}\n`,
436
+ )
437
+ expect(readLogRecords(env).records.map((record) => record.event)).toEqual([
438
+ 'send.submitted',
439
+ 'cli.error',
440
+ 'ask.registered',
441
+ ])
414
442
  })
415
443
 
416
444
  it('reports nothing at all rather than failing when there is no log', () => {
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
  /**
@@ -523,6 +534,7 @@ function isRecord(parsed: unknown): parsed is LogRecord {
523
534
  return (
524
535
  typeof parsed === 'object' &&
525
536
  parsed !== null &&
537
+ (parsed as { v?: unknown }).v === LOG_SCHEMA_VERSION &&
526
538
  typeof (parsed as { event?: unknown }).event === 'string' &&
527
539
  typeof (parsed as { ts?: unknown }).ts === 'string'
528
540
  )
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)')
@@ -290,15 +319,19 @@ const send = program
290
319
  .option('--collapse-key <key>', 'replace earlier notifications with the same key')
291
320
  .option('--thread-id <id>', 'group related notifications')
292
321
  .option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
293
- .option('--platform <platform>', 'limit optional fields to ios or macos (default: both)')
322
+ .option('--platform <platform>', 'limit optional fields to one supported platform')
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. */