@raidiant/notifai 7.0.2 → 8.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 (75) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/commands-devices.d.ts +2 -1
  3. package/dist/commands-devices.js +20 -8
  4. package/dist/commands-devices.js.map +1 -1
  5. package/dist/commands-doctor.js +14 -12
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.d.ts +23 -0
  8. package/dist/commands-guidance.js +128 -0
  9. package/dist/commands-guidance.js.map +1 -0
  10. package/dist/commands-hooks.d.ts +7 -1
  11. package/dist/commands-hooks.js +3 -6
  12. package/dist/commands-hooks.js.map +1 -1
  13. package/dist/commands-init.js +25 -22
  14. package/dist/commands-init.js.map +1 -1
  15. package/dist/commands-send-support.js +5 -3
  16. package/dist/commands-send-support.js.map +1 -1
  17. package/dist/commands-send.d.ts +0 -1
  18. package/dist/commands-send.js +45 -28
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands.d.ts +1 -0
  21. package/dist/commands.js +1 -0
  22. package/dist/commands.js.map +1 -1
  23. package/dist/config-schema.js +6 -15
  24. package/dist/config-schema.js.map +1 -1
  25. package/dist/config.d.ts +1 -3
  26. package/dist/config.js +0 -3
  27. package/dist/config.js.map +1 -1
  28. package/dist/guidance-content.d.ts +30 -0
  29. package/dist/guidance-content.js +175 -0
  30. package/dist/guidance-content.js.map +1 -0
  31. package/dist/guidance.d.ts +48 -0
  32. package/dist/guidance.js +118 -0
  33. package/dist/guidance.js.map +1 -0
  34. package/dist/hooks.js +7 -7
  35. package/dist/hooks.js.map +1 -1
  36. package/dist/interactive.d.ts +1 -1
  37. package/dist/interactive.js +13 -8
  38. package/dist/interactive.js.map +1 -1
  39. package/dist/main.js +3 -474
  40. package/dist/main.js.map +1 -1
  41. package/dist/program.d.ts +49 -0
  42. package/dist/program.js +571 -0
  43. package/dist/program.js.map +1 -0
  44. package/dist/send.d.ts +4 -9
  45. package/dist/send.js +48 -28
  46. package/dist/send.js.map +1 -1
  47. package/dist/session-labels.d.ts +9 -3
  48. package/dist/session-labels.js +38 -7
  49. package/dist/session-labels.js.map +1 -1
  50. package/package.json +5 -3
  51. package/src/commands-devices.ts +28 -9
  52. package/src/commands-doctor.ts +17 -12
  53. package/src/commands-guidance.ts +170 -0
  54. package/src/commands-hooks.ts +10 -8
  55. package/src/commands-init.ts +31 -22
  56. package/src/commands-send-support.ts +5 -3
  57. package/src/commands-send.ts +50 -28
  58. package/src/commands.test.ts +374 -63
  59. package/src/commands.ts +5 -0
  60. package/src/config-schema.ts +6 -17
  61. package/src/config.test.ts +56 -1
  62. package/src/config.ts +0 -5
  63. package/src/guidance-content.ts +190 -0
  64. package/src/guidance.test.ts +268 -0
  65. package/src/guidance.ts +151 -0
  66. package/src/hooks.test.ts +37 -5
  67. package/src/hooks.ts +7 -7
  68. package/src/interactive.ts +13 -8
  69. package/src/main.ts +4 -600
  70. package/src/program.test.ts +202 -0
  71. package/src/program.ts +723 -0
  72. package/src/send.ts +53 -32
  73. package/src/session-labels.test.ts +120 -3
  74. package/src/session-labels.ts +38 -7
  75. package/src/skill.test.ts +37 -12
