@raidiant/notifai 3.0.1 → 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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
9
9
  and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
10
10
 
11
+ ## [4.0.0](https://github.com/Raidiant-io/notifai/compare/v3.0.1...v4.0.0) (2026-08-15)
12
+
13
+
14
+ ### ⚠ BREAKING CHANGES
15
+
16
+ * **cli:** `--detail` and `--detail-file` are removed; the one canonical Markdown body is `--body` / `--body-file`. `--session` is removed, replaced by `--session-id` and `--session-label`. `--image` is now a repeatable ordered collection (max 8) with `--image-alt` for alt text.
17
+
18
+ ### Features
19
+
20
+ * add unified notification content and source context ([3e6479a](https://github.com/Raidiant-io/notifai/commit/3e6479ae22c64c6886c1e919ba3183486e408596))
21
+ * add unified notification content and source context ([2473ed5](https://github.com/Raidiant-io/notifai/commit/2473ed51df42b56652435c611a57f3751fa484f5))
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * attribute the unified-content break and propagate workspace dependency bumps ([cb438ce](https://github.com/Raidiant-io/notifai/commit/cb438cec76de9b393d7673554dc440a764887f76))
27
+ * **cli:** document replacements for the removed send flags ([44af905](https://github.com/Raidiant-io/notifai/commit/44af90536a1e6fa5af2ec6b58a19ea68f2d76b96))
28
+ * print server rejection details on command failures ([884fc78](https://github.com/Raidiant-io/notifai/commit/884fc78b62382ad23a49e1c5b92772dd1b8d574b))
29
+ * print server rejection details on command failures ([c9ec4e4](https://github.com/Raidiant-io/notifai/commit/c9ec4e45130784fff7b8ab4da560899eaddffcc5))
30
+
31
+
32
+ ### Dependencies
33
+
34
+ * The following workspace dependencies were updated
35
+ * dependencies
36
+ * @raidiant/notifai-protocol bumped from 0.6.0 to 1.0.0
37
+
11
38
  ## [3.0.1](https://github.com/Raidiant-io/notifai/compare/v3.0.0...v3.0.1) (2026-08-14)
12
39
 
13
40
 
package/README.md CHANGED
@@ -32,10 +32,13 @@ project or the machine. An unattended caller must select one explicitly:
32
32
  notifai send --title "Deploy finished" --body "staging, 2m14s" --kind done
33
33
  ```
34
34
 
35
- Write for a lock screen. The title gets a couple of seconds of attention
36
- and usually has to carry the whole message; assume the body may never be
37
- read. Long-form content belongs in `--detail`, which never appears on the
38
- banner and is shown in the companion app for when you sit down.
35
+ Write a brief title whose substance is immediately understandable; type lives
36
+ in `--kind`, and Project identity is inferred from the invocation directory.
37
+ The one body is always Markdown. Native banners show a bounded readable
38
+ plain-text excerpt from it, while Companion Apps render the complete body. Use
39
+ `--body-file <path|->` for long content. Repeat `--image` for an ordered image
40
+ collection (up to eight), pair each with `--image-alt` for alt text, and use
41
+ `media:1`, `media:2`, and so on for inline references.
39
42
 
40
43
  ## Asking you a question
41
44
 
@@ -205,21 +205,29 @@ export interface AskFlags {
205
205
  choice?: string[];
206
206
  /** The single question is multi-select: several answers may be chosen. */
207
207
  multi?: boolean;
208
- /** Long-form markdown context; shown in the app, never on the banner. */
209
- detail?: string;
208
+ /** Optional Markdown context appended after the question block. */
209
+ body?: string;
210
210
  /** Raw JSON for a multi-question form; replaces the positional question. */
211
211
  form?: string;
212
- session?: string;
212
+ image?: string[];
213
+ imageAlt?: string[];
214
+ project?: string;
215
+ sessionId?: string;
216
+ sessionLabel?: string;
217
+ }
218
+ export interface BuiltQuestions {
219
+ questions: QuestionT[];
220
+ /** Canonical Markdown body: question block first, optional context second. */
221
+ body: string;
213
222
  }
214
223
  /**
215
- * Turn ask input into the question set that will ride the push. Everything is
216
- * validated here, at registration, because the push happens inside a hook
217
- * where a rejection becomes a stderr note the agent never reads.
224
+ * Turn ask input into questions plus their canonical body. Everything is
225
+ * validated at registration because a later hook failure is easy to miss.
218
226
  */
219
227
  export declare function buildQuestions(flags: AskFlags, question: string | undefined): {
220
228
  ok: true;
221
229
  questions: QuestionT[];
222
- detail?: string;
230
+ body: string;
223
231
  } | {
224
232
  ok: false;
225
233
  error: string;
@@ -229,7 +237,7 @@ export declare function buildQuestions(flags: AskFlags, question: string | undef
229
237
  * can ask in prose and end its turn; the terminal keeps the question to itself
230
238
  * for `ask_grace_seconds` before it reaches any device.
231
239
  */
232
- export declare function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number;
240
+ export declare function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number | Promise<number>;
233
241
  /** Record the one Agent Acknowledgement associated with a replied-to request. */
234
242
  export declare function acknowledgeCommand(deps: CommandDeps, requestId: string, flags: {
235
243
  text?: string;
@@ -314,7 +322,7 @@ export declare function logsCommand(deps: CommandDeps, flags: LogsFlags): number
314
322
  * Null when the build cannot establish its version; see `./release.js`.
315
323
  */
316
324
  export declare const SKILLS_SOURCE: string | null;
317
- /** Derive a contract-valid project slug from a directory name. */
325
+ /** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
318
326
  export declare function projectSlugFrom(name: string): string;
319
327
  export interface InitFlags {
320
328
  projectId?: string;
package/dist/commands.js CHANGED
@@ -21,7 +21,8 @@ import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from '
21
21
  import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
22
22
  import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
23
23
  import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
24
- import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
24
+ import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, validateMediaInputs, } from './send.js';
25
+ import { buildSourceContext, inferInvocationContext, projectSlugFrom as inferredProjectSlugFrom, } from './invocation-context.js';
25
26
  import { openUrl } from './platform.js';
26
27
  /** The invocation's logger, or one that discards everything. */
27
28
  export function log(deps) {
@@ -79,6 +80,18 @@ function reportError(deps, err, context = {}) {
79
80
  });
80
81
  if (err instanceof ApiCallError) {
81
82
  deps.io.err(`${err.code}: ${err.message}`);
83
+ // The server's field-level details were already being logged above, but
84
+ // stderr is the one surface an agent's next turn actually reads — so the
85
+ // diagnosis has to be on it, the same way the hook path prints it.
86
+ const paths = rejectedPaths(err.details);
87
+ if (paths.length > 0)
88
+ deps.io.err(`the server rejected: ${paths.join(', ')}`);
89
+ // A 422 on a request this CLI built is not a user error: the two sides
90
+ // disagree about the contract, in whichever direction. `doctor` says which.
91
+ if (err.status === 422) {
92
+ deps.io.err('this build sent a field the server did not accept — the CLI and server ' +
93
+ 'disagree about the contract; check with `notifai doctor`');
94
+ }
82
95
  if (err.nextAction)
83
96
  deps.io.err(`next: ${err.nextAction}`);
84
97
  if (err.code === 'auth_required' || err.code === 'machine_revoked')
@@ -324,12 +337,42 @@ export async function capabilitiesCommand(deps, flags) {
324
337
  // ---------------------------------------------------------------------------
325
338
  // send / status
326
339
  // ---------------------------------------------------------------------------
340
+ function resolveDraftInvocation(deps, flags, active) {
341
+ const inferred = inferInvocationContext(deps.cwd);
342
+ const source = buildSourceContext({
343
+ env: deps.env,
344
+ invocation: inferred,
345
+ ...(flags.sessionId !== undefined ? { sessionId: flags.sessionId } : {}),
346
+ ...(flags.sessionLabel !== undefined ? { sessionLabel: flags.sessionLabel } : {}),
347
+ ...(active === null
348
+ ? {}
349
+ : {
350
+ activeHarness: {
351
+ harness: active.harness,
352
+ ...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
353
+ },
354
+ }),
355
+ });
356
+ if (!source.ok)
357
+ return source;
358
+ return {
359
+ ok: true,
360
+ invocation: {
361
+ inferredProject: inferred.project,
362
+ ...(source.source === undefined ? {} : { source: source.source }),
363
+ },
364
+ };
365
+ }
327
366
  export async function sendCommand(deps, flags) {
328
367
  const hasReplyChoice = Array.isArray(flags.replyChoice)
329
368
  ? flags.replyChoice.length > 0
330
369
  : flags.replyChoice !== undefined;
331
- if (flags.reply && flags.kind === 'done') {
332
- deps.io.err('--kind done cannot be combined with --reply; a reply request is a question.');
370
+ if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
371
+ deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`);
372
+ return EXIT.usage;
373
+ }
374
+ if (!flags.reply && flags.kind === 'question') {
375
+ deps.io.err('--kind question requires --reply so the question can be answered.');
333
376
  return EXIT.usage;
334
377
  }
335
378
  if (!flags.reply &&
@@ -366,23 +409,39 @@ export async function sendCommand(deps, flags) {
366
409
  deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
367
410
  return EXIT.usage;
368
411
  }
412
+ const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
413
+ if (mediaInputError !== null) {
414
+ deps.io.err(mediaInputError);
415
+ return EXIT.usage;
416
+ }
369
417
  const config = loadLoggedConfig(deps, {
370
418
  cwd: deps.cwd,
371
419
  env: deps.env,
372
420
  flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
373
421
  });
422
+ const source = resolveDraftInvocation(deps, flags, activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()));
423
+ if (!source.ok) {
424
+ deps.io.err(source.error);
425
+ return EXIT.usage;
426
+ }
374
427
  const authed = authedClient(deps, config);
375
428
  if (!authed)
376
429
  return EXIT.auth;
377
- if (flags.image !== undefined && !flags.image.startsWith('med_')) {
378
- const uploaded = await uploadImage(deps, authed.client, flags.image);
430
+ const mediaIds = [];
431
+ for (const image of flags.image ?? []) {
432
+ if (image.startsWith('med_')) {
433
+ mediaIds.push(image);
434
+ continue;
435
+ }
436
+ const uploaded = await uploadImage(deps, authed.client, image);
379
437
  if (!uploaded.ok) {
380
438
  deps.io.err(uploaded.error);
381
439
  return uploaded.exit;
382
440
  }
383
- flags = { ...flags, image: uploaded.mediaId };
441
+ mediaIds.push(uploaded.mediaId);
384
442
  }
385
- const build = buildDraft(config, flags);
443
+ flags = { ...flags, image: mediaIds };
444
+ const build = buildDraft(config, flags, source.invocation);
386
445
  if (!build.ok) {
387
446
  deps.io.err(build.error);
388
447
  return EXIT.usage;
@@ -529,12 +588,8 @@ function emitSendWarnings(deps, flags, config) {
529
588
  if (flags.title.length > 40) {
530
589
  deps.io.err(`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`);
531
590
  }
532
- if (looksLikeMarkdown(flags.body)) {
533
- deps.io.err('Heads up: --body looks like Markdown, but banners show plain text. Put long-form Markdown in --detail or --detail-file.');
534
- }
535
- const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update');
536
- if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
537
- deps.io.err(`Heads up: this title announces completion but the notification kind is update. Use --kind done for finished work.`);
591
+ if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
592
+ deps.io.err('Heads up: keep the title to the specific substance. Put notification type in --kind; project identity is inferred separately.');
538
593
  }
539
594
  if (flags.collapseKey === undefined &&
540
595
  config.collapse_key.value !== null &&
@@ -545,9 +600,6 @@ function emitSendWarnings(deps, flags, config) {
545
600
  deps.io.err('Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.');
546
601
  }
547
602
  }
548
- function looksLikeMarkdown(value) {
549
- return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(value);
550
- }
551
603
  export async function repliesCommand(deps, requestedId, flags) {
552
604
  const waitSeconds = flags.wait ?? 0;
553
605
  const afterSeq = flags.after ?? 0;
@@ -1251,9 +1303,8 @@ function rejectedPaths(details) {
1251
1303
  .slice(0, 5);
1252
1304
  }
1253
1305
  /**
1254
- * Turn ask input into the question set that will ride the push. Everything is
1255
- * validated here, at registration, because the push happens inside a hook
1256
- * where a rejection becomes a stderr note the agent never reads.
1306
+ * Turn ask input into questions plus their canonical body. Everything is
1307
+ * validated at registration because a later hook failure is easy to miss.
1257
1308
  */
1258
1309
  export function buildQuestions(flags, question) {
1259
1310
  if (flags.form !== undefined) {
@@ -1265,21 +1316,41 @@ export function buildQuestions(flags, question) {
1265
1316
  parsed = JSON.parse(flags.form);
1266
1317
  }
1267
1318
  catch {
1268
- return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' };
1319
+ return {
1320
+ ok: false,
1321
+ error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
1322
+ };
1269
1323
  }
1270
- if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.questions)) {
1324
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1271
1325
  return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
1272
1326
  }
1273
- const form = parsed;
1274
- if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
1275
- return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` };
1327
+ const record = parsed;
1328
+ const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body');
1329
+ if (unknownKeys.length > 0) {
1330
+ return {
1331
+ ok: false,
1332
+ error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
1333
+ };
1334
+ }
1335
+ if (!Array.isArray(record['questions'])) {
1336
+ return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
1337
+ }
1338
+ const formQuestions = record['questions'];
1339
+ if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
1340
+ return {
1341
+ ok: false,
1342
+ error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
1343
+ };
1344
+ }
1345
+ if (record['body'] !== undefined && typeof record['body'] !== 'string') {
1346
+ return { ok: false, error: '"body" must be a Markdown string.' };
1276
1347
  }
1277
- if (form.detail !== undefined && typeof form.detail !== 'string') {
1278
- return { ok: false, error: '"detail" must be a markdown string.' };
1348
+ if (flags.body !== undefined && record['body'] !== undefined) {
1349
+ return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' };
1279
1350
  }
1280
1351
  const questions = [];
1281
1352
  const usedIds = new Set();
1282
- for (const [index, entry] of form.questions.entries()) {
1353
+ for (const [index, entry] of formQuestions.entries()) {
1283
1354
  if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
1284
1355
  return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
1285
1356
  }
@@ -1289,7 +1360,15 @@ export function buildQuestions(flags, question) {
1289
1360
  return { ok: false, error: `Question ${index + 1}: ${built.error}` };
1290
1361
  questions.push(built.question);
1291
1362
  }
1292
- return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) };
1363
+ const context = flags.body ?? record['body'];
1364
+ const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n');
1365
+ return {
1366
+ ok: true,
1367
+ questions,
1368
+ body: context !== undefined && context.trim() !== ''
1369
+ ? `${questionBlock}\n\n${context}`
1370
+ : questionBlock,
1371
+ };
1293
1372
  }
1294
1373
  if (question === undefined || question.trim() === '') {
1295
1374
  return { ok: false, error: 'The question cannot be empty.' };
@@ -1297,10 +1376,13 @@ export function buildQuestions(flags, question) {
1297
1376
  const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
1298
1377
  if ('error' in built)
1299
1378
  return { ok: false, error: built.error };
1379
+ const context = flags.body;
1300
1380
  return {
1301
1381
  ok: true,
1302
1382
  questions: [built.question],
1303
- ...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
1383
+ body: context !== undefined && context.trim() !== ''
1384
+ ? `${built.question.text}\n\n${context}`
1385
+ : built.question.text,
1304
1386
  };
1305
1387
  }
1306
1388
  function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
@@ -1310,7 +1392,7 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
1310
1392
  if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
1311
1393
  return {
1312
1394
  error: `a question must be readable where it is answered: keep it within ` +
1313
- `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
1395
+ `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
1314
1396
  };
1315
1397
  }
1316
1398
  const choices = parseChoices(choiceLabels);
@@ -1332,6 +1414,108 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
1332
1414
  },
1333
1415
  };
1334
1416
  }
1417
+ function buildAskDraft(config, built, flags, invocation, mediaIds) {
1418
+ const result = buildDraft(config, {
1419
+ title: built.questions[0].text,
1420
+ body: built.body,
1421
+ ...(flags.project !== undefined ? { project: flags.project } : {}),
1422
+ ...(mediaIds.length > 0 ? { image: mediaIds } : {}),
1423
+ ...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
1424
+ reply: true,
1425
+ questions: built.questions,
1426
+ }, invocation);
1427
+ if (!result.ok)
1428
+ return result;
1429
+ const capabilities = CAPABILITIES_V1.describe(result.platform);
1430
+ if (capabilities === null)
1431
+ return { ok: false, error: 'No iOS capability contract is available.' };
1432
+ const validation = validateDraft(result.draft, capabilities);
1433
+ if (!validation.ok) {
1434
+ return {
1435
+ ok: false,
1436
+ error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
1437
+ };
1438
+ }
1439
+ return { ok: true, draft: result.draft };
1440
+ }
1441
+ function recordRegisteredQuestion(deps, sessionId, built, draft) {
1442
+ try {
1443
+ registerQuestion(sessionId, deps.env, {
1444
+ question: built.questions[0].text,
1445
+ questions: built.questions,
1446
+ body: draft.presentation.body,
1447
+ ...(draft.project !== undefined ? { project: draft.project } : {}),
1448
+ ...(draft.source !== undefined ? { source: draft.source } : {}),
1449
+ ...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
1450
+ }, (deps.now ?? Date.now)());
1451
+ }
1452
+ catch (err) {
1453
+ log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
1454
+ deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
1455
+ return EXIT.failed;
1456
+ }
1457
+ log(deps).info('ask.registered', {
1458
+ ok: true,
1459
+ session: sessionId,
1460
+ questions: built.questions.length,
1461
+ text: built.questions[0].text,
1462
+ choices: built.questions[0].choices?.length ?? 0,
1463
+ media: draft.presentation.media?.length ?? 0,
1464
+ });
1465
+ for (const [index, entry] of built.questions.entries()) {
1466
+ const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
1467
+ if (entry.choices !== undefined) {
1468
+ const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
1469
+ deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
1470
+ }
1471
+ else if (built.questions.length > 1) {
1472
+ deps.io.out(`${prefix}${entry.text} — free text`);
1473
+ }
1474
+ }
1475
+ deps.io.out(built.questions.length > 1
1476
+ ? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
1477
+ : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
1478
+ deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
1479
+ for (const [index, entry] of built.questions.entries()) {
1480
+ const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
1481
+ if (entry.choices !== undefined) {
1482
+ for (const choice of entry.choices) {
1483
+ deps.io.out(`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`);
1484
+ }
1485
+ deps.io.out(`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`);
1486
+ }
1487
+ else {
1488
+ deps.io.out(`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`);
1489
+ }
1490
+ }
1491
+ deps.io.out('When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.');
1492
+ deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
1493
+ return EXIT.ok;
1494
+ }
1495
+ async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
1496
+ const authed = authedClient(deps, config);
1497
+ if (!authed)
1498
+ return EXIT.auth;
1499
+ const mediaIds = [];
1500
+ for (const image of flags.image ?? []) {
1501
+ if (image.startsWith('med_')) {
1502
+ mediaIds.push(image);
1503
+ continue;
1504
+ }
1505
+ const uploaded = await uploadImage(deps, authed.client, image);
1506
+ if (!uploaded.ok) {
1507
+ deps.io.err(uploaded.error);
1508
+ return uploaded.exit;
1509
+ }
1510
+ mediaIds.push(uploaded.mediaId);
1511
+ }
1512
+ const ready = buildAskDraft(config, built, flags, invocation, mediaIds);
1513
+ if (!ready.ok) {
1514
+ deps.io.err(ready.error);
1515
+ return EXIT.usage;
1516
+ }
1517
+ return recordRegisteredQuestion(deps, sessionId, built, ready.draft);
1518
+ }
1335
1519
  /**
1336
1520
  * Registers a question for turn-end routing. Returns immediately so the agent
1337
1521
  * can ask in prose and end its turn; the terminal keeps the question to itself
@@ -1346,6 +1530,11 @@ export function askCommand(deps, question, flags) {
1346
1530
  deps.io.err(built.error);
1347
1531
  return EXIT.usage;
1348
1532
  }
1533
+ const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
1534
+ if (mediaInputError !== null) {
1535
+ deps.io.err(mediaInputError);
1536
+ return EXIT.usage;
1537
+ }
1349
1538
  const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
1350
1539
  if (!routingConfig.ask_notifications.value) {
1351
1540
  deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
@@ -1383,10 +1572,6 @@ export function askCommand(deps, question, flags) {
1383
1572
  }
1384
1573
  return EXIT.usage;
1385
1574
  }
1386
- if (flags.session !== undefined && flags.session !== active.sessionId) {
1387
- deps.io.err(`Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`);
1388
- return EXIT.usage;
1389
- }
1390
1575
  const routeProblems = activeQuestionRouteProblems(deps, active, installations);
1391
1576
  if (routeProblems.length > 0) {
1392
1577
  for (const problem of routeProblems)
@@ -1410,57 +1595,27 @@ export function askCommand(deps, question, flags) {
1410
1595
  deps.io.err(line);
1411
1596
  return EXIT.usage;
1412
1597
  }
1413
- try {
1414
- registerQuestion(sessionId, deps.env, {
1415
- question: built.questions[0].text,
1416
- questions: built.questions,
1417
- ...(built.detail !== undefined ? { detail: built.detail } : {}),
1418
- }, (deps.now ?? Date.now)());
1598
+ const source = resolveDraftInvocation(deps, flags, active);
1599
+ if (!source.ok) {
1600
+ deps.io.err(source.error);
1601
+ return EXIT.usage;
1419
1602
  }
1420
- catch (err) {
1421
- log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
1422
- deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
1423
- return EXIT.failed;
1603
+ if (source.invocation.source?.session_id !== sessionId) {
1604
+ deps.io.err(`Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`);
1605
+ return EXIT.usage;
1424
1606
  }
1425
- // `ask` returns immediately and the push happens later inside a hook, so this
1426
- // is the only local evidence that the question was ever registered — the
1427
- // starting point for "the agent asked, and nothing ever reached my phone".
1428
- log(deps).info('ask.registered', {
1429
- ok: true,
1430
- session: sessionId,
1431
- questions: built.questions.length,
1432
- text: built.questions[0].text,
1433
- choices: built.questions[0].choices?.length ?? 0,
1434
- });
1435
- for (const [index, entry] of built.questions.entries()) {
1436
- const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
1437
- if (entry.choices !== undefined) {
1438
- const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
1439
- deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
1440
- }
1441
- else if (built.questions.length > 1) {
1442
- deps.io.out(`${prefix}${entry.text} — free text`);
1443
- }
1607
+ // Placeholders let every body, source, project, media, and payload limit fail
1608
+ // before an upload starts. The real ids replace them only after this passes.
1609
+ const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`);
1610
+ const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders);
1611
+ if (!preflight.ok) {
1612
+ deps.io.err(preflight.error);
1613
+ return EXIT.usage;
1444
1614
  }
1445
- deps.io.out(built.questions.length > 1
1446
- ? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
1447
- : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
1448
- deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
1449
- for (const [index, entry] of built.questions.entries()) {
1450
- const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
1451
- if (entry.choices !== undefined) {
1452
- for (const choice of entry.choices) {
1453
- deps.io.out(`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`);
1454
- }
1455
- deps.io.out(`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`);
1456
- }
1457
- else {
1458
- deps.io.out(`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`);
1459
- }
1615
+ if ((flags.image?.length ?? 0) > 0) {
1616
+ return uploadAskMedia(deps, routingConfig, sessionId, built, flags, source.invocation);
1460
1617
  }
1461
- deps.io.out('When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.');
1462
- deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
1463
- return EXIT.ok;
1618
+ return recordRegisteredQuestion(deps, sessionId, built, preflight.draft);
1464
1619
  }
1465
1620
  /**
1466
1621
  * Every harness marker present in this environment, in declared order.
@@ -2599,14 +2754,9 @@ async function skillReadiness(deps, selectedScope) {
2599
2754
  },
2600
2755
  };
2601
2756
  }
2602
- /** Derive a contract-valid project slug from a directory name. */
2757
+ /** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
2603
2758
  export function projectSlugFrom(name) {
2604
- const slug = name
2605
- .toLowerCase()
2606
- .replaceAll(/[^a-z0-9._-]+/g, '-')
2607
- .replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
2608
- .slice(0, 64);
2609
- return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project';
2759
+ return inferredProjectSlugFrom(name) ?? 'project';
2610
2760
  }
2611
2761
  /** Long enough for a first TestFlight install; keep-waiting extends another budget. */
2612
2762
  const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000;
@@ -3265,29 +3415,40 @@ async function contractCheck(client) {
3265
3415
  }
3266
3416
  }
3267
3417
  function projectReadiness(deps, config) {
3268
- const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
3269
- const projectSlug = config.project.value;
3270
- return projectSlug !== null
3271
- ? {
3418
+ const configured = config.project.value;
3419
+ if (configured !== null) {
3420
+ return {
3272
3421
  id: 'project',
3273
3422
  title: 'Project identity',
3274
3423
  status: 'ready',
3275
- detail: `"${projectSlug}" (${config.project.source})`,
3276
- }
3277
- : {
3424
+ detail: `"${configured}" (${config.project.source})`,
3425
+ };
3426
+ }
3427
+ const inferred = inferInvocationContext(deps.cwd).project;
3428
+ if (inferred !== null) {
3429
+ return {
3278
3430
  id: 'project',
3279
3431
  title: 'Project identity',
3280
- // Not a blocker: a send without a project simply carries no project
3281
- // identity. init always sets one because it is free and reversible,
3282
- // but an unlabelled setup works, so this must not go red.
3283
3432
  status: 'optional-gap',
3284
- detail: `not set in ${configPath} — sends from here carry no project identity`,
3433
+ detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
3285
3434
  remedy: {
3286
3435
  by: 'cli',
3287
- summary: 'name this project after its directory',
3436
+ summary: 'make the inferred Project identity explicit for every checkout',
3288
3437
  command: 'notifai init',
3289
3438
  },
3290
3439
  };
3440
+ }
3441
+ return {
3442
+ id: 'project',
3443
+ title: 'Project identity',
3444
+ status: 'optional-gap',
3445
+ detail: 'the directory name has no characters a Project identifier can use',
3446
+ remedy: {
3447
+ by: 'cli',
3448
+ summary: 'choose an explicit Project identifier',
3449
+ command: 'notifai init --project-id my-project',
3450
+ },
3451
+ };
3291
3452
  }
3292
3453
  function remoteStatesFrom(previous) {
3293
3454
  const pick = (id) => previous.states.find((state) => state.id === id);