@raidiant/notifai 3.0.1 → 5.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 (71) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +12 -4
  3. package/dist/commands.d.ts +27 -10
  4. package/dist/commands.js +429 -152
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +20 -11
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +12 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +84 -12
  17. package/dist/hooks.js +433 -125
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/invocation-context.d.ts +42 -0
  25. package/dist/invocation-context.js +130 -0
  26. package/dist/invocation-context.js.map +1 -0
  27. package/dist/logging.d.ts +8 -0
  28. package/dist/logging.js +8 -0
  29. package/dist/logging.js.map +1 -1
  30. package/dist/main.js +84 -49
  31. package/dist/main.js.map +1 -1
  32. package/dist/opencode-plugin.d.ts +8 -0
  33. package/dist/opencode-plugin.js +24 -162
  34. package/dist/opencode-plugin.js.map +1 -1
  35. package/dist/send.d.ts +38 -7
  36. package/dist/send.js +97 -41
  37. package/dist/send.js.map +1 -1
  38. package/dist/ui/help.d.ts +0 -1
  39. package/dist/ui/help.js +6 -3
  40. package/dist/ui/help.js.map +1 -1
  41. package/dist/ui/theme.d.ts +0 -15
  42. package/dist/ui/theme.js +2 -18
  43. package/dist/ui/theme.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/commands.test.ts +501 -63
  46. package/src/commands.ts +547 -187
  47. package/src/config-schema.test.ts +1 -1
  48. package/src/config-schema.ts +22 -11
  49. package/src/config.test.ts +178 -18
  50. package/src/config.ts +14 -1
  51. package/src/file-lock.test.ts +41 -0
  52. package/src/file-lock.ts +59 -9
  53. package/src/harnesses.test.ts +37 -29
  54. package/src/harnesses.ts +1 -4
  55. package/src/hooks.test.ts +500 -58
  56. package/src/hooks.ts +523 -169
  57. package/src/install-hooks.test.ts +232 -12
  58. package/src/install-hooks.ts +241 -37
  59. package/src/interactive.ts +1 -1
  60. package/src/invocation-context.test.ts +253 -0
  61. package/src/invocation-context.ts +176 -0
  62. package/src/logging-process-worker.test.ts +8 -2
  63. package/src/logging.test.ts +33 -18
  64. package/src/logging.ts +11 -0
  65. package/src/main.ts +119 -54
  66. package/src/opencode-plugin.ts +24 -164
  67. package/src/question-caps.test.ts +49 -0
  68. package/src/send.ts +144 -50
  69. package/src/skill.test.ts +197 -120
  70. package/src/ui/help.ts +8 -3
  71. package/src/ui/theme.ts +2 -16
package/dist/commands.js CHANGED
@@ -3,25 +3,26 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, u
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
- import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH, CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
6
+ import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH, CAPABILITIES_V1, PLATFORMS, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
7
7
  import { sha256Hex } from '@raidiant/notifai-protocol/node';
8
8
  import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
9
- import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, stateDir, } from './config.js';
9
+ import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, stateDir, } from './config.js';
10
10
  import { acceptedValues, configInfo } from './config-schema.js';
11
11
  import { packageVersion, skillsSource } from './release.js';
12
12
  import { atomicWriteFileSync } from './atomic-file.js';
13
13
  import { withTargetFileLock } from './file-lock.js';
14
14
  import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
15
15
  import { firstBlocker, } from './readiness.js';
16
- import { WAITER_CEILING_SECONDS, clearAcknowledgementObligation, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readLiveProjectSessionPointers, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
17
- import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, } from './logging.js';
18
- import { BLOCKING_STOP_TIMEOUT_SECONDS, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, withCodexLayerTransaction, } from './install-hooks.js';
16
+ import { waiterCeilingSeconds, clearAcknowledgementObligation, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readLiveProjectSessionPointers, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, MIN_REPLY_WINDOW_SECONDS, } from './hooks.js';
17
+ import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, RECORD_LEVELS, } from './logging.js';
18
+ import { BLOCKING_STOP_TIMEOUT_SECONDS, stopHandlerIsDetached, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexLayerPaths, codexCoexistenceNotes, codexHomeNote, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, withCodexLayerTransaction, } from './install-hooks.js';
19
19
  import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
20
20
  import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from './claude-wake.js';
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,20 @@ 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
+ // Codes that name a policy the account chose are the exception — the
92
+ // contract held, the body simply did not satisfy that account.
93
+ if (err.status === 422 && err.code !== 'acknowledgement_text_required') {
94
+ deps.io.err('this build sent a field the server did not accept — the CLI and server ' +
95
+ 'disagree about the contract; check with `notifai doctor`');
96
+ }
82
97
  if (err.nextAction)
83
98
  deps.io.err(`next: ${err.nextAction}`);
84
99
  if (err.code === 'auth_required' || err.code === 'machine_revoked')
@@ -301,6 +316,13 @@ export function canDeviceReceive(device) {
301
316
  return deviceCanReceive(device);
302
317
  }
