@raidiant/notifai 2.0.2 → 4.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.
@@ -0,0 +1,176 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import path from 'node:path'
3
+ import {
4
+ sessionLabelFromId,
5
+ type SourceContextT,
6
+ } from '@raidiant/notifai-protocol'
7
+ import type { Harness } from './harnesses.js'
8
+
9
+ export type GitCommand = (cwd: string, args: readonly string[]) => string | null
10
+
11
+ const runGit: GitCommand = (cwd, args) => {
12
+ try {
13
+ return execFileSync('git', ['-C', cwd, ...args], {
14
+ encoding: 'utf8',
15
+ stdio: ['ignore', 'pipe', 'ignore'],
16
+ timeout: 2_000,
17
+ windowsHide: true,
18
+ }).trim()
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ /** The useful, privacy-safe context inferred from one invocation directory. */
25
+ export interface InvocationContext {
26
+ project: string | null
27
+ branch?: string
28
+ worktree?: string
29
+ }
30
+
31
+ /** Contract-valid Project slug, or null when the name has no safe characters. */
32
+ export function projectSlugFrom(name: string): string | null {
33
+ const slug = name
34
+ .toLowerCase()
35
+ .replaceAll(/[^a-z0-9._-]+/g, '-')
36
+ .replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
37
+ .slice(0, 64)
38
+ return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : null
39
+ }
40
+
41
+ function usesWindowsPaths(...values: string[]): boolean {
42
+ return values.some((value) => /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value))
43
+ }
44
+
45
+ function pathApi(...values: string[]): typeof path.posix | typeof path.win32 {
46
+ return usesWindowsPaths(...values) ? path.win32 : path.posix
47
+ }
48
+
49
+ /** Basename that can be fixture-tested for POSIX, drive, and UNC paths on any OS. */
50
+ export function portableBasename(value: string): string {
51
+ const api = pathApi(value)
52
+ return api.basename(api.normalize(value))
53
+ }
54
+
55
+ function resolveGitPath(cwd: string, value: string): string {
56
+ const api = pathApi(cwd, value)
57
+ return api.isAbsolute(value) ? api.normalize(value) : api.resolve(cwd, value)
58
+ }
59
+
60
+ function samePath(left: string, right: string): boolean {
61
+ const api = pathApi(left, right)
62
+ const normalizedLeft = api.normalize(left)
63
+ const normalizedRight = api.normalize(right)
64
+ return api === path.win32
65
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
66
+ : normalizedLeft === normalizedRight
67
+ }
68
+
69
+ /** Truncate a display string by Unicode characters and keep the ellipsis inside the bound. */
70
+ export function truncateContext(value: string, maxLength: number): string {
71
+ const characters = Array.from(value)
72
+ if (characters.length <= maxLength) return value
73
+ return `${characters.slice(0, Math.max(0, maxLength - 1)).join('')}…`
74
+ }
75
+
76
+ /**
77
+ * Infer stable Project identity plus branch/worktree display context.
78
+ *
79
+ * Linked worktrees share the parent of Git's common directory, while a non-Git
80
+ * directory falls back to its own basename. Only basenames and branch names are
81
+ * returned; no absolute path can leave this function.
82
+ */
83
+ export function inferInvocationContext(
84
+ cwd: string,
85
+ git: GitCommand = runGit,
86
+ ): InvocationContext {
87
+ const commonRaw = git(cwd, ['rev-parse', '--git-common-dir'])
88
+ if (commonRaw === null || commonRaw === '') {
89
+ return { project: projectSlugFrom(portableBasename(cwd)) }
90
+ }
91
+
92
+ const commonDir = resolveGitPath(cwd, commonRaw)
93
+ const api = pathApi(cwd, commonDir)
94
+ const gitDirRaw = git(cwd, ['rev-parse', '--git-dir'])
95
+ const topLevelRaw = git(cwd, ['rev-parse', '--show-toplevel'])
96
+ const linked =
97
+ gitDirRaw !== null &&
98
+ gitDirRaw !== '' &&
99
+ !samePath(resolveGitPath(cwd, gitDirRaw), commonDir)
100
+
101
+ // In an ordinary checkout the worktree root is the useful Project root. A
102
+ // linked worktree needs its shared repository identity instead: standard Git
103
+ // stores that in <main>/.git, while submodules and --separate-git-dir use a
104
+ // named common directory whose basename is the only stable, path-free label.
105
+ // Treating every common directory as `.git` would infer `modules` or `public`
106
+ // for submodules — an implementation detail, not the repository's identity.
107
+ const commonName = api.basename(commonDir)
108
+ const projectName = linked
109
+ ? commonName.toLowerCase() === '.git'
110
+ ? api.basename(api.dirname(commonDir))
111
+ : commonName
112
+ : topLevelRaw !== null && topLevelRaw !== ''
113
+ ? portableBasename(topLevelRaw)
114
+ : commonName.toLowerCase() === '.git'
115
+ ? api.basename(api.dirname(commonDir))
116
+ : commonName
117
+ const project = projectSlugFrom(projectName)
118
+
119
+ const branchRaw = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
120
+ const branch =
121
+ branchRaw === null || branchRaw === '' || branchRaw === 'HEAD'
122
+ ? undefined
123
+ : truncateContext(branchRaw, 128)
124
+
125
+ const worktree =
126
+ linked && topLevelRaw !== null && topLevelRaw !== ''
127
+ ? truncateContext(portableBasename(topLevelRaw), 64)
128
+ : undefined
129
+
130
+ return {
131
+ project,
132
+ ...(branch !== undefined ? { branch } : {}),
133
+ ...(worktree !== undefined && worktree !== '' ? { worktree } : {}),
134
+ }
135
+ }
136
+
137
+ export interface SourceContextInput {
138
+ env: NodeJS.ProcessEnv
139
+ invocation: InvocationContext
140
+ sessionId?: string
141
+ sessionLabel?: string
142
+ activeHarness?: { harness: Harness; sessionId?: string }
143
+ }
144
+
145
+ export type SourceContextBuild =
146
+ | { ok: true; source?: SourceContextT }
147
+ | { ok: false; error: string }
148
+
149
+ /** Resolve per-field source precedence without fabricating session identity. */
150
+ export function buildSourceContext(input: SourceContextInput): SourceContextBuild {
151
+ const sessionId =
152
+ input.sessionId ?? input.env['NOTIFAI_SESSION_ID'] ?? input.activeHarness?.sessionId
153
+ const explicitLabel = input.sessionLabel ?? input.env['NOTIFAI_SESSION_LABEL']
154
+ if (explicitLabel !== undefined && (sessionId === undefined || sessionId === '')) {
155
+ return {
156
+ ok: false,
157
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact session id.',
158
+ }
159
+ }
160
+ if (sessionId === '') {
161
+ return { ok: false, error: '--session-id must not be empty.' }
162
+ }
163
+
164
+ const sessionLabel =
165
+ sessionId === undefined ? undefined : (explicitLabel ?? sessionLabelFromId(sessionId))
166
+ const source: SourceContextT = {
167
+ ...(sessionId !== undefined ? { session_id: sessionId } : {}),
168
+ ...(sessionLabel !== undefined ? { session_label: sessionLabel } : {}),
169
+ ...(sessionId !== undefined && input.activeHarness !== undefined
170
+ ? { harness: input.activeHarness.harness }
171
+ : {}),
172
+ ...(input.invocation.branch !== undefined ? { branch: input.invocation.branch } : {}),
173
+ ...(input.invocation.worktree !== undefined ? { worktree: input.invocation.worktree } : {}),
174
+ }
175
+ return Object.keys(source).length === 0 ? { ok: true } : { ok: true, source }
176
+ }
package/src/main.ts CHANGED
@@ -262,18 +262,29 @@ const send = program
262
262
  .summary('Send a notification')
263
263
  .description('Send a notification')
264
264
  .optionsGroup(SEND_GROUP.content)
265
- .requiredOption('--title <title>', 'notification title')
266
- .requiredOption('--body <body>', 'notification body')
267
- .option('--subtitle <subtitle>', 'a line between the title and the body')
268
- .option('--detail <markdown>', 'long-form markdown shown only in the app, never on the banner')
269
- .option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
270
- .option('--image <path|url|media_id>', 'upload or attach an image')
265
+ .requiredOption('--title <title>', 'brief title whose substance is immediately understandable')
266
+ .option('--body <markdown>', 'canonical Markdown body')
267
+ .option('--body-file <path>', 'read the canonical Markdown body from a file (use - for stdin)')
268
+ .option('--subtitle <subtitle>', 'a plain-text line between the title and body')
269
+ .option(
270
+ '--image <path|url|media_id>',
271
+ 'upload or attach an image in collection order (repeatable, maximum 8)',
272
+ (v: string, all: string[]) => [...all, v],
273
+ [],
274
+ )
275
+ .option(
276
+ '--image-alt <text>',
277
+ 'alt text paired with --image occurrences by position (repeatable)',
278
+ (v: string, all: string[]) => [...all, v],
279
+ [],
280
+ )
271
281
  .optionsGroup(SEND_GROUP.routing)
272
- .option('--kind <kind>', 'what this is: update (default) | done | question (requires --reply)')
273
- .option('--project <id>', 'project identifier, e.g. my-app (lazily registered)')
282
+ .option('--kind <kind>', 'what this is: update (default) | done | failed | blocked | question (requires --reply)')
283
+ .option('--project <id>', 'project identifier override (otherwise configured or inferred)')
274
284
  .option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[]) => [...all, v], [])
