@raidiant/notifai 10.1.1 → 10.1.3

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 (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/commands-core.d.ts +4 -1
  3. package/dist/commands-core.js.map +1 -1
  4. package/dist/commands-doctor.js +2 -7
  5. package/dist/commands-doctor.js.map +1 -1
  6. package/dist/commands-hooks.d.ts +12 -0
  7. package/dist/commands-hooks.js +15 -1
  8. package/dist/commands-hooks.js.map +1 -1
  9. package/dist/commands-init.js +9 -6
  10. package/dist/commands-init.js.map +1 -1
  11. package/dist/commands-send-support.js +10 -11
  12. package/dist/commands-send-support.js.map +1 -1
  13. package/dist/commands-skill.js +72 -54
  14. package/dist/commands-skill.js.map +1 -1
  15. package/dist/harness-session-title.d.ts +22 -0
  16. package/dist/harness-session-title.js +74 -0
  17. package/dist/harness-session-title.js.map +1 -0
  18. package/dist/invocation-context.d.ts +1 -0
  19. package/dist/invocation-context.js +15 -1
  20. package/dist/invocation-context.js.map +1 -1
  21. package/dist/native-skills.d.ts +13 -3
  22. package/dist/native-skills.js +51 -12
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/node-floor.d.ts +2 -1
  25. package/dist/node-floor.js +9 -5
  26. package/dist/node-floor.js.map +1 -1
  27. package/dist/orca-session-title.d.ts +5 -4
  28. package/dist/orca-session-title.js +32 -19
  29. package/dist/orca-session-title.js.map +1 -1
  30. package/dist/platform.d.ts +11 -17
  31. package/dist/platform.js +20 -23
  32. package/dist/platform.js.map +1 -1
  33. package/dist/release.d.ts +5 -5
  34. package/dist/release.js +5 -5
  35. package/dist/session-labels.d.ts +2 -0
  36. package/dist/session-labels.js +20 -9
  37. package/dist/session-labels.js.map +1 -1
  38. package/dist/setup-destinations.js +1 -1
  39. package/dist/setup-destinations.js.map +1 -1
  40. package/dist/skill-integrity.d.ts +49 -0
  41. package/dist/skill-integrity.js +154 -0
  42. package/dist/skill-integrity.js.map +1 -0
  43. package/dist/skill-source/manifest.json +16 -0
  44. package/dist/skill-source/notifai/SKILL.md +390 -0
  45. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  46. package/package.json +11 -14
  47. package/src/atomic-file.ts +0 -198
  48. package/src/claude-wake.test.ts +0 -321
  49. package/src/claude-wake.ts +0 -499
  50. package/src/cli-bin.test.ts +0 -176
  51. package/src/cli-bin.ts +0 -233
  52. package/src/cli-release.test.ts +0 -46
  53. package/src/cli-release.ts +0 -78
  54. package/src/cli-update-contract.ts +0 -9
  55. package/src/cli-update.test.ts +0 -125
  56. package/src/client.test.ts +0 -390
  57. package/src/client.ts +0 -352
  58. package/src/codex-wake.test.ts +0 -254
  59. package/src/codex-wake.ts +0 -301
  60. package/src/command-session.ts +0 -38
  61. package/src/commands-agent-sessions.ts +0 -72
  62. package/src/commands-auth.ts +0 -238
  63. package/src/commands-config.ts +0 -336
  64. package/src/commands-core.ts +0 -312
  65. package/src/commands-devices.ts +0 -180
  66. package/src/commands-doctor.ts +0 -1501
  67. package/src/commands-guidance.ts +0 -181
  68. package/src/commands-harness-context.ts +0 -207
  69. package/src/commands-hook-shape.ts +0 -69
  70. package/src/commands-hooks.ts +0 -2318
  71. package/src/commands-init.ts +0 -1055
  72. package/src/commands-io.ts +0 -108
  73. package/src/commands-logs.ts +0 -243
  74. package/src/commands-project.ts +0 -47
  75. package/src/commands-send-support.test.ts +0 -156
  76. package/src/commands-send-support.ts +0 -311
  77. package/src/commands-send.ts +0 -875
  78. package/src/commands-setup-proof.test.ts +0 -72
  79. package/src/commands-setup-proof.ts +0 -124
  80. package/src/commands-skill.test.ts +0 -44
  81. package/src/commands-skill.ts +0 -204
  82. package/src/commands-sounds.test.ts +0 -106
  83. package/src/commands-sounds.ts +0 -49
  84. package/src/commands-update.ts +0 -180
  85. package/src/commands.test.ts +0 -8297
  86. package/src/commands.ts +0 -77
  87. package/src/config-schema.test.ts +0 -153
  88. package/src/config-schema.ts +0 -349
  89. package/src/config.test.ts +0 -581
  90. package/src/config.ts +0 -475
  91. package/src/credentials.test.ts +0 -403
  92. package/src/credentials.ts +0 -403
  93. package/src/file-lock-process-worker.test.ts +0 -64
  94. package/src/file-lock.test.ts +0 -320
  95. package/src/file-lock.ts +0 -393
  96. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  97. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  98. package/src/generated-session-label.ts +0 -155
  99. package/src/guidance-content.ts +0 -278
  100. package/src/guidance-render.ts +0 -56
  101. package/src/guidance.test.ts +0 -327
  102. package/src/guidance.ts +0 -233
  103. package/src/harness-integration.test.ts +0 -294
  104. package/src/harnesses.test.ts +0 -149
  105. package/src/harnesses.ts +0 -164
  106. package/src/hook-adapter.test.ts +0 -496
  107. package/src/hook-adapter.ts +0 -424
  108. package/src/hook-input.test.ts +0 -39
  109. package/src/hook-input.ts +0 -72
  110. package/src/hooks.test.ts +0 -5824
  111. package/src/hooks.ts +0 -4011
  112. package/src/install-hooks.test.ts +0 -1614
  113. package/src/install-hooks.ts +0 -1501
  114. package/src/interactive.test.ts +0 -387
  115. package/src/interactive.ts +0 -858
  116. package/src/invocation-context.test.ts +0 -352
  117. package/src/invocation-context.ts +0 -197
  118. package/src/logging-process-worker.test.ts +0 -45
  119. package/src/logging.test.ts +0 -544
  120. package/src/logging.ts +0 -698
  121. package/src/main-run.ts +0 -45
  122. package/src/main.ts +0 -12
  123. package/src/native-skills.test.ts +0 -115
  124. package/src/native-skills.ts +0 -162
  125. package/src/node-floor.test.ts +0 -27
  126. package/src/node-floor.ts +0 -37
  127. package/src/onboarding-docs.test.ts +0 -25
  128. package/src/openclaw-plugin.ts +0 -409
  129. package/src/opencode-plugin.ts +0 -298
  130. package/src/orca-session-title.test.ts +0 -54
  131. package/src/orca-session-title.ts +0 -103
  132. package/src/pack-feedback-slice.test.ts +0 -212
  133. package/src/pack-feedback-slice.ts +0 -187
  134. package/src/platform.test.ts +0 -152
  135. package/src/platform.ts +0 -173
  136. package/src/program.test.ts +0 -317
  137. package/src/program.ts +0 -842
  138. package/src/project-enablement.test.ts +0 -55
  139. package/src/project-enablement.ts +0 -99
  140. package/src/question-caps.test.ts +0 -49
  141. package/src/question-settlement-process.ts +0 -47
  142. package/src/question-timing.ts +0 -56
  143. package/src/readiness.test.ts +0 -25
  144. package/src/readiness.ts +0 -212
  145. package/src/release.ts +0 -51
  146. package/src/send-attempts.test.ts +0 -143
  147. package/src/send-attempts.ts +0 -188
  148. package/src/send.test.ts +0 -28
  149. package/src/send.ts +0 -436
  150. package/src/session-activation.ts +0 -61
  151. package/src/session-end-log-lock-worker.test.ts +0 -35
  152. package/src/session-labels.test.ts +0 -490
  153. package/src/session-labels.ts +0 -474
  154. package/src/setup-destinations.test.ts +0 -41
  155. package/src/setup-destinations.ts +0 -50
  156. package/src/skill.test.ts +0 -426
  157. package/src/sound-ref.test.ts +0 -31
  158. package/src/sound-ref.ts +0 -34
  159. package/src/trust-policy.test.ts +0 -701
  160. package/src/ui/banner.ts +0 -95
  161. package/src/ui/config-view.test.ts +0 -90
  162. package/src/ui/config-view.ts +0 -138
  163. package/src/ui/help.test.ts +0 -16
  164. package/src/ui/help.ts +0 -128
  165. package/src/ui/theme.test.ts +0 -92
  166. package/src/ui/theme.ts +0 -265
  167. package/src/url-policy.ts +0 -399
  168. package/tsconfig.json +0 -10
package/src/send.ts DELETED
@@ -1,436 +0,0 @@
1
- import {
2
- COLLAPSE_KEY_MAX_BYTES,
3
- INTERRUPTION_LEVELS,
4
- MEDIA_MAX_ITEMS,
5
- NOTIFICATION_SCHEMA_VERSION,
6
- QUESTION_TEXT_MAX_LENGTH,
7
- bannerExcerpt,
8
- type AndroidOptionsT,
9
- type IosOptionsT,
10
- type LifecycleT,
11
- type MacosOptionsT,
12
- type MediaItemT,
13
- type NotificationDraftT,
14
- NOTIFICATION_KINDS,
15
- PLATFORMS,
16
- type Platform,
17
- type QuestionT,
18
- type SourceContextT,
19
- type SubmissionReceipt,
20
- } from '@raidiant/notifai-protocol'
21
- import type { CliConfig } from './config.js'
22
- import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
23
-
24
- export interface SendFlags {
25
- title: string
26
- /** The one canonical Markdown body. */
27
- body: string
28
- subtitle?: string
29
- /** What this notification is; absent means update. */
30
- kind?: string
31
- project?: string
32
- /** Deliberately omit Project identity even when config or cwd could supply one. */
33
- projectless?: boolean
34
- /** Opaque exact Agent Session override. */
35
- sessionId?: string
36
- /** Human-readable Agent Session label override. */
37
- sessionLabel?: string
38
- device?: string[]
39
- all?: boolean
40
- ttl?: number
41
- collapseKey?: string
42
- sound?: string
43
- threadId?: string
44
- /** Apple interruption level; null is reserved for internal callers that must suppress config. */
45
- level?: string | null
46
- data?: string[]
47
- /** Ready media ids in author-supplied order. Commands resolve paths and URLs first. */
48
- image?: string[]
49
- /** Optional alt text paired with images by position. */
50
- imageAlt?: string[]
51
- /**
52
- * Complete canonical media manifest for internal callers that already froze
53
- * uploads and alt text. Mutually exclusive with the CLI's parallel inputs.
54
- */
55
- media?: MediaItemT[]
56
- /** Enable the inline reply action for this Notification Request. */
57
- reply?: boolean
58
- /** How long the server accepts a reply after submission. */
59
- replyWindow?: number
60
- /**
61
- * Answer labels; turns the reply into a closed question. One flag occurrence
62
- * per label, always — a label is a label, whatever characters it contains.
63
- */
64
- choice?: string[]
65
- /** The user may select several of the offered answers. */
66
- multi?: boolean
67
- /**
68
- * A full question set, overriding the single question the flags describe.
69
- * Not a user-facing flag: `ask --form` and the hooks build it.
70
- */
71
- questions?: QuestionT[]
72
- /** Platform whose optional notification fields these flags configure. */
73
- platform?: string
74
- /**
75
- * Lifecycle position of this send; not a user-facing flag. Hooks set it so
76
- * questions and their retirements ride the wire as what they are.
77
- */
78
- lifecycle?: LifecycleT
79
- /**
80
- * Allow the two-character sequence backslash-n to be sent as visible text.
81
- * Without this, a likely accidental escaped newline is refused.
82
- */
83
- literalBackslashN?: boolean
84
- }
85
-
86
- /** A backslash-n pair that was not itself escaped as a literal backslash. */
87
- const ACCIDENTAL_ESCAPED_NEWLINE = /(?:^|[^\\])\\n/
88
-
89
- export function hasAccidentalEscapedNewlines(body: string): boolean {
90
- return ACCIDENTAL_ESCAPED_NEWLINE.test(body)
91
- }
92
-
93
- export function accidentalEscapedNewlineMessage(): string {
94
- return [
95
- 'This body contains the two-character sequence \\n, which is sent literally and shows as \\n on the device.',
96
- 'To send a real line break, pass a multiline --body or --body-file -.',
97
- 'If you meant the visible characters \\n, pass --literal-backslash-n.',
98
- ].join('\n')
99
- }
100
-
101
- export function rejectAccidentalEscapedNewlines(
102
- body: string | undefined,
103
- allowLiteral: boolean | undefined,
104
- ): string | null {
105
- if (allowLiteral === true || body === undefined) return null
106
- return hasAccidentalEscapedNewlines(body) ? accidentalEscapedNewlineMessage() : null
107
- }
108
-
109
- export type DraftBuild =
110
- | { ok: true; draft: NotificationDraftT; platform: Platform }
111
- | { ok: false; error: string }
112
-
113
- /** Invocation-derived values beneath explicit and configured author input. */
114
- export interface DraftInvocation {
115
- inferredProject?: string | null
116
- source?: SourceContextT
117
- }
118
-
119
- const POSITIONAL_MEDIA_REFERENCE = /media:(\d+)(?![A-Za-z0-9_-])/g
120
-
121
- /** Validate media cardinality and positional alt pairing before any upload starts. */
122
- export function validateMediaInputs(
123
- images: readonly string[] | undefined,
124
- alts: readonly string[] | undefined,
125
- ): string | null {
126
- const imageCount = images?.length ?? 0
127
- const imageAlts = alts ?? []
128
- if (imageCount > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
129
- if (imageAlts.length > imageCount) {
130
- return `Received ${imageAlts.length} --image-alt values for ${imageCount} --image values.`
131
- }
132
- if (imageAlts.some((alt) => alt.length < 1 || alt.length > 256)) {
133
- return '--image-alt must be 1-256 characters.'
134
- }
135
- return null
136
- }
137
-
138
- /** Validate a frozen value-object manifest without collapsing sparse alt text. */
139
- function validateMediaManifest(media: readonly MediaItemT[]): string | null {
140
- if (media.length > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
141
- if (media.some((item) => item.alt !== undefined && (item.alt.length < 1 || item.alt.length > 256))) {
142
- return 'Media alt text must be 1-256 characters.'
143
- }
144
- return null
145
- }
146
-
147
- /** Rewrite authorable 1-based media positions to canonical ready media ids. */
148
- export function rewriteMediaReferences(
149
- body: string,
150
- mediaIds: readonly string[],
151
- ): { ok: true; body: string } | { ok: false; error: string } {
152
- let error: string | undefined
153
- const rewritten = body.replace(POSITIONAL_MEDIA_REFERENCE, (reference, digits: string) => {
154
- const position = Number(digits)
155
- if (
156
- !Number.isSafeInteger(position) ||
157
- position < 1 ||
158
- position > MEDIA_MAX_ITEMS ||
159
- digits !== String(position) ||
160
- mediaIds[position - 1] === undefined
161
- ) {
162
- error = `Body reference "${reference}" has no matching --image occurrence.`
163
- return reference
164
- }
165
- return `media:${mediaIds[position - 1]}`
166
- })
167
- return error === undefined ? { ok: true, body: rewritten } : { ok: false, error }
168
- }
169
-
170
- /** Merge flags over resolved config into a Notification Request draft. */
171
- export function buildDraft(
172
- config: CliConfig,
173
- flags: SendFlags,
174
- invocation: DraftInvocation = {},
175
- ): DraftBuild {
176
- if (!flags.title || !flags.body) return { ok: false, error: 'Both --title and --body are required.' }
177
- const platform = resolvePlatform(flags.platform)
178
- if (!platform) {
179
- return { ok: false, error: `Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.` }
180
- }
181
-
182
- if (flags.media !== undefined && (flags.image !== undefined || flags.imageAlt !== undefined)) {
183
- return { ok: false, error: 'Internal media manifests cannot be combined with --image or --image-alt.' }
184
- }
185
- const imageIds = flags.media?.map((item) => item.media_id) ?? flags.image ?? []
186
- const imageAlts = flags.imageAlt ?? []
187
- const mediaInputError =
188
- flags.media === undefined
189
- ? validateMediaInputs(imageIds, imageAlts)
190
- : validateMediaManifest(flags.media)
191
- if (mediaInputError !== null) return { ok: false, error: mediaInputError }
192
- const media =
193
- flags.media ??
194
- imageIds.map((media_id, index) => ({
195
- media_id,
196
- ...(imageAlts[index] !== undefined ? { alt: imageAlts[index] } : {}),
197
- }))
198
- const body = rewriteMediaReferences(flags.body, imageIds)
199
- if (!body.ok) return body
200
-
201
- const deviceIds = flags.all ? null : (flags.device?.length ? flags.device : config.devices.value)
202
- const targets: NotificationDraftT['targets'] =
203
- deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
204
-
205
- if (
206
- flags.kind !== undefined &&
207
- !NOTIFICATION_KINDS.includes(flags.kind as (typeof NOTIFICATION_KINDS)[number])
208
- ) {
209
- return {
210
- ok: false,
211
- error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
212
- }
213
- }
214
-
215
- // Explicit --sound and the saved sound key outrank kind. The server is the
216
- // one that applies kind, Project, and Account maps — stamping a kind default
217
- // here would look like an explicit choice and skip those layers. `none` is
218
- // still silent.
219
- const sound = flags.sound !== undefined ? flags.sound : config.sound.value
220
- if (sound !== null && sound !== undefined && !isCliSoundRef(sound)) {
221
- return {
222
- ok: false,
223
- error: unknownSoundMessage(sound),
224
- }
225
- }
226
- const level =
227
- flags.level !== undefined ? flags.level : config.interruption_level.value
228
- if (
229
- level !== null &&
230
- level !== undefined &&
231
- !INTERRUPTION_LEVELS.includes(level as (typeof INTERRUPTION_LEVELS)[number])
232
- ) {
233
- return { ok: false, error: `Unknown interruption level "${level}".` }
234
- }
235
- if (platform === 'android' && level !== null && level !== undefined) {
236
- return {
237
- ok: false,
238
- error:
239
- 'Android does not support caller-selected interruption levels. Remove --level or unset the configured interruption_level; kind, notification channels, and device settings own attention on Android.',
240
- }
241
- }
242
-
243
- const appleOptions: IosOptionsT & MacosOptionsT = {}
244
- const androidOptions: AndroidOptionsT = {}
245
- if (sound === 'none') {
246
- appleOptions.sound = null
247
- androidOptions.sound = null
248
- } else if (sound !== null && sound !== undefined) {
249
- appleOptions.sound = sound
250
- androidOptions.sound = sound
251
- }
252
- if (flags.threadId !== undefined) {
253
- appleOptions.thread_id = flags.threadId
254
- androidOptions.thread_id = flags.threadId
255
- }
256
- if (level !== null && level !== undefined) {
257
- appleOptions.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
258
- }
259
- if (flags.data?.length) {
260
- const data: Record<string, string> = {}
261
- for (const pair of flags.data) {
262
- const eq = pair.indexOf('=')
263
- if (eq <= 0) return { ok: false, error: `--data expects key=value, got "${pair}".` }
264
- data[pair.slice(0, eq)] = pair.slice(eq + 1)
265
- }
266
- appleOptions.custom_data = data
267
- androidOptions.custom_data = data
268
- }
269
-
270
- const ttl = flags.ttl ?? config.ttl_seconds.value
271
- const collapse = flags.collapseKey ?? config.collapse_key.value
272
- if (
273
- collapse !== null &&
274
- collapse !== undefined &&
275
- Buffer.byteLength(collapse, 'utf8') > COLLAPSE_KEY_MAX_BYTES
276
- ) {
277
- return {
278
- ok: false,
279
- error: `--collapse-key must be at most ${COLLAPSE_KEY_MAX_BYTES} UTF-8 bytes.`,
280
- }
281
- }
282
-
283
- if (flags.projectless === true && flags.project !== undefined) {
284
- return { ok: false, error: 'Choose either --project or --projectless, not both.' }
285
- }
286
- const project = flags.projectless === true
287
- ? undefined
288
- : flags.project ?? config.project.value ?? invocation.inferredProject
289
-
290
- // Ids are derived from labels so an agent writing a one-liner does not have
291
- // to invent them, but they stay the stable thing it branches on afterwards.
292
- const choices = parseChoices(flags.choice)
293
- if (choices === 'invalid') {
294
- return {
295
- ok: false,
296
- error: CHOICE_USAGE,
297
- }
298
- }
299
- if (flags.multi && choices === null && flags.questions === undefined) {
300
- return { ok: false, error: '--multi needs answers to select between; add --choice.' }
301
- }
302
- // A single reply question is the first readable block of the canonical body.
303
- // Context may follow after a blank line without changing what the user answers.
304
- const derivedQuestion = bannerExcerpt(body.body).split('\n', 1)[0]!.trim()
305
- if (
306
- flags.reply &&
307
- flags.questions === undefined &&
308
- derivedQuestion.length > QUESTION_TEXT_MAX_LENGTH
309
- ) {
310
- return {
311
- ok: false,
312
- error:
313
- `Keep the question within ${QUESTION_TEXT_MAX_LENGTH} characters and put ` +
314
- 'the longer context after a blank line — the first paragraph is the question.',
315
- }
316
- }
317
-
318
- const hasAppleOptions = Object.keys(appleOptions).length > 0
319
- const hasAndroidOptions = Object.keys(androidOptions).length > 0
320
- let platformOptions: NonNullable<NotificationDraftT['platform']> | undefined
321
- if (flags.platform === undefined) {
322
- if (hasAppleOptions || hasAndroidOptions) {
323
- platformOptions = {
324
- ...(hasAppleOptions ? { ios: appleOptions, macos: appleOptions } : {}),
325
- ...(hasAndroidOptions ? { android: androidOptions } : {}),
326
- }
327
- }
328
- } else if (platform === 'android') {
329
- if (hasAndroidOptions) platformOptions = { android: androidOptions }
330
- } else if (hasAppleOptions) {
331
- platformOptions =
332
- platform === 'ios' ? { ios: appleOptions } : { macos: appleOptions }
333
- }
334
-
335
- const draft: NotificationDraftT = {
336
- schema_version: NOTIFICATION_SCHEMA_VERSION,
337
- ...(flags.kind !== undefined
338
- ? { kind: flags.kind as (typeof NOTIFICATION_KINDS)[number] }
339
- : {}),
340
- ...(flags.lifecycle !== undefined ? { lifecycle: flags.lifecycle } : {}),
341
- ...(project !== null && project !== undefined ? { project } : {}),
342
- ...(invocation.source !== undefined ? { source: invocation.source } : {}),
343
- presentation: {
344
- title: flags.title,
345
- body: body.body,
346
- ...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
347
- ...(media.length > 0 ? { media } : {}),
348
- },
349
- targets,
350
- delivery: { ttl_seconds: ttl, collapse_key: collapse },
351
- ...(flags.reply
352
- ? {
353
- reply: {
354
- expires_in_seconds: flags.replyWindow ?? config.reply_window_seconds.value,
355
- questions: flags.questions ?? [
356
- {
357
- id: 'q1',
358
- text: derivedQuestion,
359
- ...(choices !== null ? { choices } : {}),
360
- ...(flags.multi ? { multi: true } : {}),
361
- },
362
- ],
363
- },
364
- }
365
- : {}),
366
- ...(platformOptions !== undefined ? { platform: platformOptions } : {}),
367
- }
368
- return { ok: true, draft, platform }
369
- }
370
-
371
- function resolvePlatform(value: string | undefined): Platform | null {
372
- if (value === undefined) return 'ios'
373
- return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null
374
- }
375
-
376
- /** Human one-screen receipt summary; agents use --json instead. */
377
- export function formatReceipt(receipt: SubmissionReceipt): string {
378
- const lines: string[] = []
379
- const overall =
380
- receipt.overall === 'provider_accepted_all'
381
- ? 'accepted by the push provider for every device'
382
- : receipt.overall === 'provider_accepted_partial'
383
- ? 'accepted for some devices'
384
- : receipt.overall === 'provider_rejected_all'
385
- ? 'rejected for every device'
386
- : 'still in flight'
387
- lines.push(`request ${receipt.request_id}${receipt.replayed ? ' (replayed)' : ''}: ${overall}`)
388
- for (const d of receipt.deliveries) {
389
- const detail = d.provider_reason ? ` (${d.provider_reason})` : ''
390
- lines.push(` ${d.device_name}: ${d.state}${detail}`)
391
- }
392
- lines.push(
393
- ` device receipt is not assessed here; run notifai status ${receipt.request_id} for Companion Receipt evidence`,
394
- )
395
- for (const w of receipt.warnings) lines.push(` warning ${w.path}: ${w.message}`)
396
- return lines.join('\n')
397
- }
398
-
399
- /** Stable exit codes: 0 ok/pending, 1 rejected everywhere. */
400
- export function receiptExitCode(receipt: SubmissionReceipt): number {
401
- return receipt.overall === 'provider_rejected_all' ? 1 : 0
402
- }
403
-
404
- /**
405
- * One flag occurrence, one answer: `--choice Staging --choice Production` ->
406
- * `[{id:'staging',label:'Staging'}, ...]`.
407
- *
408
- * Commas are ordinary characters. The old single-value comma split produced
409
- * buttons the agent never intended ("Nothing, I'll review" became two answers)
410
- * and nothing downstream could detect it, so the delimiter form is gone
411
- * rather than guarded: a label is a label.
412
- */
413
- export function parseChoices(
414
- value: string[] | undefined,
415
- ): { id: string; label: string }[] | null | 'invalid' {
416
- if (value === undefined || value.length === 0) return null
417
- const labels = value.map((part) => part.trim()).filter((part) => part.length > 0)
418
- if (labels.length < 2 || labels.length > 6) return 'invalid'
419
- const choices = labels.map((label) => ({ id: slugify(label), label }))
420
- if (choices.some((choice) => choice.id === '')) return 'invalid'
421
- if (new Set(choices.map((choice) => choice.id)).size !== choices.length) return 'invalid'
422
- return choices
423
- }
424
-
425
- /** Message shared by every surface that accepts choice labels. */
426
- export const CHOICE_USAGE =
427
- 'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
428
- 'e.g. --choice "Yes, ship it" --choice "Not yet".'
429
-
430
- export function slugify(label: string): string {
431
- return label
432
- .toLowerCase()
433
- .replace(/[^a-z0-9]+/g, '-')
434
- .replace(/^-+|-+$/g, '')
435
- .slice(0, 32)
436
- }
@@ -1,61 +0,0 @@
1
- import type { HookHarness } from './hooks.js'
2
- import { Buffer } from 'node:buffer'
3
- import { GUIDANCE_CONTEXT_MAX_BYTES, boundedEffectiveGuidance } from './guidance-render.js'
4
-
5
- const ROOT_OWNERSHIP =
6
- 'Notifai is enabled for this Project. The effective, provenance-marked guidance below governs Notification Requests. ' +
7
- 'Missing CLI readiness is handled through `notifai init --json`.'
8
-
9
- export const WORKER_ACTIVATION_CONTEXT =
10
- 'Notifai worker context: an Agent Event is a meaningful occurrence in your work; a Notification Request is a deliberate User-visible message about one. Report Agent Events to the parent and do not send Notification Requests unless the parent explicitly delegated that ownership. If delegated, load the Notifai skill and run `notifai guidance` before composing one.'
11
-
12
- export const MISSING_LIFECYCLE_GUIDANCE_CONTEXT =
13
- 'Notifai is active, but lifecycle guidance could not be loaded. Run `notifai guidance` once before deciding whether or how to send a Notification Request.'
14
-
15
- function rootActivationContext(cwd: string, env: NodeJS.ProcessEnv): string {
16
- const guidance = boundedEffectiveGuidance({
17
- cwd,
18
- env,
19
- maxBytes: GUIDANCE_CONTEXT_MAX_BYTES - Buffer.byteLength(`${ROOT_OWNERSHIP}\n\n`, 'utf8'),
20
- })
21
- return `${ROOT_OWNERSHIP}\n\n${guidance.ok ? guidance.content : guidance.fallback}`
22
- }
23
-
24
- /** One lifecycle meaning, encoded for each harness output contract. */
25
- export function sessionActivationOutput(
26
- harness: HookHarness | undefined,
27
- hookEventName: 'SessionStart' | 'SubagentStart',
28
- cwd: string = process.cwd(),
29
- env: NodeJS.ProcessEnv = process.env,
30
- ): string | undefined {
31
- const context = hookEventName === 'SubagentStart'
32
- ? WORKER_ACTIVATION_CONTEXT
33
- : rootActivationContext(cwd, env)
34
- if (harness === 'opencode' || harness === 'openclaw') return context
35
- if (harness === 'cursor') {
36
- return JSON.stringify({ additional_context: context })
37
- }
38
- if (harness === 'claude-code' || harness === 'codex') {
39
- return JSON.stringify({
40
- hookSpecificOutput: {
41
- hookEventName,
42
- additionalContext: context,
43
- },
44
- })
45
- }
46
- return undefined
47
- }
48
-
49
- /**
50
- * Cursor currently accepts sessionStart context without reliably delivering it
51
- * to the model. Its native post-Stop follow-up is the narrow fallback and is
52
- * claimed once per conversation by the hook-state layer.
53
- */
54
- export function cursorStopActivationOutput(
55
- cwd: string = process.cwd(),
56
- env: NodeJS.ProcessEnv = process.env,
57
- ): string {
58
- return JSON.stringify({
59
- followup_message: rootActivationContext(cwd, env),
60
- })
61
- }
@@ -1,35 +0,0 @@
1
- import { existsSync, writeFileSync } from 'node:fs'
2
- import { describe, expect, it } from 'vitest'
3
- import { withFileLock } from './file-lock.js'
4
-
5
- const workerMode = process.env['NOTIFAI_SESSION_END_LOG_LOCK_WORKER_MODE']
6
- const sleep = new Int32Array(new SharedArrayBuffer(4))
7
-
8
- function waitFor(file: string): void {
9
- const deadline = Date.now() + 15_000
10
- while (!existsSync(file)) {
11
- if (Date.now() >= deadline) throw new Error(`timed out waiting for ${file}`)
12
- Atomics.wait(sleep, 0, 0, 2)
13
- }
14
- }
15
-
16
- if (workerMode === undefined) {
17
- describe('SessionEnd log lock worker', () => {
18
- it('stays dormant outside the CLI deadline regression', () => {
19
- expect(workerMode).toBeUndefined()
20
- })
21
- })
22
- } else {
23
- describe('SessionEnd log lock worker', () => {
24
- it('holds a live bakery ticket until the parent releases it', () => {
25
- withFileLock(
26
- process.env['NOTIFAI_SESSION_END_LOG_LOCK_PATH']!,
27
- () => {
28
- writeFileSync(process.env['NOTIFAI_SESSION_END_LOG_LOCK_READY']!, workerMode)
29
- waitFor(process.env['NOTIFAI_SESSION_END_LOG_LOCK_RELEASE']!)
30
- },
31
- { waitMs: 15_000 },
32
- )
33
- })
34
- })
35
- }