303
318
  export async function capabilitiesCommand(deps, flags) {
319
+ // Locally, and with the same message `send` gives. Spending a round trip to
320
+ // have the server answer "Request validation failed" told the caller neither
321
+ // which flag was wrong nor what it accepts.
322
+ if (flags.platform !== undefined && !PLATFORMS.includes(flags.platform)) {
323
+ deps.io.err(`Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.`);
324
+ return EXIT.usage;
325
+ }
304
326
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
305
327
  const credential = deps.store.load();
306
328
  const baseUrl = resolvedBaseUrl(config, credential);
@@ -324,12 +346,51 @@ export async function capabilitiesCommand(deps, flags) {
324
346
  // ---------------------------------------------------------------------------
325
347
  // send / status
326
348
  // ---------------------------------------------------------------------------
349
+ function resolveDraftInvocation(deps, flags, active) {
350
+ const inferred = inferInvocationContext(deps.cwd);
351
+ const source = buildSourceContext({
352
+ env: deps.env,
353
+ invocation: inferred,
354
+ ...(flags.sessionId !== undefined ? { sessionId: flags.sessionId } : {}),
355
+ ...(flags.sessionLabel !== undefined ? { sessionLabel: flags.sessionLabel } : {}),
356
+ ...(active === null
357
+ ? {}
358
+ : {
359
+ activeHarness: {
360
+ harness: active.harness,
361
+ ...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
362
+ },
363
+ }),
364
+ });
365
+ if (!source.ok)
366
+ return source;
367
+ return {
368
+ ok: true,
369
+ invocation: {
370
+ inferredProject: inferred.project,
371
+ ...(source.source === undefined ? {} : { source: source.source }),
372
+ },
373
+ };
374
+ }
327
375
  export async function sendCommand(deps, flags) {
328
376
  const hasReplyChoice = Array.isArray(flags.replyChoice)
329
377
  ? flags.replyChoice.length > 0
330
378
  : 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.');
379
+ if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
380
+ deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`);
381
+ return EXIT.usage;
382
+ }
383
+ // Kind now decides the sound a notification arrives with, so it is asked for
384
+ // at the boundary rather than defaulted silently: an unlabelled send would
385
+ // reach the user as ordinary news whatever actually happened.
386
+ if (!flags.reply && flags.kind === undefined) {
387
+ deps.io.err('--kind is required: say what this notification is. ' +
388
+ 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
389
+ '--reply makes it a question without --kind.');
390
+ return EXIT.usage;
391
+ }
392
+ if (!flags.reply && flags.kind === 'question') {
393
+ deps.io.err('--kind question requires --reply so the question can be answered.');
333
394
  return EXIT.usage;
334
395
  }
335
396
  if (!flags.reply &&
@@ -361,9 +422,14 @@ export async function sendCommand(deps, flags) {
361
422
  if (flags.reply &&
362
423
  flags.replyWindow !== undefined &&
363
424
  (!Number.isInteger(flags.replyWindow) ||
364
- flags.replyWindow < 60 ||
425
+ flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
365
426
  flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)) {
366
- deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
427
+ deps.io.err(`--reply-window must be an integer from ${MIN_REPLY_WINDOW_SECONDS} to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
428
+ return EXIT.usage;
429
+ }
430
+ const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
431
+ if (mediaInputError !== null) {
432
+ deps.io.err(mediaInputError);
367
433
  return EXIT.usage;
368
434
  }
369
435
  const config = loadLoggedConfig(deps, {
@@ -371,18 +437,30 @@ export async function sendCommand(deps, flags) {
371
437
  env: deps.env,
372
438
  flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
373
439
  });
440
+ const source = resolveDraftInvocation(deps, flags, activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()));
441
+ if (!source.ok) {
442
+ deps.io.err(source.error);
443
+ return EXIT.usage;
444
+ }
374
445
  const authed = authedClient(deps, config);
375
446
  if (!authed)
376
447
  return EXIT.auth;
377
- if (flags.image !== undefined && !flags.image.startsWith('med_')) {
378
- const uploaded = await uploadImage(deps, authed.client, flags.image);
448
+ const mediaIds = [];
449
+ for (const image of flags.image ?? []) {
450
+ if (image.startsWith('med_')) {
451
+ mediaIds.push(image);
452
+ continue;
453
+ }
454
+ const uploaded = await uploadImage(deps, authed.client, image);
379
455
  if (!uploaded.ok) {
380
- deps.io.err(uploaded.error);
456
+ if (uploaded.error !== null)
457
+ deps.io.err(uploaded.error);
381
458
  return uploaded.exit;
382
459
  }
383
- flags = { ...flags, image: uploaded.mediaId };
460
+ mediaIds.push(uploaded.mediaId);
384
461
  }
385
- const build = buildDraft(config, flags);
462
+ flags = { ...flags, image: mediaIds };
463
+ const build = buildDraft(config, flags, source.invocation);
386
464
  if (!build.ok) {
387
465
  deps.io.err(build.error);
388
466
  return EXIT.usage;
@@ -454,6 +532,7 @@ export async function sendCommand(deps, flags) {
454
532
  request_id: receipt.request_id,
455
533
  replies: [],
456
534
  agent_acknowledgement_required: receipt.agent_acknowledgement_required,
535
+ agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
457
536
  agent_acknowledgement: null,
458
537
  acknowledgement_command: null,
459
538
  degraded: false,
@@ -529,12 +608,8 @@ function emitSendWarnings(deps, flags, config) {
529
608
  if (flags.title.length > 40) {
530
609
  deps.io.err(`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`);
531
610
  }
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.`);
611
+ if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
612
+ deps.io.err('Heads up: keep the title to the specific substance. Put notification type in --kind; project identity is inferred separately.');
538
613
  }
539
614
  if (flags.collapseKey === undefined &&
540
615
  config.collapse_key.value !== null &&
@@ -545,9 +620,6 @@ function emitSendWarnings(deps, flags, config) {
545
620
  deps.io.err('Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.');
546
621
  }
547
622
  }
548
- function looksLikeMarkdown(value) {
549
- return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(value);
550
- }
551
623
  export async function repliesCommand(deps, requestedId, flags) {
552
624
  const waitSeconds = flags.wait ?? 0;
553
625
  const afterSeq = flags.after ?? 0;
@@ -577,7 +649,15 @@ export async function repliesCommand(deps, requestedId, flags) {
577
649
  .map((entry) => entry.request_id)
578
650
  .filter((id) => id !== undefined);
579
651
  if (requestIds.length === 0) {
580
- deps.io.err(`Session ${sessionId} has no pushed question pending.`);
652
+ // `--json` is a promise about stdout, and an empty result is still a
653
+ // result: a caller that asked for machine-readable output must not have
654
+ // to parse an English sentence to learn there was nothing pending.
655
+ if (flags.json === true) {
656
+ deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2));
657
+ }
658
+ else {
659
+ deps.io.err(`Session ${sessionId} has no pushed question pending.`);
660
+ }
581
661
  return EXIT.noReply;
582
662
  }
583
663
  }
@@ -696,6 +776,7 @@ export async function waitForReply(client, requestId, options) {
696
776
  request_id: requestId,
697
777
  reply_expires_at: null,
698
778
  agent_acknowledgement_required: false,
779
+ agent_acknowledgement_text_required: false,
699
780
  agent_acknowledgement: null,
700
781
  replies: [],
701
782
  },
@@ -734,9 +815,14 @@ function degradedWaitWarning(requestId) {
734
815
  function isNonNegativeInteger(value) {
735
816
  return Number.isInteger(value) && value >= 0;
736
817
  }
737
- function acknowledgementCommand(requestId, required, acknowledgement, hasReply = true) {
738
- return required && acknowledgement === null && hasReply
818
+ function acknowledgeInvocation(requestId, textRequired) {
819
+ return textRequired
739
820
  ? `notifai acknowledge ${requestId} --text <text>`
821
+ : `notifai acknowledge ${requestId}`;
822
+ }
823
+ function acknowledgementCommand(requestId, required, textRequired, acknowledgement, hasReply = true) {
824
+ return required && acknowledgement === null && hasReply
825
+ ? acknowledgeInvocation(requestId, textRequired)
740
826
  : null;
741
827
  }
742
828
  function replyResultJson(response, degraded) {
@@ -747,7 +833,8 @@ function replyResultJson(response, degraded) {
747
833
  replies: response.replies,
748
834
  agent_acknowledgement_required: response.agent_acknowledgement_required,
749
835
  agent_acknowledgement: response.agent_acknowledgement,
750
- acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement, response.replies.length > 0),
836
+ agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
837
+ acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
751
838
  degraded,
752
839
  };
753
840
  }
@@ -756,13 +843,19 @@ function printAcknowledgementStatus(deps, response) {
756
843
  deps.io.out('Agent Acknowledgement: not required for this request.');
757
844
  return;
758
845
  }
846
+ const textRequired = response.agent_acknowledgement_text_required;
759
847
  if (response.agent_acknowledgement !== null) {
760
- deps.io.out(`Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}: ${response.agent_acknowledgement.text}`);
848
+ const recorded = `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}`;
849
+ deps.io.out(response.agent_acknowledgement.text.length > 0
850
+ ? `${recorded}: ${response.agent_acknowledgement.text}`
851
+ : `${recorded}.`);
761
852
  return;
762
853
  }
763
854
  if (response.replies.length > 0) {
764
855
  deps.io.out('Agent Acknowledgement required.');
765
- deps.io.out(`next: Run \`${acknowledgementCommand(response.request_id, true, null)}\` with concrete text saying what you will do because of the reply.`);
856
+ deps.io.out(textRequired
857
+ ? `next: Run \`${acknowledgeInvocation(response.request_id, true)}\` with concrete text saying what you will do because of the reply.`
858
+ : `next: Run \`${acknowledgeInvocation(response.request_id, false)}\` so the user sees you read the reply; this account turned acknowledgement text off.`);
766
859
  return;
767
860
  }
768
861
  deps.io.out('Agent Acknowledgement: required after a user reply; no reply is recorded yet.');
@@ -917,7 +1010,13 @@ async function uploadImage(deps, client, source) {
917
1010
  return { ok: true, mediaId: grant.media_id };
918
1011
  }
919
1012
  catch (err) {
920
- return { ok: false, error: err instanceof Error ? err.message : String(err), exit: EXIT.network };
1013
+ // Every other API failure in this file goes through `reportError`, which
1014
+ // maps a revoked credential to the auth code, a server fault to the network
1015
+ // one and everything else to plain failure, and records it in the local
1016
+ // log with whatever next step the server named. This used to answer
1017
+ // `network` to all of them, so an image too large for the account exited
1018
+ // the same way an unreachable server did, and nothing about it was logged.
1019
+ return { ok: false, error: null, exit: reportError(deps, err) };
921
1020
  }
922
1021
  }
923
1022
  // ---------------------------------------------------------------------------
@@ -947,7 +1046,11 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
947
1046
  // would grant slow setup a second budget and let the harness kill us before
948
1047
  // an accepted answer is journaled or written to stdout.
949
1048
  const now = deps.now ?? Date.now;
950
- const processDeadlineAt = now() + WAITER_CEILING_SECONDS * 1000;
1049
+ // The waiter may spend a long wall clock exactly when no turn is held open
1050
+ // for it, which is the same condition the installer used to declare
1051
+ // `async: true`. One predicate decides it for both.
1052
+ const detachedWaiter = stopHandlerIsDetached(harness, deps.hookPlatform ?? process.platform);
1053
+ const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000;
951
1054
  const logger = log(deps);
952
1055
  logger.bind({ cmd: `hook ${event}` });
953
1056
  let started = false;
@@ -1127,18 +1230,8 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
1127
1230
  });