275
285
  .option('--all', 'target all routable devices (overrides configured devices)')
276
- .option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
286
+ .option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
287
+ .option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
277
288
  .option('--event <event>', 'agent event name, e.g. tests_passed')
278
289
  .optionsGroup(SEND_GROUP.presentation)
279
290
  .option('--collapse-key <key>', 'replace earlier notifications with the same key')
@@ -294,8 +305,8 @@ const send = program
294
305
  // a message pointing at `ask` instead of "unknown option".
295
306
  .option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
296
307
  .optionsGroup(SEND_GROUP.advanced)
297
- .option('--sound <sound>', 'override kind profile / saved config: default | done | attention | alert | none')
298
- .option('--level <level>', 'override kind profile / saved config: passive | active | time_sensitive')
308
+ .option('--sound <sound>', 'override saved sound: default | done | attention | alert | none')
309
+ .option('--level <level>', 'override saved interruption level: passive | active | time_sensitive')
299
310
  .option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
300
311
  .option('--no-wait', 'return immediately after acceptance')
301
312
  .option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
@@ -308,24 +319,22 @@ const send = program
308
319
  const wait = typeof opts['wait'] === 'number' ? opts['wait'] : undefined
309
320
  const noBlock = opts['block'] === false
310
321
  const sendOpts = { ...opts }
