@raidiant/notifai 7.0.2 → 9.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 (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
@@ -1,8 +1,9 @@
1
- import { type ListRepliesResponse } from '@raidiant/notifai-protocol'
1
+ import { NOTIFICATION_IMAGE_MAX_BYTES, type ListRepliesResponse } from '@raidiant/notifai-protocol'
2
2
  import { sha256Hex } from '@raidiant/notifai-protocol/node'
3
3
  import { existsSync, readFileSync } from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { isRetryableReplyPollError, type ApiClient } from './client.js'
6
+ import { fetchMediaUrl } from './url-policy.js'
6
7
  import { buildSourceContext, inferInvocationContext } from './invocation-context.js'
7
8
  import { readOrcaSessionTitle } from './orca-session-title.js'
8
9
  import { type DraftInvocation, type SendFlags } from './send.js'
@@ -15,10 +16,12 @@ function managedSessionTitle(
15
16
  active: ActiveHarnessSession | null,
16
17
  ): string | undefined {
17
18
  if (active?.sessionLabel !== undefined) return active.sessionLabel
19
+ // The Orca worktree title names the workspace, not one harness: every
20
+ // harness session Orca starts inside that worktree inherits the same
21
+ // environment selector, so any active session with an identity to label
22
+ // may use it. An explicit label still wins without paying for the lookup.
18
23
  if (
19
- active?.harness !== 'claude-code' ||
20
- active.sessionId === undefined ||
21
- active.sessionId !== deps.env['CLAUDE_CODE_SESSION_ID'] ||
24
+ active?.sessionId === undefined ||
22
25
  flags.sessionLabel !== undefined ||
23
26
  deps.env['NOTIFAI_SESSION_LABEL'] !== undefined
24
27
  ) {
@@ -207,17 +210,78 @@ type UploadResult =
207
210
  /** `error: null` means `reportError` already said it; do not print it twice. */
208
211
  | { ok: false; error: string | null; exit: number }
209
212
 
210
- /** `--image` accepts a media id, a local file path, or an http(s) URL. */
211
- export async function uploadImage(deps: CommandDeps, client: ApiClient, source: string): Promise<UploadResult> {
213
+ /**
214
+ * Read a remote image without buffering more than the server will accept.
215
+ *
216
+ * Content-Length is an early refusal, not trust: a lying header still hits
217
+ * the byte cap while the body is read. Supported schemes stay http and https.
218
+ */
219
+ export async function readCappedBytes(
220
+ response: Response,
221
+ maxBytes: number,
222
+ ): Promise<Uint8Array | 'too-large'> {
223
+ const contentLength = Number(response.headers.get('content-length'))
224
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) return 'too-large'
225
+ const reader = response.body?.getReader()
226
+ if (reader === undefined) {
227
+ const buf = new Uint8Array(await response.arrayBuffer())
228
+ return buf.byteLength > maxBytes ? 'too-large' : buf
229
+ }
230
+ const chunks: Uint8Array[] = []
231
+ let received = 0
232
+ while (true) {
233
+ const { done, value } = await reader.read()
234
+ if (done) break
235
+ received += value.byteLength
236
+ if (received > maxBytes) {
237
+ await reader.cancel()
238
+ return 'too-large'
239
+ }
240
+ chunks.push(value)
241
+ }
242
+ const out = new Uint8Array(received)
243
+ let offset = 0
244
+ for (const chunk of chunks) {
245
+ out.set(chunk, offset)
246
+ offset += chunk.byteLength
247
+ }
248
+ return out
249
+ }
250
+
251
+ /**
252
+ * `--image` accepts a media id, a local file path, or an http(s) URL.
253
+ *
254
+ * A remote URL is fetched under the media URL policy (`url-policy.ts`):
255
+ * public HTTPS by default, every redirect hop re-validated, and non-public
256
+ * destinations only through the User's `media_origins` — never a
257
+ * repository's. `--image` values routinely arrive from an agent that read
258
+ * them in a working tree, so the URL is hostile input until proven otherwise.
259
+ */
260
+ export async function uploadImage(
261
+ deps: CommandDeps,
262
+ client: ApiClient,
263
+ source: string,
264
+ allowOrigins: readonly string[] = [],
265
+ ): Promise<UploadResult> {
212
266
  let bytes: Uint8Array
213
267
  let mediaType: 'image/jpeg' | 'image/png' | 'image/gif' | undefined
214
268
  if (/^https?:\/\//.test(source)) {
215
269
  try {
216
- const response = await fetch(source)
270
+ const fetched = await fetchMediaUrl(source, { allowOrigins }, deps.fetchImpl)
271
+ if (!fetched.ok) return { ok: false, error: fetched.reason, exit: EXIT.usage }
272
+ const response = fetched.response
217
273
  if (!response.ok) return { ok: false, error: `Could not fetch ${source} (${response.status}).`, exit: EXIT.usage }
218
274
  const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
219
275
  mediaType = (['image/jpeg', 'image/png', 'image/gif'] as const).find((t) => t === contentType)
220
- bytes = new Uint8Array(await response.arrayBuffer())
276
+ const downloaded = await readCappedBytes(response, NOTIFICATION_IMAGE_MAX_BYTES)
277
+ if (downloaded === 'too-large') {
278
+ return {
279
+ ok: false,
280
+ error: `Remote image exceeds the ${NOTIFICATION_IMAGE_MAX_BYTES} byte media limit.`,
281
+ exit: EXIT.usage,
282
+ }
283
+ }
284
+ bytes = downloaded
221
285
  } catch (err) {
222
286
  return { ok: false, error: `Could not fetch ${source}: ${String(err)}`, exit: EXIT.network }
223
287
  }
@@ -14,9 +14,11 @@ import {
14
14
  buildDraft,
15
15
  formatReceipt,
16
16
  receiptExitCode,
17
+ rejectAccidentalEscapedNewlines,
17
18
  validateMediaInputs,
18
19
  type SendFlags,
19
20
  } from './send.js'
21
+ import { recordObservedDeliveryProof } from './commands-setup-proof.js'
20
22
  import {
21
23
  EXIT,
22
24
  authedClient,
@@ -46,14 +48,13 @@ export async function sendCommand(
46
48
  wait?: number
47
49
  noWait?: boolean
48
50
  replyTimeout?: number
49
- noBlock?: boolean
50
51
  idempotencyKey?: string
51
52
  baseUrl?: string
52
53
  },
53
54
  ): Promise<number> {
54
- const hasReplyChoice = Array.isArray(flags.replyChoice)
55
- ? flags.replyChoice.length > 0
56
- : flags.replyChoice !== undefined
55
+ const hasChoice = Array.isArray(flags.choice)
56
+ ? flags.choice.length > 0
57
+ : flags.choice !== undefined
57
58
  if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
58
59
  deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
59
60
  return EXIT.usage
@@ -64,7 +65,7 @@ export async function sendCommand(
64
65
  if (!flags.reply && flags.kind === undefined) {
65
66
  deps.io.err(
66
67
  '--kind is required: say what this notification is. ' +
67
- 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
68
+ 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed; no User reply would resume it). ' +
68
69
  '--reply makes it a question without --kind.',
69
70
  )
70
71
  return EXIT.usage
@@ -75,9 +76,9 @@ export async function sendCommand(
75
76
  }
76
77
  if (
77
78
  !flags.reply &&
78
- (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)
79
+ (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || hasChoice)
79
80
  ) {
80
- deps.io.err('Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.')
81
+ deps.io.err('Use --reply with --reply-timeout, --reply-window, or --choice.')
81
82
  return EXIT.usage
82
83
  }
83
84
  const replyTimeout = flags.replyTimeout ?? 900
@@ -91,13 +92,10 @@ export async function sendCommand(
91
92
  // request id. So the user gets a real button, taps it, and nothing happens —
92
93
  // worse than a banner that never asked, because it spends their attention
93
94
  // 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)) {
95
+ if (flags.reply && replyTimeout === 0) {
98
96
  deps.io.err(
99
97
  'A question needs someone to hear the answer, so --reply cannot be combined ' +
100
- 'with --no-block or --reply-timeout 0.\n' +
98
+ 'with --reply-timeout 0.\n' +
101
99
  'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
102
100
  'To announce finished work, drop --reply and its choices.',
103
101
  )
@@ -115,6 +113,11 @@ export async function sendCommand(
115
113
  )
116
114
  return EXIT.usage
117
115
  }
116
+ const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
117
+ if (escapedBody !== null) {
118
+ deps.io.err(escapedBody)
119
+ return EXIT.usage
120
+ }
118
121
  const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
119
122
  if (mediaInputError !== null) {
120
123
  deps.io.err(mediaInputError)
@@ -142,7 +145,7 @@ export async function sendCommand(
142
145
  mediaIds.push(image)
143
146
  continue
144
147
  }
145
- const uploaded = await uploadImage(deps, authed.client, image)
148
+ const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
146
149
  if (!uploaded.ok) {
147
150
  if (uploaded.error !== null) deps.io.err(uploaded.error)
148
151
  return uploaded.exit
@@ -165,17 +168,29 @@ export async function sendCommand(
165
168
  for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
166
169
  return EXIT.usage
167
170
  }
171
+ for (const warning of validation.warnings) {
172
+ deps.io.err(`Heads up (${warning.path}): ${warning.message}`)
173
+ }
168
174
  emitSendWarnings(deps, flags, config)
169
175
  if (
170
176
  !flags.reply &&
171
177
  (flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
172
178
  ) {
173
179
  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.',
180
+ '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
181
  )
176
182
  }
177
183
  const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
178
184
  const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
185
+ // Persist the retry identity before entering the ambiguous network boundary.
186
+ // If the process is killed after the server accepts but before a response,
187
+ // this is the only durable way to reconstruct the same Agent Event key.
188
+ log(deps).info('send.attempt', {
189
+ idempotency_key: idempotencyKey,
190
+ kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
191
+ title: flags.title,
192
+ session_id: source.invocation.source?.session_id ?? null,
193
+ })
179
194
  let receipt: SubmissionReceipt
180
195
  try {
181
196
  receipt = await authed.client.submit(
@@ -183,7 +198,20 @@ export async function sendCommand(
183
198
  waitSeconds,
184
199
  )
185
200
  } catch (err) {
186
- return reportError(deps, err)
201
+ const exit = reportError(deps, err)
202
+ if (exit === EXIT.network) {
203
+ deps.io.err(
204
+ 'The server may have accepted this Notification Request before the connection failed. ' +
205
+ `Retry the same send with \`--idempotency-key ${idempotencyKey}\` so this Agent Event cannot create a duplicate.`,
206
+ )
207
+ }
208
+ if (err instanceof ApiCallError && err.code === 'no_active_devices') {
209
+ deps.io.err(
210
+ 'After setup succeeds, repeat this original Notification Request with ' +
211
+ `\`--idempotency-key ${idempotencyKey}\`; the setup verification request does not replace this Agent Event.`,
212
+ )
213
+ }
214
+ return exit
187
215
  }
188
216
  const receiptExit = receiptExitCode(receipt)
189
217
  // The single most useful line in the log: it ties the local invocation to
@@ -192,7 +220,7 @@ export async function sendCommand(
192
220
  log(deps).info('send.submitted', {
193
221
  request_id: receipt.request_id,
194
222
  kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
195
- title: flags.title,
223
+ title_chars: flags.title.length,
196
224
  overall: receipt.overall,
197
225
  replayed: receipt.replayed,
198
226
  agent_acknowledgement_required: receipt.agent_acknowledgement_required,
@@ -220,23 +248,14 @@ export async function sendCommand(
220
248
  receipt.overall === 'provider_accepted_all' &&
221
249
  receipt.warnings.length === 0
222
250
  if (!quietOrdinarySuccess) deps.io.out(formatReceipt(receipt))
223
- } else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
251
+ }
224
252
 
225
253
  // A zero wait can no longer reach here: --reply guarantees a positive one.
226
254
  if (!flags.reply || receiptExit !== EXIT.ok) {
227
255
  if (flags.json) {
228
256
  deps.io.out(
229
257
  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
- })
258
+ ? JSON.stringify(unansweredReplyResultJson(receipt, false))
240
259
  : JSON.stringify(receipt, null, 2),
241
260
  )
242
261
  }
@@ -253,7 +272,7 @@ export async function sendCommand(
253
272
  recordReplies(deps, receipt.request_id, result.response.replies)
254
273
  if (flags.json) {
255
274
  deps.io.out(
256
- JSON.stringify(replyResultJson(result.response, result.degraded)),
275
+ JSON.stringify(replyResultJson(result.response, result.degraded, receipt)),
257
276
  )
258
277
  } else if (result.response.replies.length > 0) {
259
278
  printReplies(deps, result.response.replies)
@@ -282,9 +301,9 @@ export async function sendCommand(
282
301
  }
283
302
  return result.timedOut ? EXIT.noReply : EXIT.ok
284
303
  } 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.
304
+ // The send already succeeded durably; a wait fault must not read as "send
305
+ // failed". Permanent poll errors (auth, closed window, not found) still
306
+ // surface, but always name the durable request and point at recovery.
288
307
  if (err instanceof ApiCallError || err instanceof NetworkError) {
289
308
  log(deps).error('cli.error', {
290
309
  kind: err instanceof ApiCallError ? 'api' : 'network',
@@ -297,6 +316,9 @@ export async function sendCommand(
297
316
  `notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
298
317
  `Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
299
318
  )
319
+ // JSON callers still get their one object: the durable receipt, no
320
+ // replies, and `degraded: true` — "could not find out", not "no answer".
321
+ if (flags.json) deps.io.out(JSON.stringify(unansweredReplyResultJson(receipt, true)))
300
322
  if (err instanceof ApiCallError) {
301
323
  if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
302
324
  return err.status >= 500 || err.status === 429 || err.status === 408
@@ -370,19 +392,52 @@ export async function repliesCommand(
370
392
  // Every delivered question in the session's queue, in registration order —
371
393
  // an agent may have several outstanding at once.
372
394
  const state = readSessionState(sessionId, deps.env)
373
- requestIds = (Array.isArray(state.pending) ? state.pending : [])
395
+ const pending = Array.isArray(state.pending) ? state.pending : []
396
+ requestIds = pending
374
397
  .map((entry) => entry.request_id)
375
398
  .filter((id): id is string => id !== undefined)
399
+ const unsent = pending.filter((entry) => entry.request_id === undefined)
376
400
  if (requestIds.length === 0) {
377
401
  // `--json` is a promise about stdout, and an empty result is still a
378
402
  // result: a caller that asked for machine-readable output must not have
379
403
  // to parse an English sentence to learn there was nothing pending.
380
404
  if (flags.json === true) {
381
- deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2))
405
+ deps.io.out(
406
+ JSON.stringify(
407
+ {
408
+ session_id: sessionId,
409
+ pending: unsent.map((entry) => ({
410
+ question: entry.question,
411
+ ...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
412
+ })),
413
+ replies: [],
414
+ },
415
+ null,
416
+ 2,
417
+ ),
418
+ )
419
+ } else if (unsent.length > 0) {
420
+ for (const entry of unsent) {
421
+ deps.io.out(
422
+ entry.question_id === undefined
423
+ ? `unpushed question: ${entry.question}`
424
+ : `unpushed question ${entry.question_id}: ${entry.question}`,
425
+ )
426
+ }
427
+ deps.io.out('Retire one with `notifai close <question_id>` so a later Stop will not send it.')
382
428
  } else {
383
429
  deps.io.err(`Session ${sessionId} has no pushed question pending.`)
384
430
  }
385
- return EXIT.noReply
431
+ return unsent.length > 0 ? EXIT.ok : EXIT.noReply
432
+ }
433
+ if (unsent.length > 0 && flags.json !== true) {
434
+ for (const entry of unsent) {
435
+ deps.io.out(
436
+ entry.question_id === undefined
437
+ ? `unpushed question: ${entry.question}`
438
+ : `unpushed question ${entry.question_id}: ${entry.question}`,
439
+ )
440
+ }
386
441
  }
387
442
  }
388
443
  if (requestIds.length === 0) {
@@ -477,9 +532,19 @@ function isNonNegativeInteger(value: number): boolean {
477
532
  }
478
533
 
479
534
 
480
- function replyResultJson(response: ListRepliesResponse, degraded: boolean): object {
535
+ /**
536
+ * The one JSON object `send --reply --json` prints, whatever happens after the
537
+ * durable submit. Embedding the receipt keeps stdout a single parseable line;
538
+ * `replies` (from a wait) omits it because there is no submission to report.
539
+ */
540
+ function replyResultJson(
541
+ response: ListRepliesResponse,
542
+ degraded: boolean,
543
+ receipt?: SubmissionReceipt,
544
+ ): object {
481
545
  return {
482
546
  type: 'reply_result',
547
+ ...(receipt !== undefined ? { receipt } : {}),
483
548
  request_id: response.request_id,
484
549
  reply_expires_at: response.reply_expires_at,
485
550
  replies: response.replies,
@@ -497,6 +562,25 @@ function replyResultJson(response: ListRepliesResponse, degraded: boolean): obje
497
562
  }
498
563
  }
499
564
 
565
+ /**
566
+ * The reply_result for a reply send that has no answer to report — a receipt
567
+ * failure, or a wait that faulted. Same shape, same single line; `degraded`
568
+ * distinguishes "the wait could not find out" from "delivery already failed".
569
+ */
570
+ function unansweredReplyResultJson(receipt: SubmissionReceipt, degraded: boolean): object {
571
+ return {
572
+ type: 'reply_result',
573
+ receipt,
574
+ request_id: receipt.request_id,
575
+ replies: [],
576
+ agent_acknowledgement_required: receipt.agent_acknowledgement_required,
577
+ agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
578
+ agent_acknowledgement: null,
579
+ acknowledgement_command: null,
580
+ degraded,
581
+ }
582
+ }
583
+
500
584
 
501
585
  function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
502
586
  const logger = log(deps)
@@ -505,8 +589,8 @@ function recordReplies(deps: CommandDeps, requestId: string, replies: readonly R
505
589
  request_id: requestId,
506
590
  sequence: reply.seq,
507
591
  device: reply.device_name,
508
- text: reply.text,
509
- answers: reply.answers,
592
+ text_chars: reply.text.length,
593
+ answers_count: reply.answers?.length ?? 0,
510
594
  source: reply.source,
511
595
  })
512
596
  }
@@ -565,11 +649,12 @@ export async function statusCommand(
565
649
  if (!authed) return EXIT.auth
566
650
  try {
567
651
  const snapshot = await authed.client.evidence(requestId)
652
+ recordObservedDeliveryProof(deps, snapshot, config.project.value)
568
653
  if (flags.json) {
569
654
  deps.io.out(JSON.stringify(snapshot, null, 2))
570
655
  return EXIT.ok
571
656
  }
572
- deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
657
+ deps.io.out(`request ${snapshot.request_id} — ${snapshot.overall}`)
573
658
  let anyReplyReceived = false
574
659
  for (const d of snapshot.deliveries) {
575
660
  deps.io.out(` ${d.device_name}:`)
@@ -66,6 +66,40 @@ export function observedCompanionReceipt(
66
66
  deviceId: string,
67
67
  ): { delivery: EvidenceSnapshot['deliveries'][number]; observedAt: string } | null {
68
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
69
+ if (!delivery) return null
70
+ if (delivery.companion_receipt.state === 'observed' && delivery.companion_receipt.observed_at) {
71
+ return { delivery, observedAt: delivery.companion_receipt.observed_at }
72
+ }
73
+ const receipt = delivery.events.find((event) => event.stage === 'companion_received')
74
+ return receipt ? { delivery, observedAt: receipt.occurred_at } : null
75
+ }
76
+
77
+ /** Replace an unknown setup proof after this age; recent in-flight proofs stay. */
78
+ export const SETUP_PROOF_STALE_MS = 24 * 60 * 60 * 1000
79
+
80
+ export function setupProofIsStale(proof: SetupProofRecord, now: number): boolean {
81
+ const started = Date.parse(proof.started_at)
82
+ return Number.isFinite(started) && now - started > SETUP_PROOF_STALE_MS
83
+ }
84
+
85
+ /**
86
+ * Persist ordinary send/status Companion Receipts as the project's delivery
87
+ * proof so doctor does not demand a second verification request.
88
+ */
89
+ export function recordObservedDeliveryProof(
90
+ deps: CommandDeps,
91
+ snapshot: EvidenceSnapshot,
92
+ project: string | null,
93
+ ): boolean {
94
+ for (const delivery of snapshot.deliveries) {
95
+ const observed = observedCompanionReceipt(snapshot, delivery.device_id)
96
+ if (observed === null) continue
97
+ return writeSetupProof(deps, {
98
+ request_id: snapshot.request_id,
99
+ device_id: delivery.device_id,
100
+ project,
101
+ started_at: observed.observedAt,
102
+ })
103
+ }
104
+ return false
71
105
  }
@@ -1,4 +1,4 @@
1
- import type { NativeSkill, SkillScope } from './native-skills.js'
1
+ import { SKILLS_INSTALLER_SPEC, type NativeSkill, type SkillScope } from './native-skills.js'
2
2
  import { type ReadinessState } from './readiness.js'
3
3
  import { skillsSource } from './release.js'
4
4
  import type { CommandDeps } from './commands-core.js'
@@ -18,6 +18,8 @@ export const SKILLS_SOURCE: string | null = skillsSource()
18
18
  */
19
19
  const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching this CLI'
20
20
 
21
+ const SKILL_SCOPES: readonly SkillScope[] = ['project', 'global']
22
+
21
23
  function skillSourceParts(): { source: string; ref: string } | null {
22
24
  if (SKILLS_SOURCE === null) return null
23
25
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
@@ -35,13 +37,23 @@ function expectedSkill(skill: NativeSkill): boolean {
35
37
  )
36
38
  }
37
39
 
38
- export async function skillReadiness(
39
- deps: CommandDeps,
40
- selectedScope?: SkillScope,
41
- ): Promise<ReadinessState> {
42
- const scopes: SkillScope[] = selectedScope === undefined ? ['project', 'global'] : [selectedScope]
40
+ function skillPin(skill: NativeSkill): string {
41
+ return skill.ref ?? 'unknown pin'
42
+ }
43
+
44
+ export interface ScopedNotifaiSkills {
45
+ installed: NativeSkill[]
46
+ errors: string[]
47
+ }
48
+
49
+ /**
50
+ * Installer-managed notifai skills in both scopes. Duplicate detection has to
51
+ * see the pair; a selected-scope filter would hide the copy the harness still
52
+ * lists.
53
+ */
54
+ export async function listScopedNotifaiSkills(deps: CommandDeps): Promise<ScopedNotifaiSkills> {
43
55
  const results = await Promise.all(
44
- scopes.map(async (scope) => {
56
+ SKILL_SCOPES.map(async (scope) => {
45
57
  if (deps.nativeSkills === undefined) return { scope, skills: [] as NativeSkill[] }
46
58
  try {
47
59
  return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) }
@@ -50,19 +62,70 @@ export async function skillReadiness(
50
62
  }
51
63
  }),
52
64
  )
53
- const installed = results.flatMap(({ skills }) => skills).find(expectedSkill)
54
- if (installed !== undefined) {
65
+ return {
66
+ installed: results.flatMap(({ skills }) => skills.filter((skill) => skill.name === 'notifai')),
67
+ errors: results
68
+ .filter((result) => result.error !== undefined)
69
+ .map((result) => `${result.scope}: ${result.error}`),
70
+ }
71
+ }
72
+
73
+ function duplicateSkillState(installed: NativeSkill[], selectedScope?: SkillScope): ReadinessState {
74
+ const project = installed.find((skill) => skill.scope === 'project')
75
+ const global = installed.find((skill) => skill.scope === 'global')
76
+ const projectPin = project === undefined ? 'not installed' : skillPin(project)
77
+ const globalPin = global === undefined ? 'not installed' : skillPin(global)
78
+ return {
79
+ id: 'skill',
80
+ title: 'Agent guidance skill',
81
+ status: 'gap',
82
+ detail:
83
+ `project (${projectPin}) and global (${globalPin}) are both installed, so the harness lists both. ` +
84
+ 'Keep either project or global and uninstall the other.',
85
+ technical: {
86
+ project:
87
+ project === undefined
88
+ ? null
89
+ : { ref: project.ref, path: project.path, current: expectedSkill(project) },
90
+ global:
91
+ global === undefined
92
+ ? null
93
+ : { ref: global.ref, path: global.path, current: expectedSkill(global) },
94
+ resolution: 'both-listed',
95
+ },
96
+ remedy:
97
+ selectedScope === undefined
98
+ ? {
99
+ by: 'user-here',
100
+ summary:
101
+ 'keep either the project or the machine-global skill and uninstall the other; add --global to remove the machine-global copy',
102
+ command: `npx -y ${SKILLS_INSTALLER_SPEC} remove notifai`,
103
+ }
104
+ : {
105
+ by: 'cli',
106
+ summary: `keep the ${selectedScope} skill and uninstall the other`,
107
+ command: `notifai init --skills --setup-scope ${selectedScope}`,
108
+ },
109
+ }
110
+ }
111
+
112
+ export async function skillReadiness(
113
+ deps: CommandDeps,
114
+ selectedScope?: SkillScope,
115
+ ): Promise<ReadinessState> {
116
+ const { installed, errors } = await listScopedNotifaiSkills(deps)
117
+ if (installed.length > 1) return duplicateSkillState(installed, selectedScope)
118
+
119
+ const current = installed.find(expectedSkill)
120
+ if (current !== undefined) {
55
121
  return {
56
122
  id: 'skill',
57
123
  title: 'Agent guidance skill',
58
124
  status: 'ready',
59
- detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${installed.scope} scope`,
125
+ detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${current.scope} scope`,
60
126
  }
61
127
  }
62
128
 
63
- const errors = results
64
- .filter((result) => result.error !== undefined)
65
- .map((result) => `${result.scope}: ${result.error}`)
66
129
  const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`
67
130
  return {
68
131
  id: 'skill',
@@ -77,8 +140,8 @@ export async function skillReadiness(
77
140
  summary: 'install the skill agents follow when deciding to notify',
78
141
  command:
79
142
  selectedScope === undefined
80
- ? 'notifai init --skills'
81
- : `notifai init --skills --skills-scope ${selectedScope}`,
143
+ ? 'notifai init --skills --setup-scope project'
144
+ : `notifai init --skills --setup-scope ${selectedScope}`,
82
145
  },
83
146
  }
84
147
  }