1128
1231
  for (const note of outcome.notes)
1129
1232
  deps.io.err(`notifai: ${note}`);
1130
- if (outcome.stdout !== undefined) {
1131
- let stdout = outcome.stdout;
1132
- if (harness !== undefined &&
1133
- event === 'stop' &&
1134
- HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message') {
1135
- const decision = JSON.parse(outcome.stdout);
1136
- if (decision.decision === 'block' && typeof decision.reason === 'string') {
1137
- stdout = JSON.stringify({ followup_message: decision.reason });
1138
- }
1139
- }
1140
- deps.io.out(stdout);
1141
- }
1233
+ if (outcome.stdout !== undefined)
1234
+ deps.io.out(outcome.stdout);
1142
1235
  return EXIT.ok;
1143
1236
  }
1144
1237
  catch (err) {
@@ -1251,9 +1344,8 @@ function rejectedPaths(details) {
1251
1344
  .slice(0, 5);
1252
1345
  }
1253
1346
  /**
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.
1347
+ * Turn ask input into questions plus their canonical body. Everything is
1348
+ * validated at registration because a later hook failure is easy to miss.
1257
1349
  */
1258
1350
  export function buildQuestions(flags, question) {
1259
1351
  if (flags.form !== undefined) {
@@ -1265,21 +1357,41 @@ export function buildQuestions(flags, question) {
1265
1357
  parsed = JSON.parse(flags.form);
1266
1358
  }
1267
1359
  catch {
1268
- return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' };
1360
+ return {
1361
+ ok: false,
1362
+ error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
1363
+ };
1269
1364
  }
1270
- if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.questions)) {
1271
- return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
1365
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1366
+ return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` };
1272
1367
  }
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}.` };
1368
+ const record = parsed;
1369
+ const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body');
1370
+ if (unknownKeys.length > 0) {
1371
+ return {
1372
+ ok: false,
1373
+ error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
1374
+ };
1375
+ }
1376
+ if (!Array.isArray(record['questions'])) {
1377
+ return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` };
1378
+ }
1379
+ const formQuestions = record['questions'];
1380
+ if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
1381
+ return {
1382
+ ok: false,
1383
+ error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
1384
+ };
1276
1385
  }
1277
- if (form.detail !== undefined && typeof form.detail !== 'string') {
1278
- return { ok: false, error: '"detail" must be a markdown string.' };
1386
+ if (record['body'] !== undefined && typeof record['body'] !== 'string') {
1387
+ return { ok: false, error: '"body" must be a Markdown string.' };
1388
+ }
1389
+ if (flags.body !== undefined && record['body'] !== undefined) {
1390
+ return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' };
1279
1391
  }
1280
1392
  const questions = [];
1281
1393
  const usedIds = new Set();
1282
- for (const [index, entry] of form.questions.entries()) {
1394
+ for (const [index, entry] of formQuestions.entries()) {
1283
1395
  if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
1284
1396
  return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
1285
1397
  }
@@ -1289,7 +1401,15 @@ export function buildQuestions(flags, question) {
1289
1401
  return { ok: false, error: `Question ${index + 1}: ${built.error}` };
1290
1402
  questions.push(built.question);
1291
1403
  }
1292
- return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) };
1404
+ const context = flags.body ?? record['body'];
1405
+ const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n');
1406
+ return {
1407
+ ok: true,
1408
+ questions,
1409
+ body: context !== undefined && context.trim() !== ''
1410
+ ? `${questionBlock}\n\n${context}`
1411
+ : questionBlock,
1412
+ };
1293
1413
  }
1294
1414
  if (question === undefined || question.trim() === '') {
1295
1415
  return { ok: false, error: 'The question cannot be empty.' };
@@ -1297,10 +1417,13 @@ export function buildQuestions(flags, question) {
1297
1417
  const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
1298
1418
  if ('error' in built)
1299
1419
  return { ok: false, error: built.error };
1420
+ const context = flags.body;
1300
1421
  return {
1301
1422
  ok: true,
1302
1423
  questions: [built.question],
1303
- ...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
1424
+ body: context !== undefined && context.trim() !== ''
1425
+ ? `${built.question.text}\n\n${context}`
1426
+ : built.question.text,
1304
1427
  };
1305
1428
  }
1306
1429
  function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
@@ -1310,7 +1433,7 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
1310
1433
  if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
1311
1434
  return {
1312
1435
  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.`,
1436
+ `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
1314
1437
  };
1315
1438
  }
1316
1439
  const choices = parseChoices(choiceLabels);
@@ -1332,6 +1455,131 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
1332
1455
  },
1333
1456
  };
1334
1457
  }
1458
+ function buildAskDraft(config, built, flags, invocation, mediaIds) {
1459
+ const result = buildDraft(config, {
1460
+ title: built.questions[0].text,
1461
+ body: built.body,
1462
+ ...(flags.project !== undefined ? { project: flags.project } : {}),
1463
+ ...(mediaIds.length > 0 ? { image: mediaIds } : {}),
1464
+ ...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
1465
+ reply: true,
1466
+ questions: built.questions,
1467
+ }, invocation);
1468
+ if (!result.ok)
1469
+ return result;
1470
+ const capabilities = CAPABILITIES_V1.describe(result.platform);
1471
+ if (capabilities === null)
1472
+ return { ok: false, error: 'No iOS capability contract is available.' };
1473
+ const validation = validateDraft(result.draft, capabilities);
1474
+ if (!validation.ok) {
1475
+ return {
1476
+ ok: false,
1477
+ error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
1478
+ };
1479
+ }
1480
+ return { ok: true, draft: result.draft };
1481
+ }
1482
+ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
1483
+ try {
1484
+ registerQuestion(sessionId, deps.env, {
1485
+ question: built.questions[0].text,
1486
+ questions: built.questions,
1487
+ body: draft.presentation.body,
1488
+ ...(draft.project !== undefined ? { project: draft.project } : {}),
1489
+ ...(draft.source !== undefined ? { source: draft.source } : {}),
1490
+ ...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
1491
+ }, (deps.now ?? Date.now)());
1492
+ }
1493
+ catch (err) {
1494
+ log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
1495
+ deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
1496
+ return EXIT.failed;
1497
+ }
1498
+ log(deps).info('ask.registered', {
1499
+ ok: true,
1500
+ session: sessionId,
1501
+ questions: built.questions.length,
1502
+ text: built.questions[0].text,
1503
+ choices: built.questions[0].choices?.length ?? 0,
1504
+ media: draft.presentation.media?.length ?? 0,
1505
+ });
1506
+ // The block below is the densest guidance this CLI prints, and until now it
1507
+ // was prose only: an agent could not read back the choice ids it must branch
1508
+ // on without asking the server for them. The JSON form carries the same
1509
+ // obligation as data.
1510
+ if (json) {
1511
+ deps.io.out(JSON.stringify({
1512
+ registered: true,
1513
+ questions: built.questions.map((entry) => ({
1514
+ id: entry.id,
1515
+ text: entry.text,
1516
+ ...(entry.choices === undefined ? {} : { choices: entry.choices }),
1517
+ ...(entry.multi === true ? { multi: true } : {}),
1518
+ })),
1519
+ next: {
1520
+ end_turn: true,
1521
+ in_this_turn: 'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
1522
+ route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
1523
+ on_answer: 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
1524
+ },
1525
+ }, null, 2));
1526
+ return EXIT.ok;
1527
+ }
1528
+ for (const [index, entry] of built.questions.entries()) {
1529
+ const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
1530
+ if (entry.choices !== undefined) {
1531
+ const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
1532
+ deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
1533
+ }
1534
+ else if (built.questions.length > 1) {
1535
+ deps.io.out(`${prefix}${entry.text} — free text`);
1536
+ }
1537
+ }
1538
+ deps.io.out(built.questions.length > 1
1539
+ ? `${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.`
1540
+ : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
1541
+ deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
1542
+ for (const [index, entry] of built.questions.entries()) {
1543
+ const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
1544
+ if (entry.choices !== undefined) {
1545
+ for (const choice of entry.choices) {
1546
+ deps.io.out(`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`);
1547
+ }
1548
+ deps.io.out(`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`);
1549
+ }
1550
+ else {
1551
+ deps.io.out(`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`);
1552
+ }
1553
+ }
1554
+ 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.');
1555
+ deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
1556
+ return EXIT.ok;
1557
+ }
1558
+ async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
1559
+ const authed = authedClient(deps, config);
1560
+ if (!authed)
1561
+ return EXIT.auth;
1562
+ const mediaIds = [];
1563
+ for (const image of flags.image ?? []) {
1564
+ if (image.startsWith('med_')) {
1565
+ mediaIds.push(image);
1566
+ continue;
1567
+ }
1568
+ const uploaded = await uploadImage(deps, authed.client, image);
1569
+ if (!uploaded.ok) {
1570
+ if (uploaded.error !== null)
1571
+ deps.io.err(uploaded.error);
1572
+ return uploaded.exit;
1573
+ }
1574
+ mediaIds.push(uploaded.mediaId);
1575
+ }
1576
+ const ready = buildAskDraft(config, built, flags, invocation, mediaIds);
1577
+ if (!ready.ok) {
1578
+ deps.io.err(ready.error);
1579
+ return EXIT.usage;
1580
+ }
1581
+ return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true);
1582
+ }
1335
1583
  /**
1336
1584
  * Registers a question for turn-end routing. Returns immediately so the agent
1337
1585
  * can ask in prose and end its turn; the terminal keeps the question to itself
@@ -1346,6 +1594,11 @@ export function askCommand(deps, question, flags) {
1346
1594
  deps.io.err(built.error);
1347
1595
  return EXIT.usage;
1348
1596
  }
1597
+ const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
1598
+ if (mediaInputError !== null) {
1599
+ deps.io.err(mediaInputError);
1600
+ return EXIT.usage;
1601
+ }
1349
1602
  const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
1350
1603
  if (!routingConfig.ask_notifications.value) {
1351
1604
  deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
@@ -1383,10 +1636,6 @@ export function askCommand(deps, question, flags) {
1383
1636
  }
1384
1637
  return EXIT.usage;
1385
1638
  }
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
1639
  const routeProblems = activeQuestionRouteProblems(deps, active, installations);
1391
1640
  if (routeProblems.length > 0) {
1392
1641
  for (const problem of routeProblems)
@@ -1410,57 +1659,27 @@ export function askCommand(deps, question, flags) {
1410
1659
  deps.io.err(line);
1411
1660
  return EXIT.usage;
1412
1661
  }
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)());
1662
+ const source = resolveDraftInvocation(deps, flags, active);
1663
+ if (!source.ok) {
1664
+ deps.io.err(source.error);
1665
+ return EXIT.usage;
1419
1666
  }
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;
1667
+ if (source.invocation.source?.session_id !== sessionId) {
1668
+ 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.`);
1669
+ return EXIT.usage;
1424
1670
  }
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
- }
1671
+ // Placeholders let every body, source, project, media, and payload limit fail
1672
+ // before an upload starts. The real ids replace them only after this passes.
1673
+ const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`);
1674
+ const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders);
1675
+ if (!preflight.ok) {
1676
+ deps.io.err(preflight.error);
1677
+ return EXIT.usage;
1444
1678
  }
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
- }
1679
+ if ((flags.image?.length ?? 0) > 0) {
1680
+ return uploadAskMedia(deps, routingConfig, sessionId, built, flags, source.invocation);
1460
1681
  }
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;
1682
+ return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true);
1464
1683
  }
1465
1684
  /**
1466
1685
  * Every harness marker present in this environment, in declared order.
@@ -1659,15 +1878,22 @@ function hookActivationAdvice(installations) {
1659
1878
  }
1660
1879
  return `${advice.join('. ')}.`;
1661
1880
  }
1662
- /** Record the one Agent Acknowledgement associated with a replied-to request. */
1881
+ /**
1882
+ * Record the one Agent Acknowledgement associated with a replied-to request.
1883
+ *
1884
+ * `--text` is optional here because the acknowledgement is not: an account may
1885
+ * turn the agent's written reply off, and the receipt must still be recorded so
1886
+ * the user sees that an agent read the answer. The service holds the account's
1887
+ * snapshot, so it — not this process — decides whether text was owed.
1888
+ */
1663
1889
  export async function acknowledgeCommand(deps, requestId, flags) {
1664
1890
  const text = flags.text?.trim() ?? '';
1665
- if (text.length === 0) {
1666
- deps.io.err('--text is required and must contain non-whitespace text.');
1891
+ if (flags.text !== undefined && text.length === 0) {
1892
+ deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.');
1667
1893
  return EXIT.usage;
1668
1894
  }
1669
1895
  if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
1670
- deps.io.err(`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming.`);
1896
+ deps.io.err(`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming. Shorten it: an acknowledgement is a receipt, not a report.`);
1671
1897
  return EXIT.usage;
1672
1898
  }
1673
1899
  const logger = log(deps);
@@ -1681,7 +1907,7 @@ export async function acknowledgeCommand(deps, requestId, flags) {
1681
1907
  if (!authed)
1682
1908
  return EXIT.auth;
1683
1909
  try {
1684
- const result = await authed.client.putAgentAcknowledgement(requestId, { text });
1910
+ const result = await authed.client.putAgentAcknowledgement(requestId, text.length > 0 ? { text } : {});
1685
1911
  const output = {
1686
1912
  request_id: requestId,
1687
1913
  outcome: result.status,
@@ -1721,7 +1947,7 @@ export async function closeCommand(deps, requestId, flags = {}) {
1721
1947
  if (flags.json) {
1722
1948
  deps.io.out(JSON.stringify({
1723
1949
  ...response,
1724
- acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement, response.replies.length > 0),
1950
+ acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
1725
1951
  }, null, 2));
1726
1952
  }
1727
1953
  else {
@@ -1779,7 +2005,7 @@ export function hooksInstallCommand(deps, flags) {
1779
2005
  if (flags.harness === undefined) {
1780
2006
  const detected = detectedHarnesses(deps.cwd, deps.env);
1781
2007
  if (detected.length === 0) {
1782
- deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
2008
+ deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`);
1783
2009
  return EXIT.usage;