311
- // Same empty-collector normalisation as `ask`.
312
- if (Array.isArray(sendOpts['replyChoice']) && sendOpts['replyChoice'].length === 0) {
313
- delete sendOpts['replyChoice']
322
+ // Commander collectors default to []; an empty list means "not passed".
323
+ for (const key of ['replyChoice', 'image', 'imageAlt']) {
324
+ if (Array.isArray(sendOpts[key]) && sendOpts[key].length === 0) delete sendOpts[key]
314
325
  }
315
326
  delete sendOpts['block']
316
- // Long-form detail is usually a build log or a diff summary, which nobody
317
- // wants to shell-escape onto a command line.
318
- const detailFile = sendOpts['detailFile']
319
- delete sendOpts['detailFile']
320
- if (typeof detailFile === 'string') {
321
- if (sendOpts['detail'] !== undefined) {
322
- deps.io.err('Pass either --detail or --detail-file, not both.')
327
+ const bodyFile = sendOpts['bodyFile']
328
+ delete sendOpts['bodyFile']
329
+ if (typeof bodyFile === 'string') {
330
+ if (sendOpts['body'] !== undefined) {
331
+ deps.io.err('Pass either --body or --body-file, not both.')
323
332
  process.exit(2)
324
333
  }
325
334
  try {
326
- sendOpts['detail'] = readFileSync(detailFile === '-' ? 0 : detailFile, 'utf8')
335
+ sendOpts['body'] = readFileSync(bodyFile === '-' ? 0 : bodyFile, 'utf8')
327
336
  } catch (err) {
328
- deps.io.err(`Could not read ${detailFile}: ${String(err)}`)
337
+ deps.io.err(`Could not read ${bodyFile}: ${String(err)}`)
329
338
  process.exit(2)
330
339
  }
331
340
  }
@@ -337,7 +346,7 @@ const send = program
337
346
 
338
347
  send.addHelpText(
339
348
  'after',
340
- `\nKind profiles apply automatically. Pass --sound or --level only to override them or saved user config:\n update sound none level passive\n done sound done level passive\n question sound attention level active (--reply)\n`,
349
+ `\nKind describes status in the Companion Apps; it never chooses banner sound or interruption level.\nWithout --sound/--level or saved preferences, those fields are omitted for the destination to handle.\n`,
341
350
  )
342
351
 
343
352
  program
@@ -387,28 +396,46 @@ program
387
396
  (v: string, all: string[]) => [...all, v], [],
388
397
  )
389
398
  .option('--multi', 'with --choice, several answers may be selected')
390
- .option('--detail <markdown>', 'long-form context shown only in the app, never on the banner')
391
- .option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
399
+ .option('--body <markdown>', 'optional Markdown context appended after the question block')
400
+ .option('--body-file <path>', 'read optional Markdown context from a file (use - for stdin)')
392
401
  .option('--form <path>', 'ask several questions as one form; JSON file (use - for stdin)')
393
- .option('--session <id>', 'session id (default: the session working in this directory, else $NOTIFAI_SESSION)')
394
- .action((question: string | undefined, opts: {
402
+ .option(
403
+ '--image <path|url|media_id>',
404
+ 'upload or attach an image in collection order (repeatable, maximum 8)',
405
+ (v: string, all: string[]) => [...all, v],
406
+ [],
407
+ )
408
+ .option(
409
+ '--image-alt <text>',
410
+ 'alt text paired with --image occurrences by position (repeatable)',
411
+ (v: string, all: string[]) => [...all, v],
412
+ [],
413
+ )
414
+ .option('--project <id>', 'project identifier override (otherwise configured or inferred)')
415
+ .option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
416
+ .option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
417
+ .action(async (question: string | undefined, opts: {
395
418
  choice?: string[]
396
419
  multi?: boolean
397
- detail?: string
398
- detailFile?: string
420
+ body?: string
421
+ bodyFile?: string
399
422
  form?: string
400
- session?: string
423
+ image?: string[]
424
+ imageAlt?: string[]
425
+ project?: string
426
+ sessionId?: string
427
+ sessionLabel?: string
401
428
  }) => {
402
- let detail = opts.detail
403
- if (typeof opts.detailFile === 'string') {
404
- if (detail !== undefined) {
405
- deps.io.err('Pass either --detail or --detail-file, not both.')
429
+ let body = opts.body
430
+ if (typeof opts.bodyFile === 'string') {
431
+ if (body !== undefined) {
432
+ deps.io.err('Pass either --body or --body-file, not both.')
406
433
  process.exit(2)
407
434
  }
408
435
  try {
409
- detail = readFileSync(opts.detailFile === '-' ? 0 : opts.detailFile, 'utf8')
436
+ body = readFileSync(opts.bodyFile === '-' ? 0 : opts.bodyFile, 'utf8')
410
437
  } catch (err) {
411
- deps.io.err(`Could not read ${opts.detailFile}: ${String(err)}`)
438
+ deps.io.err(`Could not read ${opts.bodyFile}: ${String(err)}`)
412
439
  process.exit(2)
413
440
  }
414
441
  }
@@ -421,15 +448,19 @@ program
421
448
  process.exit(2)
422
449
  }
423
450
  }
424
- // commander's collector defaults to []; an empty list means "not asked".
451
+ // Commander collectors default to []; an empty list means "not passed".
425
452
  const flags: Parameters<typeof askCommand>[2] = {
426
- ...(opts.session !== undefined ? { session: opts.session } : {}),
427
453
  ...(opts.choice?.length ? { choice: opts.choice } : {}),
428
454
  ...(opts.multi ? { multi: true } : {}),
429
- ...(detail !== undefined ? { detail } : {}),
455
+ ...(body !== undefined ? { body } : {}),
430
456
  ...(form !== undefined ? { form } : {}),
457
+ ...(opts.image?.length ? { image: opts.image } : {}),
458
+ ...(opts.imageAlt?.length ? { imageAlt: opts.imageAlt } : {}),
459
+ ...(opts.project !== undefined ? { project: opts.project } : {}),
460
+ ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
461
+ ...(opts.sessionLabel !== undefined ? { sessionLabel: opts.sessionLabel } : {}),
431
462
  }
432
- process.exit(askCommand(deps, question, flags))
463
+ process.exit(await askCommand(deps, question, flags))
433
464
  })
434
465
 
435
466
  program
package/src/send.ts CHANGED
@@ -2,16 +2,20 @@ import {
2
2
  CLI_SOUNDS,
3
3
  COLLAPSE_KEY_MAX_BYTES,
4
4
  INTERRUPTION_LEVELS,
5
+ MEDIA_MAX_ITEMS,
5
6
  NOTIFICATION_SCHEMA_VERSION,
6
7
  QUESTION_TEXT_MAX_LENGTH,
8
+ bannerExcerpt,
7
9
  type IosOptionsT,
8
10
  type LifecycleT,
9
11
  type MacosOptionsT,
12
+ type MediaItemT,
10
13
  type NotificationDraftT,
11
14
  NOTIFICATION_KINDS,
12
15
  PLATFORMS,
13
16
  type Platform,
14
17
  type QuestionT,
18
+ type SourceContextT,
15
19
  type SubmissionReceipt,
16
20
  } from '@raidiant/notifai-protocol'
17
21
  import type { CliConfig } from './config.js'
@@ -20,15 +24,17 @@ type CliSound = (typeof CLI_SOUNDS)[number]
20
24
 
21
25
  export interface SendFlags {
22
26
  title: string
27
+ /** The one canonical Markdown body. */
23
28
  body: string
24
29
  subtitle?: string
25
- /** Long-form markdown for the app's detail view; never on the banner. */
26
- detail?: string
27
30
  event?: string
28
- /** What this notification is: update (default), done, or question. */
31
+ /** What this notification is; absent means update. */
29
32
  kind?: string
30
33
  project?: string
31
- session?: string
34
+ /** Opaque exact-session override. */
35
+ sessionId?: string
36
+ /** Human-readable session label override. */
37
+ sessionLabel?: string
32
38
  device?: string[]
33
39
  all?: boolean
34
40
  ttl?: number
@@ -37,7 +43,15 @@ export interface SendFlags {
37
43
  threadId?: string
38
44
  level?: string
39
45
  data?: string[]
40
- image?: string
46
+ /** Ready media ids in author-supplied order. Commands resolve paths and URLs first. */
47
+ image?: string[]
48
+ /** Optional alt text paired with images by position. */
49
+ imageAlt?: string[]
50
+ /**
51
+ * Complete canonical media manifest for internal callers that already froze
52
+ * uploads and alt text. Mutually exclusive with the CLI's parallel inputs.
53
+ */
54
+ media?: MediaItemT[]
41
55
  /** Enable the inline reply action for this Notification Request. */
42
56
  reply?: boolean
43
57
  /** How long the server accepts a reply after submission. */
@@ -67,33 +81,109 @@ export type DraftBuild =
67
81
  | { ok: true; draft: NotificationDraftT; platform: Platform }
68
82
  | { ok: false; error: string }
69
83
 
84
+ /** Invocation-derived values beneath explicit and configured author input. */
85
+ export interface DraftInvocation {
86
+ inferredProject?: string | null
87
+ source?: SourceContextT
88
+ }
89
+
90
+ const POSITIONAL_MEDIA_REFERENCE = /media:(\d+)(?![A-Za-z0-9_-])/g
91
+
92
+ /** Validate media cardinality and positional alt pairing before any upload starts. */
93
+ export function validateMediaInputs(
94
+ images: readonly string[] | undefined,
95
+ alts: readonly string[] | undefined,
96
+ ): string | null {
97
+ const imageCount = images?.length ?? 0
98
+ const imageAlts = alts ?? []
99
+ if (imageCount > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
100
+ if (imageAlts.length > imageCount) {
101
+ return `Received ${imageAlts.length} --image-alt values for ${imageCount} --image values.`
102
+ }
103
+ if (imageAlts.some((alt) => alt.length < 1 || alt.length > 256)) {
104
+ return '--image-alt must be 1-256 characters.'
105
+ }
106
+ return null
107
+ }
108
+
109
+ /** Validate a frozen value-object manifest without collapsing sparse alt text. */
110
+ function validateMediaManifest(media: readonly MediaItemT[]): string | null {
111
+ if (media.length > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
112
+ if (media.some((item) => item.alt !== undefined && (item.alt.length < 1 || item.alt.length > 256))) {
113
+ return 'Media alt text must be 1-256 characters.'
114
+ }
115
+ return null
116
+ }
117
+
118
+ /** Rewrite authorable 1-based media positions to canonical ready media ids. */
119
+ export function rewriteMediaReferences(
120
+ body: string,
121
+ mediaIds: readonly string[],
122
+ ): { ok: true; body: string } | { ok: false; error: string } {
123
+ let error: string | undefined
124
+ const rewritten = body.replace(POSITIONAL_MEDIA_REFERENCE, (reference, digits: string) => {
125
+ const position = Number(digits)
126
+ if (
127
+ !Number.isSafeInteger(position) ||
128
+ position < 1 ||
129
+ position > MEDIA_MAX_ITEMS ||
130
+ digits !== String(position) ||
131
+ mediaIds[position - 1] === undefined
132
+ ) {
133
+ error = `Body reference "${reference}" has no matching --image occurrence.`
134
+ return reference
135
+ }
136
+ return `media:${mediaIds[position - 1]}`
137
+ })
138
+ return error === undefined ? { ok: true, body: rewritten } : { ok: false, error }
139
+ }
140
+
70
141
  /** Merge flags over resolved config into a Notification Request draft. */
71
- export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
142
+ export function buildDraft(
143
+ config: CliConfig,
144
+ flags: SendFlags,
145
+ invocation: DraftInvocation = {},
146
+ ): DraftBuild {
72
147
  if (!flags.title || !flags.body) return { ok: false, error: 'Both --title and --body are required.' }
73
148
  const platform = resolvePlatform(flags.platform)
74
149
  if (!platform) {
75
150
  return { ok: false, error: `Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.` }
76
151
  }
77
152
 
153
+ if (flags.media !== undefined && (flags.image !== undefined || flags.imageAlt !== undefined)) {
154
+ return { ok: false, error: 'Internal media manifests cannot be combined with --image or --image-alt.' }
155
+ }
156
+ const imageIds = flags.media?.map((item) => item.media_id) ?? flags.image ?? []
157
+ const imageAlts = flags.imageAlt ?? []
158
+ const mediaInputError =
159
+ flags.media === undefined
160
+ ? validateMediaInputs(imageIds, imageAlts)
161
+ : validateMediaManifest(flags.media)
162
+ if (mediaInputError !== null) return { ok: false, error: mediaInputError }
163
+ const media =
164
+ flags.media ??
165
+ imageIds.map((media_id, index) => ({
166
+ media_id,
167
+ ...(imageAlts[index] !== undefined ? { alt: imageAlts[index] } : {}),
168
+ }))
169
+ const body = rewriteMediaReferences(flags.body, imageIds)
170
+ if (!body.ok) return body
171
+
78
172
  const deviceIds = flags.all ? null : (flags.device?.length ? flags.device : config.devices.value)
79
173
  const targets: NotificationDraftT['targets'] =
80
174
  deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
81
175
 
82
- const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
83
- const profile = KIND_PROFILES[effectiveKind as keyof typeof KIND_PROFILES]
84
- const configuredSound = config.sound.source === 'default' ? undefined : config.sound.value
85
- const sound = flags.sound ?? configuredSound ?? profile?.sound
176
+ // Kind is semantic status metadata for Companion presentation. It never
177
+ // chooses native-banner attention; only an explicit flag or saved preference
178
+ // may add sound or interruption fields to the platform payload.
179
+ const sound = flags.sound ?? config.sound.value
86
180
  if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
87
181
  return {
88
182
  ok: false,
89
183
  error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
90
184
  }
91
185
  }
92
- const configuredLevel =
93
- config.interruption_level.source === 'default'
94
- ? undefined
95
- : config.interruption_level.value
96
- const level = flags.level ?? configuredLevel ?? profile?.level
186
+ const level = flags.level ?? config.interruption_level.value
97
187
  if (
98
188
  level !== null &&
99
189
  level !== undefined &&
@@ -144,10 +234,7 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
144
234
  }
145
235
  }
146
236
 
147
- const project = flags.project ?? config.project.value
148
- // Harnesses can export NOTIFAI_SESSION once instead of passing the flag
149
- // on every send.
150
- const session = flags.session ?? process.env['NOTIFAI_SESSION']
237
+ const project = flags.project ?? config.project.value ?? invocation.inferredProject
151
238
 
152
239
  // Ids are derived from labels so an agent writing a one-liner does not have
153
240
  // to invent them, but they stay the stable thing it branches on afterwards.
@@ -161,19 +248,19 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
161
248
  if (flags.replyMulti && choices === null && flags.questions === undefined) {
162
249
  return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' }
163
250
  }
164
- // The banner body doubles as the question text on a single-question send, so
165
- // it inherits the question's readable-in-full bound; context beyond that
166
- // belongs in --detail, which never rides the banner.
251
+ // A single reply question is the first readable block of the canonical body.
252
+ // Context may follow after a blank line without changing what the user answers.
253
+ const derivedQuestion = bannerExcerpt(body.body).split('\n', 1)[0]!.trim()
167
254
  if (
168
255
  flags.reply &&
169
256
  flags.questions === undefined &&
170
- flags.body.length > QUESTION_TEXT_MAX_LENGTH
257
+ derivedQuestion.length > QUESTION_TEXT_MAX_LENGTH
171
258
  ) {
172
259
  return {
173
260
  ok: false,
174
261
  error:
175
- `A question must be readable where it is answered: keep it within ${QUESTION_TEXT_MAX_LENGTH} ` +
176
- 'characters and put the longer context in --detail.',
262
+ `Keep the question within ${QUESTION_TEXT_MAX_LENGTH} characters and put ` +
263
+ 'the longer context after a blank line — the first paragraph is the question.',
177
264
  }
178
265
  }
179
266
 
@@ -185,15 +272,12 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
185
272
  : {}),
186
273
  ...(flags.lifecycle !== undefined ? { lifecycle: flags.lifecycle } : {}),
187
274
  ...(project !== null && project !== undefined ? { project } : {}),
188
- ...(session !== undefined && session !== '' ? { session } : {}),
275
+ ...(invocation.source !== undefined ? { source: invocation.source } : {}),
189
276
  presentation: {
190
277
  title: flags.title,
191
- body: flags.body,
278
+ body: body.body,
192
279
  ...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
193
- ...(flags.detail !== undefined && flags.detail.trim() !== ''
194
- ? { detail: flags.detail }
195
- : {}),
196
- ...(flags.image !== undefined ? { image: { media_id: flags.image } } : {}),
280
+ ...(media.length > 0 ? { media } : {}),
197
281
  },
198
282
  targets,
199
283
  delivery: { ttl_seconds: ttl, collapse_key: collapse },
@@ -204,7 +288,7 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
204
288
  questions: flags.questions ?? [
205
289
  {
206
290
  id: 'q1',
207
- text: flags.body,
291
+ text: derivedQuestion,
208
292
  ...(choices !== null ? { choices } : {}),
209
293
  ...(flags.replyMulti ? { multi: true } : {}),
210
294
  },
@@ -223,12 +307,6 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
223
307
  return { ok: true, draft, platform }
224
308
  }
225
309
 
226
- const KIND_PROFILES = {
227
- update: { sound: 'none', level: 'passive' },
228
- done: { sound: 'done', level: 'passive' },
229
- question: { sound: 'attention', level: 'active' },
230
- } as const
231
-
232
310
  function resolvePlatform(value: string | undefined): Platform | null {
233
311
  if (value === undefined) return 'ios'
234
312
  return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null