@@ -0,0 +1,170 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { describeSource } from './config-schema.js'
4
+ import { EXIT, type CommandDeps } from './commands-core.js'
5
+ import { SHIPPED_GUIDANCE, shippedGuidanceTopic } from './guidance-content.js'
6
+ import {
7
+ GUIDANCE_TOPIC_MAX_BYTES,
8
+ GUIDANCE_TOPIC_PATTERN,
9
+ findProjectGuidanceDir,
10
+ globalGuidanceDir,
11
+ personalProjectGuidanceDir,
12
+ resolveGuidance,
13
+ } from './guidance.js'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // guidance show / set / unset
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * The whole effective guidance, ready to be followed as read. Each topic is
21
+ * printed under a provenance comment so a reader can tell the user's standing
22
+ * word from the shipped default without the output stopping being Markdown.
23
+ */
24
+ export function guidanceShowCommand(deps: CommandDeps, flags: { json?: boolean }): number {
25
+ const topics = resolveGuidance({ cwd: deps.cwd, env: deps.env })
26
+ if (flags.json) {
27
+ deps.io.out(
28
+ JSON.stringify(
29
+ topics.map((topic) => ({
30
+ name: topic.name,
31
+ source: topic.source,
32
+ summary: topic.summary,
33
+ content: topic.content,
34
+ })),
35
+ null,
36
+ 2,
37
+ ),
38
+ )
39
+ return EXIT.ok
40
+ }
41
+ const blocks = topics.map((topic) => {
42
+ const { label, path: filePath } = describeSource(topic.source)
43
+ const origin = topic.source === 'default' ? 'shipped default' : label
44
+ const provenance = filePath === null ? origin : `${origin} · ${filePath}`
45
+ return `<!-- ${topic.name} · ${provenance} -->\n${topic.content.trimEnd()}`
46
+ })
47
+ deps.io.out(blocks.join('\n\n'))
48
+ return EXIT.ok
49
+ }
50
+
51
+ type GuidanceMutationFlags = {
52
+ project?: boolean | undefined
53
+ local?: boolean | undefined
54
+ yes?: boolean | undefined
55
+ }
56
+ type GuidanceLayer = 'global' | 'project' | 'local'
57
+
58
+ /**
59
+ * Where a topic file for one layer lives. The project layer prefers an
60
+ * existing `.notifai/guidance` up the tree and otherwise starts one at cwd,
61
+ * the same anchoring `config set --project` uses for its file.
62
+ */
63
+ function guidanceLayerDir(deps: CommandDeps, layer: GuidanceLayer): string {
64
+ if (layer === 'local') return personalProjectGuidanceDir(deps.cwd, deps.env)
65
+ if (layer === 'project') {
66
+ return findProjectGuidanceDir(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'guidance')
67
+ }
68
+ return globalGuidanceDir(deps.env)
69
+ }
70
+
71
+ async function guidanceMutationTarget(
72
+ deps: CommandDeps,
73
+ topic: string,
74
+ flags: GuidanceMutationFlags,
75
+ ): Promise<string | null> {
76
+ if (!GUIDANCE_TOPIC_PATTERN.test(topic)) {
77
+ deps.io.err(
78
+ `"${topic}" is not a topic name. Use lowercase letters, digits, "-" and "_", e.g. ${SHIPPED_GUIDANCE.map((t) => t.name).join(', ')}.`,
79
+ )
80
+ return null
81
+ }
82
+ let layer: GuidanceLayer = flags.local ? 'local' : flags.project ? 'project' : 'global'
83
+ if (
84
+ flags.local !== true &&
85
+ flags.project !== true &&
86
+ flags.yes !== true &&
87
+ deps.io.interactive === true &&
88
+ deps.io.select
89
+ ) {
90
+ const selected = await deps.io.select('Where should this guidance live?', [
91
+ { value: 'global', label: 'This machine', hint: 'applies across projects' },
92
+ { value: 'project', label: 'This project (shared)', hint: '.notifai/guidance/' },
93
+ { value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
94
+ ])
95
+ if (selected === null) {
96
+ deps.io.err('No guidance layer selected.')
97
+ return null
98
+ }
99
+ layer = selected as GuidanceLayer
100
+ }
101
+ return path.join(guidanceLayerDir(deps, layer), `${topic}.md`)
102
+ }
103
+
104
+ /**
105
+ * Writes one topic at one layer, replacing the shipped topic of that name for
106
+ * this scope. The confirmation gate is the same one `config set` holds: the
107
+ * user owns their standing word, so an agent writes it only with their words
108
+ * and their say-so.
109
+ */
110
+ export async function guidanceSetCommand(
111
+ deps: CommandDeps,
112
+ topic: string,
113
+ text: string,
114
+ flags: GuidanceMutationFlags,
115
+ ): Promise<number> {
116
+ if (text.trim() === '') {
117
+ deps.io.err('Guidance text is empty. Pass the text, or --file <path|-> to read it.')
118
+ return EXIT.usage
119
+ }
120
+ if (text.length > GUIDANCE_TOPIC_MAX_BYTES) {
121
+ deps.io.err(`Guidance for one topic is capped at ${GUIDANCE_TOPIC_MAX_BYTES} bytes.`)
122
+ return EXIT.usage
123
+ }
124
+ const target = await guidanceMutationTarget(deps, topic, flags)
125
+ if (target === null) return EXIT.usage
126
+
127
+ const shipped = shippedGuidanceTopic(topic)
128
+ if (!flags.yes) {
129
+ const replaces =
130
+ shipped === undefined
131
+ ? `Add guidance topic "${topic}"`
132
+ : `Replace the shipped "${topic}" guidance for this scope`
133
+ const confirmed = await deps.io.confirm(`${replaces} at ${target}?`)
134
+ if (!confirmed) {
135
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
136
+ return EXIT.usage
137
+ }
138
+ }
139
+
140
+ mkdirSync(path.dirname(target), { recursive: true })
141
+ writeFileSync(target, text.endsWith('\n') ? text : `${text}\n`)
142
+ deps.io.out(`Wrote ${topic} to ${target}`)
143
+ if (shipped !== undefined) {
144
+ deps.io.out(`This file now replaces the shipped "${topic}" guidance wherever it applies.`)
145
+ }
146
+ return EXIT.ok
147
+ }
148
+
149
+ export async function guidanceUnsetCommand(
150
+ deps: CommandDeps,
151
+ topic: string,
152
+ flags: GuidanceMutationFlags,
153
+ ): Promise<number> {
154
+ const target = await guidanceMutationTarget(deps, topic, flags)
155
+ if (target === null) return EXIT.usage
156
+ if (!existsSync(target)) {
157
+ deps.io.out(`${topic} is not set at ${target}`)
158
+ return EXIT.ok
159
+ }
160
+ if (!flags.yes) {
161
+ const confirmed = await deps.io.confirm(`Remove ${target}?`)
162
+ if (!confirmed) {
163
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
164
+ return EXIT.usage
165
+ }
166
+ }
167
+ rmSync(target, { force: true })
168
+ deps.io.out(`Removed ${target}; the inherited guidance now applies.`)
169
+ return EXIT.ok
170
+ }
@@ -460,7 +460,13 @@ interface AskFormQuestion {
460
460
 
461
461
  export interface BuiltQuestions {
462
462
  questions: QuestionT[]
463
- /** Canonical Markdown body: question block first, optional context second. */
463
+ /**
464
+ * Canonical Markdown body. The question already travels as the notification
465
+ * title and as structured questions, so the body carries only the context —
466
+ * repeating the question there put it on the lock screen and the reply
467
+ * screen twice. Only when there is no context does the question text stand
468
+ * in, because the wire requires a body.
469
+ */
464
470
  body: string
465
471
  }
466
472
 
@@ -524,14 +530,13 @@ export function buildQuestions(
524
530
  questions.push(built.question)
525
531
  }
526
532
  const context = flags.body ?? (record['body'] as string | undefined)
527
- const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n')
528
533
  return {
529
534
  ok: true,
530
535
  questions,
531
536
  body:
532
537
  context !== undefined && context.trim() !== ''
533
- ? `${questionBlock}\n\n${context}`
534
- : questionBlock,
538
+ ? context
539
+ : questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n'),
535
540
  }
536
541
  }
537
542
 
@@ -544,10 +549,7 @@ export function buildQuestions(
544
549
  return {
545
550
  ok: true,
546
551
  questions: [built.question],
547
- body:
548
- context !== undefined && context.trim() !== ''
549
- ? `${built.question.text}\n\n${context}`
550
- : built.question.text,
552
+ body: context !== undefined && context.trim() !== '' ? context : built.question.text,
551
553
  }
552
554
  }
553
555
 
@@ -23,7 +23,7 @@ import {
23
23
  reportError,
24
24
  type CommandDeps,
25
25
  } from './commands-core.js'
26
- import { readyIosDevices, supportPageUrl } from './commands-devices.js'
26
+ import { readyCompanionDevices, supportPageUrl } from './commands-devices.js'
27
27
  import { assessReadiness, remedyLine } from './commands-doctor.js'
28
28
  import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
29
29
  import { observedCompanionReceipt, readSetupProof, writeSetupProof } from './commands-setup-proof.js'
@@ -55,7 +55,7 @@ export interface InitFlags {
55
55
  }
56
56
 
57
57
 
58
- /** Long enough for a first TestFlight install; keep-waiting extends another budget. */
58
+ /** Long enough for a first controlled Companion install; keep-waiting extends another budget. */
59
59
  const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000
60
60
  const DEVICE_BRIDGE_POLL_MS = 2_000
61
61
  const PROOF_TIMEOUT_MS = 30_000
@@ -159,20 +159,21 @@ async function closeGap(
159
159
 
160
160
  function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
161
161
  if (devices.length === 0) {
162
- return 'Waiting for the iPhone app to sign in and register…'
162
+ return 'Waiting for a Companion App to sign in and register…'
163
163
  }
164
164
  const denied = devices.find((device) => device.permission_status === 'denied')
165
165
  if (denied) return `Waiting for notifications to be allowed on ${denied.display_name}…`
166
166
  const undecided = devices.find((device) => device.permission_status === 'not_determined')
167
167
  if (undecided) return `Waiting for ${undecided.display_name} to allow the notification prompt…`
168
- return 'Waiting for an iPhone to become ready…'
168
+ return 'Waiting for a Companion device to become ready…'
169
169
  }
170
170
 
171
171
  /**
172
172
  * Observe the supported Device Installation path while the user finishes the
173
- * app-side work. The live bridge is the dashboard `/support` page (TestFlight
174
- * steps). Interactive runs offer to open it so the user never types the URL;
175
- * non-interactive/agent paths only print plain text and never wait on a prompt.
173
+ * app-side work. The live bridge is the dashboard `/support` page (controlled
174
+ * Companion installation steps). Interactive runs offer to open it so the user
175
+ * never types the URL; non-interactive/agent paths only print plain text and
176
+ * never wait on a prompt.
176
177
  *
177
178
  * The wait budget is stated up front. On expiry we say the *timer* expired —
178
179
  * not the setup — and offer another budget when a human is present. Agents
@@ -197,9 +198,9 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
197
198
  state.detail,
198
199
  remedy.summary,
199
200
  `Install steps (no typing): ${supportUrl}`,
200
- `I will wait up to ${budgetLabel} for your iPhone to become ready.`,
201
+ `I will wait up to ${budgetLabel} for a Companion device to become ready.`,
201
202
  ].join('\n'),
202
- 'Finish setup on your iPhone',
203
+ 'Finish setup on your Companion device',
203
204
  )
204
205
 
205
206
  // Open the real support page so the user never has to type the URL. Decline
@@ -219,7 +220,9 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
219
220
  const sleep =
220
221
  deps.sleep ??
221
222
  ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
222
- const spinner = await deps.io.spinner?.(`Waiting up to ${budgetLabel} for your iPhone…`)
223
+ const spinner = await deps.io.spinner?.(
224
+ `Waiting up to ${budgetLabel} for a Companion device…`,
225
+ )
223
226
  let lastDevices: RoutableDevice[] = []
224
227
  let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
225
228
 
@@ -227,14 +230,16 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
227
230
  while (now() < deadline) {
228
231
  try {
229
232
  const response = await authed.client.listDevices()
230
- const iphoneDevices = response.devices.filter((device) => device.platform === 'ios')
231
- lastDevices = iphoneDevices
232
- const ready = readyIosDevices(iphoneDevices)[0]
233
+ const companionDevices = response.devices.filter(
234
+ (device) => device.platform === 'ios' || device.platform === 'android',
235
+ )
236
+ lastDevices = companionDevices
237
+ const ready = readyCompanionDevices(companionDevices)[0]
233
238
  if (ready) {
234
239
  spinner?.stop(`${ready.display_name} is ready to receive`)
235
240
  return 'closed'
236
241
  }
237
- spinner?.message(deviceBridgeMessage(iphoneDevices))
242
+ spinner?.message(deviceBridgeMessage(companionDevices))
238
243
  } catch (err) {
239
244
  if (!(err instanceof NetworkError)) {
240
245
  spinner?.error('Could not check companion readiness')
@@ -263,10 +268,12 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
263
268
  false,
264
269
  )
265
270
  if (!keepWaiting) {
266
- deps.io.out('Stopping the wait. iPhone setup can continue; re-run `notifai init` when ready.')
271
+ deps.io.out(
272
+ 'Stopping the wait. Companion setup can continue; re-run `notifai init` when ready.',
273
+ )
267
274
  return 'pending'
268
275
  }
269
- spinner?.message(`Waiting another ${budgetLabel} for your iPhone…`)
276
+ spinner?.message(`Waiting another ${budgetLabel} for a Companion device…`)
270
277
  deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
271
278
  }
272
279
  }
@@ -284,10 +291,12 @@ function setupProofDraft(
284
291
  : `This real notification completed setup verification for ${project}.`,
285
292
  event: 'setup_verified',
286
293
  kind: 'update',
287
- platform: 'ios',
294
+ platform: device.platform,
288
295
  device: [device.device_id],
289
296
  sound: 'none',
290
- level: 'passive',
297
+ // Android has no caller-selected interruption level. Explicit null keeps an
298
+ // Apple preference in config from leaking into this Android-only proof.
299
+ level: device.platform === 'ios' ? 'passive' : null,
291
300
  collapseKey: 'notifai-setup-verification',
292
301
  })
293
302
  }
@@ -331,8 +340,8 @@ async function submitSetupProof(
331
340
 
332
341
  /**
333
342
  * Send or resume one real setup probe, then wait for a Companion Receipt.
334
- * Provider Acceptance is intentionally insufficient: it proves APNs accepted
335
- * the push, not that a companion process received it.
343
+ * Provider Acceptance is intentionally insufficient: it proves a push provider
344
+ * accepted the request, not that a companion process received it.
336
345
  */
337
346
  async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
338
347
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
@@ -346,7 +355,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
346
355
  reportError(deps, err)
347
356
  return 'failed'
348
357
  }