1784
2010
  }
1785
2011
  let ok = true;
@@ -1888,6 +2114,14 @@ export function hooksInstallCommand(deps, flags) {
1888
2114
  for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
1889
2115
  deps.io.out(problem);
1890
2116
  }
2117
+ for (const note of codexCoexistenceNotes(deps.cwd, deps.env, hookPlatform)) {
2118
+ deps.io.out(note);
2119
+ }
2120
+ if (flags.global) {
2121
+ const home = codexHomeNote(deps.env, hookPlatform);
2122
+ if (home !== null)
2123
+ deps.io.out(home);
2124
+ }
1891
2125
  }
1892
2126
  return EXIT.ok;
1893
2127
  }
@@ -2040,12 +2274,6 @@ function assertOwnedRegularFile(file) {
2040
2274
  throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
2041
2275
  }
2042
2276
  }
2043
- const HARNESS_LABEL = {
2044
- 'claude-code': 'Claude Code',
2045
- codex: 'Codex',
2046
- cursor: 'Cursor',
2047
- opencode: 'OpenCode',
2048
- };
2049
2277
  function resolveHarness(deps, requested) {
2050
2278
  if (requested !== undefined) {
2051
2279
  if (HARNESSES.includes(requested))
@@ -2076,7 +2304,7 @@ async function pickHarnessesToInstall(deps, requested) {
2076
2304
  if (deps.io.interactive === true && deps.io.multiselect) {
2077
2305
  const picked = await deps.io.multiselect('Which agent harnesses should Notifai wire here?', HARNESSES.map((name) => ({
2078
2306
  value: name,
2079
- label: HARNESS_LABEL[name],
2307
+ label: HARNESS_LABELS[name],
2080
2308
  ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
2081
2309
  })), detected);
2082
2310
  if (picked === null)
@@ -2462,8 +2690,8 @@ export function logsCommand(deps, flags) {
2462
2690
  query.since = since;
2463
2691
  }
2464
2692
  if (flags.level !== undefined) {
2465
- if (!LOG_LEVELS.includes(flags.level)) {
2466
- deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`);
2693
+ if (!RECORD_LEVELS.includes(flags.level)) {
2694
+ deps.io.err(`--level takes one of: ${RECORD_LEVELS.join(', ')} — not "${flags.level}".`);
2467
2695
  return EXIT.usage;
2468
2696
  }
2469
2697
  query.level = flags.level;
@@ -2599,14 +2827,9 @@ async function skillReadiness(deps, selectedScope) {
2599
2827
  },
2600
2828
  };
2601
2829
  }
2602
- /** Derive a contract-valid project slug from a directory name. */
2830
+ /** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
2603
2831
  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';
2832
+ return inferredProjectSlugFrom(name) ?? 'project';
2610
2833
  }
2611
2834
  /** Long enough for a first TestFlight install; keep-waiting extends another budget. */
2612
2835
  const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000;
@@ -3199,7 +3422,9 @@ async function printInitClose(deps, readiness, flags) {
3199
3422
  ];
3200
3423
  await deps.io.note?.(lines.join('\n'), 'Ready');
3201
3424
  printOptionalLeftovers(deps, leftovers);
3202
- deps.io.out('All set. Agents in this project can notify you and ask questions.');
3425
+ deps.io.out(questions
3426
+ ? 'All set. Agents in this project can notify you and ask you questions.'
3427
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.');
3203
3428
  await deps.io.outro?.('All set ✨');
3204
3429
  return;
3205
3430
  }
@@ -3214,7 +3439,9 @@ async function printInitClose(deps, readiness, flags) {
3214
3439
  }
3215
3440
  if (blocker === null) {
3216
3441
  printOptionalLeftovers(deps, leftovers);
3217
- deps.io.out('All set. Agents in this project can notify you and ask questions.');
3442
+ deps.io.out(questions
3443
+ ? 'All set. Agents in this project can notify you and ask you questions.'
3444
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.');
3218
3445
  return;
3219
3446
  }
3220
3447
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
@@ -3265,29 +3492,40 @@ async function contractCheck(client) {
3265
3492
  }
3266
3493
  }
3267
3494
  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
- ? {
3495
+ const configured = config.project.value;
3496
+ if (configured !== null) {
3497
+ return {
3272
3498
  id: 'project',
3273
3499
  title: 'Project identity',
3274
3500
  status: 'ready',
3275
- detail: `"${projectSlug}" (${config.project.source})`,
3276
- }
3277
- : {
3501
+ detail: `"${configured}" (${config.project.source})`,
3502
+ };
3503
+ }
3504
+ const inferred = inferInvocationContext(deps.cwd).project;
3505
+ if (inferred !== null) {
3506
+ return {
3278
3507
  id: 'project',
3279
3508
  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
3509
  status: 'optional-gap',
3284
- detail: `not set in ${configPath} — sends from here carry no project identity`,
3510
+ detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
3285
3511
  remedy: {
3286
3512
  by: 'cli',
3287
- summary: 'name this project after its directory',
3513
+ summary: 'make the inferred Project identity explicit for every checkout',
3288
3514
  command: 'notifai init',
3289
3515
  },
3290
3516
  };
3517
+ }
3518
+ return {
3519
+ id: 'project',
3520
+ title: 'Project identity',
3521
+ status: 'optional-gap',
3522
+ detail: 'the directory name has no characters a Project identifier can use',
3523
+ remedy: {
3524
+ by: 'cli',
3525
+ summary: 'choose an explicit Project identifier',
3526
+ command: 'notifai init --project-id my-project',
3527
+ },
3528
+ };
3291
3529
  }
3292
3530
  function remoteStatesFrom(previous) {
3293
3531
  const pick = (id) => previous.states.find((state) => state.id === id);
@@ -3732,14 +3970,39 @@ function hookStates(deps) {
3732
3970
  settings,
3733
3971
  ];
3734
3972
  }
3973
+ /**
3974
+ * A human title per check.
3975
+ *
3976
+ * Three checks used to collapse onto "Question routing", so a reader could not
3977
+ * tell which of them had failed, and the rest fell through to their internal
3978
+ * name — `hooks (stale)` beside `Delivery proof`. The `id` stays the stable
3979
+ * thing to branch on; this is only what a person reads.
3980
+ */
3981
+ const CHECK_TITLES = {
3982
+ hooks: 'Question routing',
3983
+ 'hooks (detected)': 'Harnesses detected',
3984
+ 'hooks (active harness)': 'Routing for this harness',
3985
+ 'hooks (active session)': 'Routing for this session',
3986
+ 'hooks (stale)': 'Hook definitions current',
3987
+ 'hooks (adapter)': 'Hook adapter',
3988
+ 'hooks (trust)': 'Codex hook trust',
3989
+ 'hooks (stop shape)': 'Turn-end hook shape',
3990
+ 'hooks (duplicates)': 'Duplicate hook installs',
3991
+ 'hooks (codex representation)': 'Codex hook representation',
3992
+ 'hooks (question admission)': 'Question admission',
3993
+ 'hooks (fired)': 'Hooks have run here',
3994
+ 'hooks (answer continuation)': 'How an answer returns',
3995
+ 'hooks (wake route)': 'Direct wake route',
3996
+ };
3997
+ function checkTitle(name) {
3998
+ return CHECK_TITLES[name] ?? name;
3999
+ }
3735
4000
  /** Real but not in the way; see the note above. */
3736
4001
  const informational = new Set();
3737
4002
  return [
3738
4003
  ...hookChecks(deps).map((check) => ({
3739
4004
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
3740
- title: check.name === 'hooks' || check.name.startsWith('hooks (active')
3741
- ? 'Question routing'
3742
- : check.name,
4005
+ title: checkTitle(check.name),
3743
4006
  status: check.ok
3744
4007
  ? 'ready'
3745
4008
  : check.informational === true || informational.has(check.name)
@@ -3784,7 +4047,7 @@ function stopShapeProblems(installation, platform = process.platform) {
3784
4047
  return [];
3785
4048
  const problems = [];
3786
4049
  for (const handler of installation.handlers.filter((entry) => handlerEvent(entry.command) === 'stop')) {
3787
- if (installation.harness === 'claude-code' && platform !== 'win32') {
4050
+ if (stopHandlerIsDetached(installation.harness, platform)) {
3788
4051
  if (handler.async !== true) {
3789
4052
  problems.push(`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`);
3790
4053
  }
@@ -3826,7 +4089,7 @@ function hookChecks(deps) {
3826
4089
  name: 'hooks (detected)',
3827
4090
  ok: false,
3828
4091
  informational: true,
3829
- detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
4092
+ detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
3830
4093
  remedy: {
3831
4094
  summary: 'install hooks for every detected harness',
3832
4095
  command: 'notifai hooks install',
@@ -3962,6 +4225,20 @@ function hookChecks(deps) {
3962
4225
  detail: representationProblems.join('; '),
3963
4226
  });
3964
4227
  }
4228
+ else {
4229
+ const coexistence = codexCoexistenceNotes(deps.cwd, deps.env, deps.hookPlatform);
4230
+ if (coexistence.length > 0) {
4231
+ checks.push({
4232
+ name: 'hooks (codex representation)',
4233
+ ok: true,
4234
+ detail: coexistence.join('; '),
4235
+ });
4236
+ }
4237
+ }
4238
+ const codexHome = codexHomeNote(deps.env, deps.hookPlatform);
4239
+ if (codexHome !== null) {
4240
+ checks.push({ name: 'hooks (codex home)', ok: true, detail: codexHome });
4241
+ }
3965
4242
  if (active !== null && activeInstallations.length > 0) {
3966
4243
  const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
3967
4244
  checks.push({