@raidiant/notifai 5.0.0 → 7.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 (126) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +2 -2
  3. package/dist/atomic-file.js +13 -0
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +8 -3
  6. package/dist/client.js +24 -3
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands-auth.d.ts +14 -0
  9. package/dist/commands-auth.js +154 -0
  10. package/dist/commands-auth.js.map +1 -0
  11. package/dist/commands-config.d.ts +31 -0
  12. package/dist/commands-config.js +246 -0
  13. package/dist/commands-config.js.map +1 -0
  14. package/dist/commands-core.d.ts +102 -0
  15. package/dist/commands-core.js +135 -0
  16. package/dist/commands-core.js.map +1 -0
  17. package/dist/commands-devices.d.ts +31 -0
  18. package/dist/commands-devices.js +124 -0
  19. package/dist/commands-devices.js.map +1 -0
  20. package/dist/commands-doctor.d.ts +24 -0
  21. package/dist/commands-doctor.js +1011 -0
  22. package/dist/commands-doctor.js.map +1 -0
  23. package/dist/commands-harness-context.d.ts +65 -0
  24. package/dist/commands-harness-context.js +97 -0
  25. package/dist/commands-harness-context.js.map +1 -0
  26. package/dist/commands-hook-shape.d.ts +17 -0
  27. package/dist/commands-hook-shape.js +37 -0
  28. package/dist/commands-hook-shape.js.map +1 -0
  29. package/dist/commands-hooks.d.ts +115 -0
  30. package/dist/commands-hooks.js +1225 -0
  31. package/dist/commands-hooks.js.map +1 -0
  32. package/dist/commands-init.d.ts +35 -0
  33. package/dist/commands-init.js +580 -0
  34. package/dist/commands-init.js.map +1 -0
  35. package/dist/commands-io.d.ts +2 -0
  36. package/dist/commands-io.js +110 -0
  37. package/dist/commands-io.js.map +1 -0
  38. package/dist/commands-logs.d.ts +30 -0
  39. package/dist/commands-logs.js +201 -0
  40. package/dist/commands-logs.js.map +1 -0
  41. package/dist/commands-send-support.d.ts +44 -0
  42. package/dist/commands-send-support.js +183 -0
  43. package/dist/commands-send-support.js.map +1 -0
  44. package/dist/commands-send.d.ts +33 -0
  45. package/dist/commands-send.js +519 -0
  46. package/dist/commands-send.js.map +1 -0
  47. package/dist/commands-setup-proof.d.ts +15 -0
  48. package/dist/commands-setup-proof.js +53 -0
  49. package/dist/commands-setup-proof.js.map +1 -0
  50. package/dist/commands-skill.d.ts +10 -0
  51. package/dist/commands-skill.js +71 -0
  52. package/dist/commands-skill.js.map +1 -0
  53. package/dist/commands.d.ts +12 -387
  54. package/dist/commands.js +12 -4536
  55. package/dist/commands.js.map +1 -1
  56. package/dist/config-schema.js +1 -1
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +4 -4
  59. package/dist/config.js +6 -4
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.js +13 -3
  62. package/dist/credentials.js.map +1 -1
  63. package/dist/generated-session-label.d.ts +2 -0
  64. package/dist/generated-session-label.js +152 -0
  65. package/dist/generated-session-label.js.map +1 -0
  66. package/dist/hooks.d.ts +2 -2
  67. package/dist/hooks.js +127 -99
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/interactive.js +4 -4
  70. package/dist/interactive.js.map +1 -1
  71. package/dist/invocation-context.d.ts +3 -0
  72. package/dist/invocation-context.js +23 -6
  73. package/dist/invocation-context.js.map +1 -1
  74. package/dist/logging.js +1 -0
  75. package/dist/logging.js.map +1 -1
  76. package/dist/main.js +3 -3
  77. package/dist/main.js.map +1 -1
  78. package/dist/opencode-plugin.js +18 -3
  79. package/dist/opencode-plugin.js.map +1 -1
  80. package/dist/readiness.d.ts +3 -1
  81. package/dist/readiness.js.map +1 -1
  82. package/dist/session-labels.d.ts +30 -0
  83. package/dist/session-labels.js +278 -0
  84. package/dist/session-labels.js.map +1 -0
  85. package/package.json +2 -2
  86. package/src/atomic-file.ts +12 -0
  87. package/src/client.test.ts +90 -1
  88. package/src/client.ts +35 -2
  89. package/src/commands-auth.ts +174 -0
  90. package/src/commands-config.ts +307 -0
  91. package/src/commands-core.ts +240 -0
  92. package/src/commands-devices.ts +150 -0
  93. package/src/commands-doctor.ts +1209 -0
  94. package/src/commands-harness-context.ts +134 -0
  95. package/src/commands-hook-shape.ts +53 -0
  96. package/src/commands-hooks.ts +1586 -0
  97. package/src/commands-init.ts +721 -0
  98. package/src/commands-io.ts +108 -0
  99. package/src/commands-logs.ts +243 -0
  100. package/src/commands-send-support.ts +227 -0
  101. package/src/commands-send.ts +623 -0
  102. package/src/commands-setup-proof.ts +71 -0
  103. package/src/commands-skill.ts +84 -0
  104. package/src/commands.test.ts +567 -42
  105. package/src/commands.ts +58 -5614
  106. package/src/config-schema.ts +1 -1
  107. package/src/config.test.ts +9 -0
  108. package/src/config.ts +10 -5
  109. package/src/credentials.test.ts +25 -1
  110. package/src/credentials.ts +13 -3
  111. package/src/generated-session-label.ts +155 -0
  112. package/src/hooks.test.ts +355 -16
  113. package/src/hooks.ts +145 -97
  114. package/src/install-hooks.test.ts +84 -1
  115. package/src/interactive.test.ts +19 -1
  116. package/src/interactive.ts +4 -4
  117. package/src/invocation-context.test.ts +115 -16
  118. package/src/invocation-context.ts +32 -11
  119. package/src/logging.test.ts +15 -2
  120. package/src/logging.ts +1 -0
  121. package/src/main.ts +9 -3
  122. package/src/opencode-plugin.ts +18 -3
  123. package/src/readiness.ts +3 -1
  124. package/src/session-labels.test.ts +303 -0
  125. package/src/session-labels.ts +360 -0
  126. package/src/skill.test.ts +13 -0
