@raidiant/notifai 8.0.0 → 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 (145) hide show
  1. package/CHANGELOG.md +41 -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-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. package/src/url-policy.ts +399 -0
@@ -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,
@@ -63,7 +65,7 @@ export async function sendCommand(
63
65
  if (!flags.reply && flags.kind === undefined) {
64
66
  deps.io.err(
65
67
  '--kind is required: say what this notification is. ' +
66
- '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). ' +
67
69
  '--reply makes it a question without --kind.',
68
70
  )
69
71
  return EXIT.usage
@@ -111,6 +113,11 @@ export async function sendCommand(
111
113
  )
112
114
  return EXIT.usage
113
115
  }
116
+ const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
117
+ if (escapedBody !== null) {
118
+ deps.io.err(escapedBody)
119
+ return EXIT.usage
120
+ }
114
121
  const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
115
122
  if (mediaInputError !== null) {
116
123
  deps.io.err(mediaInputError)
@@ -138,7 +145,7 @@ export async function sendCommand(
138
145
  mediaIds.push(image)
139
146
  continue
140
147
  }
141
- const uploaded = await uploadImage(deps, authed.client, image)
148
+ const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
142
149
  if (!uploaded.ok) {
143
150
  if (uploaded.error !== null) deps.io.err(uploaded.error)
144
151
  return uploaded.exit
@@ -175,6 +182,15 @@ export async function sendCommand(
175
182
  }
176
183
  const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
177
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
+ })
178
194
  let receipt: SubmissionReceipt
179
195
  try {
180
196
  receipt = await authed.client.submit(
@@ -182,7 +198,20 @@ export async function sendCommand(
182
198
  waitSeconds,
183
199
  )
184
200
  } catch (err) {
185
- 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
186
215
  }
187
216
  const receiptExit = receiptExitCode(receipt)
188
217
  // The single most useful line in the log: it ties the local invocation to
@@ -191,7 +220,7 @@ export async function sendCommand(
191
220
  log(deps).info('send.submitted', {
192
221
  request_id: receipt.request_id,
193
222
  kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
194
- title: flags.title,
223
+ title_chars: flags.title.length,
195
224
  overall: receipt.overall,
196
225
  replayed: receipt.replayed,
197
226
  agent_acknowledgement_required: receipt.agent_acknowledgement_required,
@@ -363,19 +392,52 @@ export async function repliesCommand(
363
392
  // Every delivered question in the session's queue, in registration order —
364
393
  // an agent may have several outstanding at once.
365
394
  const state = readSessionState(sessionId, deps.env)
366
- requestIds = (Array.isArray(state.pending) ? state.pending : [])
395
+ const pending = Array.isArray(state.pending) ? state.pending : []
396
+ requestIds = pending
367
397
  .map((entry) => entry.request_id)
368
398
  .filter((id): id is string => id !== undefined)
399
+ const unsent = pending.filter((entry) => entry.request_id === undefined)
369
400
  if (requestIds.length === 0) {
370
401
  // `--json` is a promise about stdout, and an empty result is still a
371
402
  // result: a caller that asked for machine-readable output must not have
372
403
  // to parse an English sentence to learn there was nothing pending.
373
404
  if (flags.json === true) {
374
- 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.')
375
428
  } else {
376
429
  deps.io.err(`Session ${sessionId} has no pushed question pending.`)
377
430
  }
378
- 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
+ }
379
441
  }
380
442
  }
381
443
  if (requestIds.length === 0) {
@@ -527,8 +589,8 @@ function recordReplies(deps: CommandDeps, requestId: string, replies: readonly R
527
589
  request_id: requestId,
528
590
  sequence: reply.seq,
529
591
  device: reply.device_name,
530
- text: reply.text,
531
- answers: reply.answers,
592
+ text_chars: reply.text.length,
593
+ answers_count: reply.answers?.length ?? 0,
532
594
  source: reply.source,
533
595
  })
534
596
  }
@@ -587,11 +649,12 @@ export async function statusCommand(
587
649
  if (!authed) return EXIT.auth
588
650
  try {
589
651
  const snapshot = await authed.client.evidence(requestId)
652
+ recordObservedDeliveryProof(deps, snapshot, config.project.value)
590
653
  if (flags.json) {
591
654
  deps.io.out(JSON.stringify(snapshot, null, 2))
592
655
  return EXIT.ok
593
656
  }
594
- deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
657
+ deps.io.out(`request ${snapshot.request_id} — ${snapshot.overall}`)
595
658
  let anyReplyReceived = false
596
659
  for (const d of snapshot.deliveries) {
597
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
  }