349
- const candidates = readyIosDevices(devices)
358
+ const candidates = readyCompanionDevices(devices)
350
359
  const existing = readSetupProof(deps)
351
360
  const target =
352
361
  candidates.find(
@@ -354,7 +363,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
354
363
  device.device_id === existing?.device_id && existing.project === config.project.value,
355
364
  ) ?? candidates[0]
356
365
  if (!target) {
357
- deps.io.err('Setup proof needs a receipt-capable iPhone.')
366
+ deps.io.err('Setup proof needs a receipt-capable iPhone or Android Companion App.')
358
367
  return 'pending'
359
368
  }
360
369
 
@@ -15,10 +15,12 @@ function managedSessionTitle(
15
15
  active: ActiveHarnessSession | null,
16
16
  ): string | undefined {
17
17
  if (active?.sessionLabel !== undefined) return active.sessionLabel
18
+ // The Orca worktree title names the workspace, not one harness: every
19
+ // harness session Orca starts inside that worktree inherits the same
20
+ // environment selector, so any active session with an identity to label
21
+ // may use it. An explicit label still wins without paying for the lookup.
18
22
  if (
19
- active?.harness !== 'claude-code' ||
20
- active.sessionId === undefined ||
21
- active.sessionId !== deps.env['CLAUDE_CODE_SESSION_ID'] ||
23
+ active?.sessionId === undefined ||
22
24
  flags.sessionLabel !== undefined ||
23
25
  deps.env['NOTIFAI_SESSION_LABEL'] !== undefined
24
26
  ) {
@@ -46,14 +46,13 @@ export async function sendCommand(
46
46
  wait?: number
47
47
  noWait?: boolean
48
48
  replyTimeout?: number
49
- noBlock?: boolean
50
49
  idempotencyKey?: string
51
50
  baseUrl?: string
52
51
  },
53
52
  ): Promise<number> {
54
- const hasReplyChoice = Array.isArray(flags.replyChoice)
55
- ? flags.replyChoice.length > 0
56
- : flags.replyChoice !== undefined
53
+ const hasChoice = Array.isArray(flags.choice)
54
+ ? flags.choice.length > 0
55
+ : flags.choice !== undefined
57
56
  if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
58
57
  deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
59
58
  return EXIT.usage
@@ -75,9 +74,9 @@ export async function sendCommand(
75
74
  }
76
75
  if (
77
76
  !flags.reply &&
78
- (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)
77
+ (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || hasChoice)
79
78
  ) {
80
- deps.io.err('Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.')
79
+ deps.io.err('Use --reply with --reply-timeout, --reply-window, or --choice.')
81
80
  return EXIT.usage
82
81
  }
83
82
  const replyTimeout = flags.replyTimeout ?? 900
@@ -91,13 +90,10 @@ export async function sendCommand(
91
90
  // request id. So the user gets a real button, taps it, and nothing happens —
92
91
  // worse than a banner that never asked, because it spends their attention
93
92
  // and their trust in the channel.
94
- //
95
- // Both spellings of "do not wait" are rejected, because the defect is the
96
- // zero wait and not the flag that produced it.
97
- if (flags.reply && (flags.noBlock || replyTimeout === 0)) {
93
+ if (flags.reply && replyTimeout === 0) {
98
94
  deps.io.err(
99
95
  'A question needs someone to hear the answer, so --reply cannot be combined ' +
100
- 'with --no-block or --reply-timeout 0.\n' +
96
+ 'with --reply-timeout 0.\n' +
101
97
  'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
102
98
  'To announce finished work, drop --reply and its choices.',
103
99
  )
@@ -165,13 +161,16 @@ export async function sendCommand(
165
161
  for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
166
162
  return EXIT.usage
167
163
  }
164
+ for (const warning of validation.warnings) {
165
+ deps.io.err(`Heads up (${warning.path}): ${warning.message}`)
166
+ }
168
167
  emitSendWarnings(deps, flags, config)
169
168
  if (
170
169
  !flags.reply &&
171
170
  (flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
172
171
  ) {
173
172
  deps.io.err(
174
- 'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --reply-choice) so it can be answered from the notification.',
173
+ 'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --choice) so it can be answered from the notification.',
175
174
  )
176
175
  }
177
176
  const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
@@ -220,23 +219,14 @@ export async function sendCommand(
220
219
  receipt.overall === 'provider_accepted_all' &&
221
220
  receipt.warnings.length === 0
222
221
  if (!quietOrdinarySuccess) deps.io.out(formatReceipt(receipt))
223
- } else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
222
+ }
224
223
 
225
224
  // A zero wait can no longer reach here: --reply guarantees a positive one.
226
225
  if (!flags.reply || receiptExit !== EXIT.ok) {
227
226
  if (flags.json) {
228
227
  deps.io.out(
229
228
  flags.reply
230
- ? JSON.stringify({
231
- type: 'reply_result',
232
- request_id: receipt.request_id,
233
- replies: [],
234
- agent_acknowledgement_required: receipt.agent_acknowledgement_required,
235
- agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
236
- agent_acknowledgement: null,
237
- acknowledgement_command: null,
238
- degraded: false,
239
- })
229
+ ? JSON.stringify(unansweredReplyResultJson(receipt, false))
240
230
  : JSON.stringify(receipt, null, 2),
241
231
  )
242
232
  }
@@ -253,7 +243,7 @@ export async function sendCommand(
253
243
  recordReplies(deps, receipt.request_id, result.response.replies)
254
244
  if (flags.json) {
255
245
  deps.io.out(
256
- JSON.stringify(replyResultJson(result.response, result.degraded)),
246
+ JSON.stringify(replyResultJson(result.response, result.degraded, receipt)),
257
247
  )
258
248
  } else if (result.response.replies.length > 0) {
259
249
  printReplies(deps, result.response.replies)
@@ -282,9 +272,9 @@ export async function sendCommand(
282
272
  }
283
273
  return result.timedOut ? EXIT.noReply : EXIT.ok
284
274
  } catch (err) {
285
- // Receipt already printed; a wait fault must not read as "send failed".
286
- // Permanent poll errors (auth, closed window, not found) still surface, but
287
- // always name the durable request and point at recovery.
275
+ // The send already succeeded durably; a wait fault must not read as "send
276
+ // failed". Permanent poll errors (auth, closed window, not found) still
277
+ // surface, but always name the durable request and point at recovery.
288
278
  if (err instanceof ApiCallError || err instanceof NetworkError) {
289
279
  log(deps).error('cli.error', {
290
280
  kind: err instanceof ApiCallError ? 'api' : 'network',
@@ -297,6 +287,9 @@ export async function sendCommand(
297
287
  `notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
298
288
  `Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
299
289
  )
290
+ // JSON callers still get their one object: the durable receipt, no
291
+ // replies, and `degraded: true` — "could not find out", not "no answer".
292
+ if (flags.json) deps.io.out(JSON.stringify(unansweredReplyResultJson(receipt, true)))
300
293
  if (err instanceof ApiCallError) {
301
294
  if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
302
295
  return err.status >= 500 || err.status === 429 || err.status === 408
@@ -477,9 +470,19 @@ function isNonNegativeInteger(value: number): boolean {
477
470
  }
478
471
 
479
472
 
480
- function replyResultJson(response: ListRepliesResponse, degraded: boolean): object {
473
+ /**
474
+ * The one JSON object `send --reply --json` prints, whatever happens after the
475
+ * durable submit. Embedding the receipt keeps stdout a single parseable line;
476
+ * `replies` (from a wait) omits it because there is no submission to report.
477
+ */
478
+ function replyResultJson(
479
+ response: ListRepliesResponse,
480
+ degraded: boolean,
481
+ receipt?: SubmissionReceipt,
482
+ ): object {
481
483
  return {
482
484
  type: 'reply_result',
485
+ ...(receipt !== undefined ? { receipt } : {}),
483
486
  request_id: response.request_id,
484
487
  reply_expires_at: response.reply_expires_at,
485
488
  replies: response.replies,
@@ -497,6 +500,25 @@ function replyResultJson(response: ListRepliesResponse, degraded: boolean): obje
497
500
  }
498
501
  }
499
502
 
503
+ /**
504
+ * The reply_result for a reply send that has no answer to report — a receipt
505
+ * failure, or a wait that faulted. Same shape, same single line; `degraded`
506
+ * distinguishes "the wait could not find out" from "delivery already failed".
507
+ */
508
+ function unansweredReplyResultJson(receipt: SubmissionReceipt, degraded: boolean): object {
509
+ return {
510
+ type: 'reply_result',
511
+ receipt,
512
+ request_id: receipt.request_id,
513
+ replies: [],
514
+ agent_acknowledgement_required: receipt.agent_acknowledgement_required,
515
+ agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
516
+ agent_acknowledgement: null,
517
+ acknowledgement_command: null,
518
+ degraded,
519
+ }
520
+ }
521
+
500
522
 
501
523
  function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
502
524
  const logger = log(deps)