@@ -0,0 +1,623 @@
1
+ import {
2
+ CAPABILITIES_V1,
3
+ REPLY_MAX_WINDOW_SECONDS,
4
+ validateDraft,
5
+ type ListRepliesResponse,
6
+ type ReplyView,
7
+ type SubmissionReceipt,
8
+ } from '@raidiant/notifai-protocol'
9
+ import { randomBytes } from 'node:crypto'
10
+ import { ApiCallError, NetworkError } from './client.js'
11
+ import type { FlagOverrides, loadConfig } from './config.js'
12
+ import { MIN_REPLY_WINDOW_SECONDS, readProjectSession, readSessionState } from './hooks.js'
13
+ import {
14
+ buildDraft,
15
+ formatReceipt,
16
+ receiptExitCode,
17
+ validateMediaInputs,
18
+ type SendFlags,
19
+ } from './send.js'
20
+ import {
21
+ EXIT,
22
+ authedClient,
23
+ loadLoggedConfig,
24
+ log,
25
+ reportError,
26
+ type CommandDeps,
27
+ } from './commands-core.js'
28
+ import { activeHarnessSession } from './commands-harness-context.js'
29
+ import {
30
+ acknowledgementCommand,
31
+ printAcknowledgementStatus,
32
+ resolveDraftInvocation,
33
+ uploadImage,
34
+ waitForReply,
35
+ } from './commands-send-support.js'
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // send / status
39
+ // ---------------------------------------------------------------------------
40
+
41
+
42
+ export async function sendCommand(
43
+ deps: CommandDeps,
44
+ flags: SendFlags & {
45
+ json?: boolean
46
+ wait?: number
47
+ noWait?: boolean
48
+ replyTimeout?: number
49
+ noBlock?: boolean
50
+ idempotencyKey?: string
51
+ baseUrl?: string
52
+ },
53
+ ): Promise<number> {
54
+ const hasReplyChoice = Array.isArray(flags.replyChoice)
55
+ ? flags.replyChoice.length > 0
56
+ : flags.replyChoice !== undefined
57
+ if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
58
+ deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
59
+ return EXIT.usage
60
+ }
61
+ // Kind now decides the sound a notification arrives with, so it is asked for
62
+ // at the boundary rather than defaulted silently: an unlabelled send would
63
+ // reach the user as ordinary news whatever actually happened.
64
+ if (!flags.reply && flags.kind === undefined) {
65
+ deps.io.err(
66
+ '--kind is required: say what this notification is. ' +
67
+ 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
68
+ '--reply makes it a question without --kind.',
69
+ )
70
+ return EXIT.usage
71
+ }
72
+ if (!flags.reply && flags.kind === 'question') {
73
+ deps.io.err('--kind question requires --reply so the question can be answered.')
74
+ return EXIT.usage
75
+ }
76
+ if (
77
+ !flags.reply &&
78
+ (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)
79
+ ) {
80
+ deps.io.err('Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.')
81
+ return EXIT.usage
82
+ }
83
+ const replyTimeout = flags.replyTimeout ?? 900
84
+ if (flags.reply && !isNonNegativeInteger(replyTimeout)) {
85
+ deps.io.err('--reply-timeout must be a non-negative integer number of seconds.')
86
+ return EXIT.usage
87
+ }
88
+ // Asking while declaring that nothing will wait for the answer. The reply is
89
+ // captured server-side and then unreachable: only a blocking send waits for
90
+ // it, and the hook path drains questions registered by `ask`, never a send's
91
+ // request id. So the user gets a real button, taps it, and nothing happens —
92
+ // worse than a banner that never asked, because it spends their attention
93
+ // 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)) {
98
+ deps.io.err(
99
+ 'A question needs someone to hear the answer, so --reply cannot be combined ' +
100
+ 'with --no-block or --reply-timeout 0.\n' +
101
+ 'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
102
+ 'To announce finished work, drop --reply and its choices.',
103
+ )
104
+ return EXIT.usage
105
+ }
106
+ if (
107
+ flags.reply &&
108
+ flags.replyWindow !== undefined &&
109
+ (!Number.isInteger(flags.replyWindow) ||
110
+ flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
111
+ flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)
112
+ ) {
113
+ deps.io.err(
114
+ `--reply-window must be an integer from ${MIN_REPLY_WINDOW_SECONDS} to ${REPLY_MAX_WINDOW_SECONDS} seconds.`,
115
+ )
116
+ return EXIT.usage
117
+ }
118
+ const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
119
+ if (mediaInputError !== null) {
120
+ deps.io.err(mediaInputError)
121
+ return EXIT.usage
122
+ }
123
+ const config = loadLoggedConfig(deps, {
124
+ cwd: deps.cwd,
125
+ env: deps.env,
126
+ flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
127
+ })
128
+ const source = resolveDraftInvocation(
129
+ deps,
130
+ flags,
131
+ activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
132
+ )
133
+ if (!source.ok) {
134
+ deps.io.err(source.error)
135
+ return EXIT.usage
136
+ }
137
+ const authed = authedClient(deps, config)
138
+ if (!authed) return EXIT.auth
139
+ const mediaIds: string[] = []
140
+ for (const image of flags.image ?? []) {
141
+ if (image.startsWith('med_')) {
142
+ mediaIds.push(image)
143
+ continue
144
+ }
145
+ const uploaded = await uploadImage(deps, authed.client, image)
146
+ if (!uploaded.ok) {
147
+ if (uploaded.error !== null) deps.io.err(uploaded.error)
148
+ return uploaded.exit
149
+ }
150
+ mediaIds.push(uploaded.mediaId)
151
+ }
152
+ flags = { ...flags, image: mediaIds }
153
+ const build = buildDraft(config, flags, source.invocation)
154
+ if (!build.ok) {
155
+ deps.io.err(build.error)
156
+ return EXIT.usage
157
+ }
158
+ const capabilities = CAPABILITIES_V1.describe(build.platform)
159
+ if (!capabilities) {
160
+ deps.io.err(`No capability contract is available for ${build.platform}.`)
161
+ return EXIT.usage
162
+ }
163
+ const validation = validateDraft(build.draft, capabilities)
164
+ if (!validation.ok) {
165
+ for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
166
+ return EXIT.usage
167
+ }
168
+ emitSendWarnings(deps, flags, config)
169
+ if (
170
+ !flags.reply &&
171
+ (flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
172
+ ) {
173
+ 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.',
175
+ )
176
+ }
177
+ const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
178
+ const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
179
+ let receipt: SubmissionReceipt
180
+ try {
181
+ receipt = await authed.client.submit(
182
+ { idempotency_key: idempotencyKey, draft: build.draft },
183
+ waitSeconds,
184
+ )
185
+ } catch (err) {
186
+ return reportError(deps, err)
187
+ }
188
+ const receiptExit = receiptExitCode(receipt)
189
+ // The single most useful line in the log: it ties the local invocation to
190
+ // the server-side request id, which is what every later question about this
191
+ // notification ("did it arrive?", "which device?") is asked in terms of.
192
+ log(deps).info('send.submitted', {
193
+ request_id: receipt.request_id,
194
+ kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
195
+ title: flags.title,
196
+ overall: receipt.overall,
197
+ replayed: receipt.replayed,
198
+ agent_acknowledgement_required: receipt.agent_acknowledgement_required,
199
+ deliveries: receipt.deliveries.length,
200
+ wait_seconds: waitSeconds,
201
+ exit: receiptExit,
202
+ })
203
+ log(deps).info('send.outcome', {
204
+ request_id: receipt.request_id,
205
+ overall: receipt.overall,
206
+ // Per device, because "it said accepted but nothing arrived" is answered
207
+ // by which device reached which state and why the provider said so.
208
+ devices: receipt.deliveries.map((delivery) => ({
209
+ device: delivery.device_name,
210
+ state: delivery.state,
211
+ attempts: delivery.attempts,
212
+ provider_status: delivery.provider_status,
213
+ provider_reason: delivery.provider_reason,
214
+ })),
215
+ ...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
216
+ })
217
+ if (!flags.json) {
218
+ const quietOrdinarySuccess =
219
+ !flags.reply &&
220
+ receipt.overall === 'provider_accepted_all' &&
221
+ receipt.warnings.length === 0
222
+ if (!quietOrdinarySuccess) deps.io.out(formatReceipt(receipt))
223
+ } else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
224
+
225
+ // A zero wait can no longer reach here: --reply guarantees a positive one.
226
+ if (!flags.reply || receiptExit !== EXIT.ok) {
227
+ if (flags.json) {
228
+ deps.io.out(
229
+ 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
+ })
240
+ : JSON.stringify(receipt, null, 2),
241
+ )
242
+ }
243
+ return receiptExit
244
+ }
245
+
246
+ try {
247
+ const result = await waitForReply(authed.client, receipt.request_id, {
248
+ timeoutSeconds: replyTimeout,
249
+ afterSeq: 0,
250
+ now: deps.now,
251
+ sleep: deps.sleep,
252
+ })
253
+ recordReplies(deps, receipt.request_id, result.response.replies)
254
+ if (flags.json) {
255
+ deps.io.out(
256
+ JSON.stringify(replyResultJson(result.response, result.degraded)),
257
+ )
258
+ } else if (result.response.replies.length > 0) {
259
+ printReplies(deps, result.response.replies)
260
+ printAcknowledgementStatus(deps, result.response)
261
+ } else {
262
+ printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
263
+ printAcknowledgementStatus(deps, result.response)
264
+ }
265
+ if (result.degraded) {
266
+ log(deps).error('cli.error', {
267
+ kind: 'network',
268
+ operation: 'reply_wait',
269
+ request_id: receipt.request_id,
270
+ degraded: true,
271
+ message: 'the reply wait ended while the server was unreachable or faulting',
272
+ })
273
+ deps.io.err(degradedWaitWarning(receipt.request_id))
274
+ return EXIT.network
275
+ }
276
+ if (result.timedOut) {
277
+ deps.io.err(
278
+ `No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
279
+ `\`notifai close ${receipt.request_id}\`. ` +
280
+ `This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`,
281
+ )
282
+ }
283
+ return result.timedOut ? EXIT.noReply : EXIT.ok
284
+ } 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.
288
+ if (err instanceof ApiCallError || err instanceof NetworkError) {
289
+ log(deps).error('cli.error', {
290
+ kind: err instanceof ApiCallError ? 'api' : 'network',
291
+ operation: 'reply_wait',
292
+ request_id: receipt.request_id,
293
+ ...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
294
+ message: err.message,
295
+ })
296
+ deps.io.err(
297
+ `notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
298
+ `Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
299
+ )
300
+ if (err instanceof ApiCallError) {
301
+ if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
302
+ return err.status >= 500 || err.status === 429 || err.status === 408
303
+ ? EXIT.network
304
+ : EXIT.failed
305
+ }
306
+ return EXIT.network
307
+ }
308
+ return reportError(deps, err)
309
+ }
310
+ }
311
+
312
+ function emitSendWarnings(
313
+ deps: CommandDeps,
314
+ flags: SendFlags,
315
+ config: ReturnType<typeof loadConfig>,
316
+ ): void {
317
+ if (flags.title.length > 40) {
318
+ deps.io.err(
319
+ `Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`,
320
+ )
321
+ }
322
+ if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
323
+ deps.io.err(
324
+ 'Heads up: keep the title to the specific substance. Put notification type in --kind; project identity is inferred separately.',
325
+ )
326
+ }
327
+ if (
328
+ flags.collapseKey === undefined &&
329
+ config.collapse_key.value !== null &&
330
+ config.collapse_key.source.startsWith('global:')
331
+ ) {
332
+ deps.io.err(
333
+ 'Heads up: collapse_key comes from machine-global config, so unrelated notifications may replace each other. Prefer a project or command-specific --collapse-key.',
334
+ )
335
+ }
336
+ if (flags.ttl !== undefined && flags.ttl > 259_200) {
337
+ deps.io.err(
338
+ 'Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.',
339
+ )
340
+ }
341
+ }
342
+
343
+ export async function repliesCommand(
344
+ deps: CommandDeps,
345
+ requestedId: string | undefined,
346
+ flags: { wait?: number; after?: number; json?: boolean; pending?: boolean },
347
+ ): Promise<number> {
348
+ const waitSeconds = flags.wait ?? 0
349
+ const afterSeq = flags.after ?? 0
350
+ if (!isNonNegativeInteger(waitSeconds)) {
351
+ deps.io.err('--wait must be a non-negative integer number of seconds.')
352
+ return EXIT.usage
353
+ }
354
+ if (!isNonNegativeInteger(afterSeq)) {
355
+ deps.io.err('--after must be a non-negative integer sequence number.')
356
+ return EXIT.usage
357
+ }
358
+
359
+ if (flags.pending === true && requestedId !== undefined) {
360
+ deps.io.err('Pass a request id or --pending, not both.')
361
+ return EXIT.usage
362
+ }
363
+ let requestIds = requestedId === undefined ? [] : [requestedId]
364
+ if (flags.pending === true) {
365
+ const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
366
+ if (sessionId === null) {
367
+ deps.io.err('No active session pointer is available in this directory.')
368
+ return EXIT.noReply
369
+ }
370
+ // Every delivered question in the session's queue, in registration order —
371
+ // an agent may have several outstanding at once.
372
+ const state = readSessionState(sessionId, deps.env)
373
+ requestIds = (Array.isArray(state.pending) ? state.pending : [])
374
+ .map((entry) => entry.request_id)
375
+ .filter((id): id is string => id !== undefined)
376
+ if (requestIds.length === 0) {
377
+ // `--json` is a promise about stdout, and an empty result is still a
378
+ // result: a caller that asked for machine-readable output must not have
379
+ // to parse an English sentence to learn there was nothing pending.
380
+ if (flags.json === true) {
381
+ deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2))
382
+ } else {
383
+ deps.io.err(`Session ${sessionId} has no pushed question pending.`)
384
+ }
385
+ return EXIT.noReply
386
+ }
387
+ }
388
+ if (requestIds.length === 0) {
389
+ deps.io.err('Pass a request id or --pending.')
390
+ return EXIT.usage
391
+ }
392
+
393
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
394
+ const authed = authedClient(deps, config)
395
+ if (!authed) return EXIT.auth
396
+ try {
397
+ let anyReplies = false
398
+ const degradedRequestIds: string[] = []
399
+ let allTimedOut = true
400
+ const jsonBodies: object[] = []
401
+ for (const requestId of requestIds) {
402
+ try {
403
+ const result = await waitForReply(authed.client, requestId, {
404
+ timeoutSeconds: waitSeconds,
405
+ afterSeq,
406
+ now: deps.now,
407
+ sleep: deps.sleep,
408
+ })
409
+ recordReplies(deps, requestId, result.response.replies)
410
+ if (flags.json) {
411
+ jsonBodies.push(replyResultJson(result.response, result.degraded))
412
+ } else if (result.response.replies.length > 0) {
413
+ if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
414
+ printReplies(deps, result.response.replies)
415
+ printAcknowledgementStatus(deps, result.response)
416
+ } else {
417
+ printNoReply(deps, requestId, result.response.reply_expires_at)
418
+ printAcknowledgementStatus(deps, result.response)
419
+ }
420
+ anyReplies ||= result.response.replies.length > 0
421
+ if (result.degraded) degradedRequestIds.push(requestId)
422
+ allTimedOut &&= result.timedOut
423
+ } catch (err) {
424
+ recordDegradedReplyWaits(deps, degradedRequestIds)
425
+ return reportError(deps, err, { operation: 'reply_wait', request_id: requestId })
426
+ }
427
+ }
428
+ if (flags.json) {
429
+ // One request keeps the response shape agents already parse; several
430
+ // (only possible via --pending) arrive as an array in queue order.
431
+ deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
432
+ }
433
+ if (degradedRequestIds.length > 0) {
434
+ recordDegradedReplyWaits(deps, degradedRequestIds)
435
+ // Name a request whose polls actually degraded, not merely the first item
436
+ // in a multi-request queue that may have completed cleanly.
437
+ deps.io.err(degradedWaitWarning(degradedRequestIds[0]!))
438
+ return EXIT.network
439
+ }
440
+ if (anyReplies) return EXIT.ok
441
+ return allTimedOut ? EXIT.noReply : EXIT.ok
442
+ } catch (err) {
443
+ return reportError(deps, err)
444
+ }
445
+ }
446
+
447
+
448
+ function recordDegradedReplyWaits(deps: CommandDeps, requestIds: readonly string[]): void {
449
+ if (requestIds.length === 0) return
450
+ log(deps).error('cli.error', {
451
+ kind: 'network',
452
+ operation: 'reply_wait',
453
+ request_ids: requestIds,
454
+ degraded: true,
455
+ message: 'the reply wait ended while the server was unreachable or faulting',
456
+ })
457
+ }
458
+
459
+ /**
460
+ * Shared by every surface that waits: "the user did not answer" and "I could
461
+ * not find out" must not look the same, because agents branch on the exit code
462
+ * and one of those two branches is safe to proceed from.
463
+ *
464
+ * Delivery / Companion Receipt / OS presentation are separate facts — a wait
465
+ * fault is not evidence that the push failed.
466
+ */
467
+ function degradedWaitWarning(requestId: string): string {
468
+ return (
469
+ `notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
470
+ `so this is "could not find out", not "no answer" — the request is still durable and the ` +
471
+ `reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
472
+ `Retry with \`notifai replies ${requestId}\`.`
473
+ )
474
+ }
475
+ function isNonNegativeInteger(value: number): boolean {
476
+ return Number.isInteger(value) && value >= 0
477
+ }
478
+
479
+
480
+ function replyResultJson(response: ListRepliesResponse, degraded: boolean): object {
481
+ return {
482
+ type: 'reply_result',
483
+ request_id: response.request_id,
484
+ reply_expires_at: response.reply_expires_at,
485
+ replies: response.replies,
486
+ agent_acknowledgement_required: response.agent_acknowledgement_required,
487
+ agent_acknowledgement: response.agent_acknowledgement,
488
+ agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
489
+ acknowledgement_command: acknowledgementCommand(
490
+ response.request_id,
491
+ response.agent_acknowledgement_required,
492
+ response.agent_acknowledgement_text_required,
493
+ response.agent_acknowledgement,
494
+ response.replies.length > 0,
495
+ ),
496
+ degraded,
497
+ }
498
+ }
499
+
500
+
501
+ function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
502
+ const logger = log(deps)
503
+ for (const reply of replies) {
504
+ logger.info('reply.received', {
505
+ request_id: requestId,
506
+ sequence: reply.seq,
507
+ device: reply.device_name,
508
+ text: reply.text,
509
+ answers: reply.answers,
510
+ source: reply.source,
511
+ })
512
+ }
513
+ }
514
+
515
+ function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
516
+ for (const reply of replies) deps.io.out(`reply from ${reply.device_name}: ${reply.text}`)
517
+ const contradiction = contradictingAnswer(replies)
518
+ if (contradiction !== null) deps.io.err(contradiction)
519
+ }
520
+
521
+ /**
522
+ * The correction note for a wait that saw more than one answer.
523
+ *
524
+ * A later reply that conflicts with an earlier one is a correction, so the
525
+ * latest answer is the one that counts. The server retires the question on
526
+ * the other devices the moment one answers, which makes this rare; it is
527
+ * still reachable, because a device can answer between the first reply
528
+ * landing and its retirement arriving.
529
+ *
530
+ * Ordered by seq, so `replies.at(-1)` is the answer to act on.
531
+ */
532
+ export function contradictingAnswer(replies: ReplyView[]): string | null {
533
+ const winner = replies.at(-1)
534
+ if (winner === undefined || replies.length < 2) return null
535
+ const key = (reply: ReplyView) =>
536
+ reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text
537
+ const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner))
538
+ if (corrected.length === 0) return null
539
+ const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ')
540
+ return (
541
+ `note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
542
+ `answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`
543
+ )
544
+ }
545
+
546
+ function printNoReply(deps: CommandDeps, requestId: string, expiresAt?: string | null): void {
547
+ // A harness hook may have retired this question, in which case promising an
548
+ // open window would send the caller back to wait for an answer the server
549
+ // will now refuse.
550
+ const closed = expiresAt != null && Date.parse(expiresAt) <= Date.now()
551
+ deps.io.out(
552
+ closed
553
+ ? `no reply for request ${requestId}; the reply window has closed`
554
+ : `no reply yet for request ${requestId}; the reply window remains open`,
555
+ )
556
+ }
557
+
558
+ export async function statusCommand(
559
+ deps: CommandDeps,
560
+ requestId: string,
561
+ flags: { json?: boolean },
562
+ ): Promise<number> {
563
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
564
+ const authed = authedClient(deps, config)
565
+ if (!authed) return EXIT.auth
566
+ try {
567
+ const snapshot = await authed.client.evidence(requestId)
568
+ if (flags.json) {
569
+ deps.io.out(JSON.stringify(snapshot, null, 2))
570
+ return EXIT.ok
571
+ }
572
+ deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
573
+ let anyReplyReceived = false
574
+ for (const d of snapshot.deliveries) {
575
+ deps.io.out(` ${d.device_name}:`)
576
+ deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`)
577
+ deps.io.out(
578
+ ` Provider Acceptance: ${d.state === 'provider_accepted' ? 'accepted' : 'not recorded'}`,
579
+ )
580
+ if (d.companion_receipt.state === 'observed') {
581
+ const latency = d.companion_receipt.latency_ms
582
+ deps.io.out(
583
+ ` Companion Receipt (the app's delivery confirmation): observed at ${d.companion_receipt.observed_at}` +
584
+ (latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`),
585
+ )
586
+ } else {
587
+ deps.io.out(
588
+ " Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt",
589
+ )
590
+ }
591
+ // Notifai never learns whether the OS painted a banner; saying so stops
592
+ // a reply-wait fault from being misread as "the phone never showed it".
593
+ deps.io.out(
594
+ ' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown',
595
+ )
596
+ const replyEvent = d.events.find((e) => e.stage === 'reply_received')
597
+ if (replyEvent) {
598
+ anyReplyReceived = true
599
+ deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`)
600
+ } else {
601
+ deps.io.out(' Reply received: not yet recorded on this delivery')
602
+ }
603
+ for (const e of d.events) {
604
+ deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
605
+ }
606
+ }
607
+ deps.io.out(
608
+ anyReplyReceived
609
+ ? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
610
+ : ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``,
611
+ )
612
+ return EXIT.ok
613
+ } catch (err) {
614
+ return reportError(deps, err)
615
+ }
616
+ }
617
+
618
+ function formatElapsed(milliseconds: number): string {
619
+ const totalSeconds = Math.floor(milliseconds / 1_000)
620
+ const minutes = Math.floor(totalSeconds / 60)
621
+ const seconds = totalSeconds % 60
622
+ return minutes === 0 ? `${seconds}s` : `${minutes}m ${seconds}s`
623
+ }
@@ -0,0 +1,71 @@
1
+ import { type EvidenceSnapshot } from '@raidiant/notifai-protocol'
2
+ import { createHash } from 'node:crypto'
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
10
+ import path from 'node:path'
11
+ import { stateDir } from './config.js'
12
+ import type { CommandDeps } from './commands-core.js'
13
+
14
+ interface SetupProofRecord {
15
+ request_id: string
16
+ device_id: string
17
+ project: string | null
18
+ started_at: string
19
+ }
20
+
21
+ function setupProofPath(deps: CommandDeps): string {
22
+ let projectDir = path.resolve(deps.cwd)
23
+ try {
24
+ projectDir = realpathSync(projectDir)
25
+ } catch {
26
+ // A deleted or not-yet-created cwd cannot collide with a real directory:
27
+ // the resolved absolute path is still a stable local identity for it.
28
+ }
29
+ const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32)
30
+ return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`)
31
+ }
32
+
33
+ export function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
34
+ const file = setupProofPath(deps)
35
+ if (!existsSync(file)) return null
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<SetupProofRecord>
38
+ return typeof parsed.request_id === 'string' &&
39
+ typeof parsed.device_id === 'string' &&
40
+ (typeof parsed.project === 'string' || parsed.project === null) &&
41
+ typeof parsed.started_at === 'string'
42
+ ? (parsed as SetupProofRecord)
43
+ : null
44
+ } catch {
45
+ // Corrupt local evidence is not readiness. A fresh proof replaces it.
46
+ return null
47
+ }
48
+ }
49
+
50
+ export function writeSetupProof(deps: CommandDeps, proof: SetupProofRecord): boolean {
51
+ const file = setupProofPath(deps)
52
+ try {
53
+ mkdirSync(path.dirname(file), { recursive: true })
54
+ writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 })
55
+ return true
56
+ } catch (err) {
57
+ deps.io.err(
58
+ `Could not save setup proof ${proof.request_id} at ${file}: ${String(err)}`,
59
+ )
60
+ return false
61
+ }
62
+ }
63
+
64
+ export function observedCompanionReceipt(
65
+ snapshot: EvidenceSnapshot,
66
+ deviceId: string,
67
+ ): { delivery: EvidenceSnapshot['deliveries'][number]; observedAt: string } | null {
68
+ const delivery = snapshot.deliveries.find((candidate) => candidate.device_id === deviceId)
69
+ const receipt = delivery?.events.find((event) => event.stage === 'companion_received')
70
+ return delivery && receipt ? { delivery, observedAt: receipt.occurred_at } : null
71
+ }