@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/src/commands.ts CHANGED
@@ -6,6 +6,7 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
6
6
  import {
7
7
  AGENT_ACKNOWLEDGEMENT_MAX_LENGTH,
8
8
  CAPABILITIES_V1,
9
+ PLATFORMS,
9
10
  QUESTION_TEXT_MAX_LENGTH,
10
11
  REPLY_MAX_QUESTIONS,
11
12
  REPLY_MAX_WINDOW_SECONDS,
@@ -13,6 +14,7 @@ import {
13
14
  type EvidenceSnapshot,
14
15
  type AccountAccessResponse,
15
16
  type ListRepliesResponse,
17
+ type NotificationDraftT,
16
18
  type Platform,
17
19
  type QuestionT,
18
20
  type ReplyView,
@@ -31,7 +33,6 @@ import {
31
33
  import {
32
34
  BOOLEAN_CONFIG_KEYS,
33
35
  CONFIG_KEYS,
34
- LOG_LEVELS,
35
36
  NUMERIC_CONFIG_KEYS,
36
37
  configBounds,
37
38
  configDefaultValue,
@@ -65,7 +66,7 @@ import {
65
66
  } from './readiness.js'
66
67
  import type { Tone } from './ui/theme.js'
67
68
  import {
68
- WAITER_CEILING_SECONDS,
69
+ waiterCeilingSeconds,
69
70
  clearAcknowledgementObligation,
70
71
  handleSessionEnd,
71
72
  handleStop,
@@ -81,6 +82,7 @@ import {
81
82
  type EscalationDeliveryRoute,
82
83
  type HookContext,
83
84
  type HookHarness,
85
+ MIN_REPLY_WINDOW_SECONDS,
84
86
  } from './hooks.js'
85
87
  import {
86
88
  LOG_EVENTS,
@@ -94,9 +96,11 @@ import {
94
96
  renderRecord,
95
97
  type LogQuery,
96
98
  type Logger,
99
+ RECORD_LEVELS,
97
100
  } from './logging.js'
98
101
  import {
99
102
  BLOCKING_STOP_TIMEOUT_SECONDS,
103
+ stopHandlerIsDetached,
100
104
  CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
101
105
  HARNESSES,
102
106
  applyPlan,
@@ -104,6 +108,8 @@ import {
104
108
  buildHookConfig,
105
109
  codexLayerDir,
106
110
  codexLayerPaths,
111
+ codexCoexistenceNotes,
112
+ codexHomeNote,
107
113
  codexRepresentationProblems,
108
114
  codexTrustProblems,
109
115
  codexProjectRoot,
@@ -153,8 +159,15 @@ import {
153
159
  parseChoices,
154
160
  receiptExitCode,
155
161
  slugify,
162
+ validateMediaInputs,
163
+ type DraftInvocation,
156
164
  type SendFlags,
157
165
  } from './send.js'
166
+ import {
167
+ buildSourceContext,
168
+ inferInvocationContext,
169
+ projectSlugFrom as inferredProjectSlugFrom,
170
+ } from './invocation-context.js'
158
171
  import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
159
172
  import { openUrl } from './platform.js'
160
173
 
@@ -304,6 +317,21 @@ function reportError(
304
317
  })
305
318
  if (err instanceof ApiCallError) {
306
319
  deps.io.err(`${err.code}: ${err.message}`)
320
+ // The server's field-level details were already being logged above, but
321
+ // stderr is the one surface an agent's next turn actually reads — so the
322
+ // diagnosis has to be on it, the same way the hook path prints it.
323
+ const paths = rejectedPaths(err.details)
324
+ if (paths.length > 0) deps.io.err(`the server rejected: ${paths.join(', ')}`)
325
+ // A 422 on a request this CLI built is not a user error: the two sides
326
+ // disagree about the contract, in whichever direction. `doctor` says which.
327
+ // Codes that name a policy the account chose are the exception — the
328
+ // contract held, the body simply did not satisfy that account.
329
+ if (err.status === 422 && err.code !== 'acknowledgement_text_required') {
330
+ deps.io.err(
331
+ 'this build sent a field the server did not accept — the CLI and server ' +
332
+ 'disagree about the contract; check with `notifai doctor`',
333
+ )
334
+ }
307
335
  if (err.nextAction) deps.io.err(`next: ${err.nextAction}`)
308
336
  if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
309
337
  return err.status >= 500 ? EXIT.network : EXIT.failed
@@ -544,6 +572,13 @@ export async function capabilitiesCommand(
544
572
  deps: CommandDeps,
545
573
  flags: { json?: boolean; platform?: Platform },
546
574
  ): Promise<number> {
575
+ // Locally, and with the same message `send` gives. Spending a round trip to
576
+ // have the server answer "Request validation failed" told the caller neither
577
+ // which flag was wrong nor what it accepts.
578
+ if (flags.platform !== undefined && !(PLATFORMS as readonly string[]).includes(flags.platform)) {
579
+ deps.io.err(`Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.`)
580
+ return EXIT.usage
581
+ }
547
582
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
548
583
  const credential = deps.store.load()
549
584
  const baseUrl = resolvedBaseUrl(config, credential)
@@ -568,6 +603,36 @@ export async function capabilitiesCommand(
568
603
  // send / status
569
604
  // ---------------------------------------------------------------------------
570
605
 
606
+ function resolveDraftInvocation(
607
+ deps: CommandDeps,
608
+ flags: Pick<SendFlags, 'sessionId' | 'sessionLabel'>,
609
+ active: ActiveHarnessSession | null,
610
+ ): { ok: true; invocation: DraftInvocation } | { ok: false; error: string } {
611
+ const inferred = inferInvocationContext(deps.cwd)
612
+ const source = buildSourceContext({
613
+ env: deps.env,
614
+ invocation: inferred,
615
+ ...(flags.sessionId !== undefined ? { sessionId: flags.sessionId } : {}),
616
+ ...(flags.sessionLabel !== undefined ? { sessionLabel: flags.sessionLabel } : {}),
617
+ ...(active === null
618
+ ? {}
619
+ : {
620
+ activeHarness: {
621
+ harness: active.harness,
622
+ ...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
623
+ },
624
+ }),
625
+ })
626
+ if (!source.ok) return source
627
+ return {
628
+ ok: true,
629
+ invocation: {
630
+ inferredProject: inferred.project,
631
+ ...(source.source === undefined ? {} : { source: source.source }),
632
+ },
633
+ }
634
+ }
635
+
571
636
  export async function sendCommand(
572
637
  deps: CommandDeps,
573
638
  flags: SendFlags & {
@@ -583,8 +648,23 @@ export async function sendCommand(
583
648
  const hasReplyChoice = Array.isArray(flags.replyChoice)
584
649
  ? flags.replyChoice.length > 0
585
650
  : flags.replyChoice !== undefined
586
- if (flags.reply && flags.kind === 'done') {
587
- deps.io.err('--kind done cannot be combined with --reply; a reply request is a question.')
651
+ if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
652
+ deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
653
+ return EXIT.usage
654
+ }
655
+ // Kind now decides the sound a notification arrives with, so it is asked for
656
+ // at the boundary rather than defaulted silently: an unlabelled send would
657
+ // reach the user as ordinary news whatever actually happened.
658
+ if (!flags.reply && flags.kind === undefined) {
659
+ deps.io.err(
660
+ '--kind is required: say what this notification is. ' +
661
+ 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
662
+ '--reply makes it a question without --kind.',
663
+ )
664
+ return EXIT.usage
665
+ }
666
+ if (!flags.reply && flags.kind === 'question') {
667
+ deps.io.err('--kind question requires --reply so the question can be answered.')
588
668
  return EXIT.usage
589
669
  }
590
670
  if (
@@ -621,10 +701,17 @@ export async function sendCommand(
621
701
  flags.reply &&
622
702
  flags.replyWindow !== undefined &&
623
703
  (!Number.isInteger(flags.replyWindow) ||
624
- flags.replyWindow < 60 ||
704
+ flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
625
705
  flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)
626
706
  ) {
627
- deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`)
707
+ deps.io.err(
708
+ `--reply-window must be an integer from ${MIN_REPLY_WINDOW_SECONDS} to ${REPLY_MAX_WINDOW_SECONDS} seconds.`,
709
+ )
710
+ return EXIT.usage
711
+ }
712
+ const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
713
+ if (mediaInputError !== null) {
714
+ deps.io.err(mediaInputError)
628
715
  return EXIT.usage
629
716
  }
630
717
  const config = loadLoggedConfig(deps, {
@@ -632,17 +719,32 @@ export async function sendCommand(
632
719
  env: deps.env,
633
720
  flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
634
721
  })
722
+ const source = resolveDraftInvocation(
723
+ deps,
724
+ flags,
725
+ activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
726
+ )
727
+ if (!source.ok) {
728
+ deps.io.err(source.error)
729
+ return EXIT.usage
730
+ }
635
731
  const authed = authedClient(deps, config)
636
732
  if (!authed) return EXIT.auth
637
- if (flags.image !== undefined && !flags.image.startsWith('med_')) {
638
- const uploaded = await uploadImage(deps, authed.client, flags.image)
733
+ const mediaIds: string[] = []
734
+ for (const image of flags.image ?? []) {
735
+ if (image.startsWith('med_')) {
736
+ mediaIds.push(image)
737
+ continue
738
+ }
739
+ const uploaded = await uploadImage(deps, authed.client, image)
639
740
  if (!uploaded.ok) {
640
- deps.io.err(uploaded.error)
741
+ if (uploaded.error !== null) deps.io.err(uploaded.error)
641
742
  return uploaded.exit
642
743
  }
643
- flags = { ...flags, image: uploaded.mediaId }
744
+ mediaIds.push(uploaded.mediaId)
644
745
  }
645
- const build = buildDraft(config, flags)
746
+ flags = { ...flags, image: mediaIds }
747
+ const build = buildDraft(config, flags, source.invocation)
646
748
  if (!build.ok) {
647
749
  deps.io.err(build.error)
648
750
  return EXIT.usage
@@ -719,6 +821,7 @@ export async function sendCommand(
719
821
  request_id: receipt.request_id,
720
822
  replies: [],
721
823
  agent_acknowledgement_required: receipt.agent_acknowledgement_required,
824
+ agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
722
825
  agent_acknowledgement: null,
723
826
  acknowledgement_command: null,
724
827
  degraded: false,
@@ -805,15 +908,9 @@ function emitSendWarnings(
805
908
  `Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`,
806
909
  )
807
910
  }
808
- if (looksLikeMarkdown(flags.body)) {
809
- deps.io.err(
810
- 'Heads up: --body looks like Markdown, but banners show plain text. Put long-form Markdown in --detail or --detail-file.',
811
- )
812
- }
813
- const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
814
- if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
911
+ if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
815
912
  deps.io.err(
816
- `Heads up: this title announces completion but the notification kind is update. Use --kind done for finished work.`,
913
+ 'Heads up: keep the title to the specific substance. Put notification type in --kind; project identity is inferred separately.',
817
914
  )
818
915
  }
819
916
  if (
@@ -832,12 +929,6 @@ function emitSendWarnings(
832
929
  }
833
930
  }
834
931
 
835
- function looksLikeMarkdown(value: string): boolean {
836
- return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(
837
- value,
838
- )
839
- }
840
-
841
932
  export async function repliesCommand(
842
933
  deps: CommandDeps,
843
934
  requestedId: string | undefined,
@@ -872,7 +963,14 @@ export async function repliesCommand(
872
963
  .map((entry) => entry.request_id)
873
964
  .filter((id): id is string => id !== undefined)
874
965
  if (requestIds.length === 0) {
875
- deps.io.err(`Session ${sessionId} has no pushed question pending.`)
966
+ // `--json` is a promise about stdout, and an empty result is still a
967
+ // result: a caller that asked for machine-readable output must not have
968
+ // to parse an English sentence to learn there was nothing pending.
969
+ if (flags.json === true) {
970
+ deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2))
971
+ } else {
972
+ deps.io.err(`Session ${sessionId} has no pushed question pending.`)
973
+ }
876
974
  return EXIT.noReply
877
975
  }
878
976
  }
@@ -1008,6 +1106,7 @@ export async function waitForReply(
1008
1106
  request_id: requestId,
1009
1107
  reply_expires_at: null,
1010
1108
  agent_acknowledgement_required: false,
1109
+ agent_acknowledgement_text_required: false,
1011
1110
  agent_acknowledgement: null,
1012
1111
  replies: [],
1013
1112
  } satisfies ListRepliesResponse),
@@ -1050,14 +1149,21 @@ function isNonNegativeInteger(value: number): boolean {
1050
1149
  return Number.isInteger(value) && value >= 0
1051
1150
  }
1052
1151
 
1152
+ function acknowledgeInvocation(requestId: string, textRequired: boolean): string {
1153
+ return textRequired
1154
+ ? `notifai acknowledge ${requestId} --text <text>`
1155
+ : `notifai acknowledge ${requestId}`
1156
+ }
1157
+
1053
1158
  function acknowledgementCommand(
1054
1159
  requestId: string,
1055
1160
  required: boolean,
1161
+ textRequired: boolean,
1056
1162
  acknowledgement: ListRepliesResponse['agent_acknowledgement'],
1057
1163
  hasReply = true,
1058
1164
  ): string | null {
1059
1165
  return required && acknowledgement === null && hasReply
1060
- ? `notifai acknowledge ${requestId} --text <text>`
1166
+ ? acknowledgeInvocation(requestId, textRequired)
1061
1167
  : null
1062
1168
  }
1063
1169
 
@@ -1069,9 +1175,11 @@ function replyResultJson(response: ListRepliesResponse, degraded: boolean): obje
1069
1175
  replies: response.replies,
1070
1176
  agent_acknowledgement_required: response.agent_acknowledgement_required,
1071
1177
  agent_acknowledgement: response.agent_acknowledgement,
1178
+ agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
1072
1179
  acknowledgement_command: acknowledgementCommand(
1073
1180
  response.request_id,
1074
1181
  response.agent_acknowledgement_required,
1182
+ response.agent_acknowledgement_text_required,
1075
1183
  response.agent_acknowledgement,
1076
1184
  response.replies.length > 0,
1077
1185
  ),
@@ -1084,16 +1192,22 @@ function printAcknowledgementStatus(deps: CommandDeps, response: ListRepliesResp
1084
1192
  deps.io.out('Agent Acknowledgement: not required for this request.')
1085
1193
  return
1086
1194
  }
1195
+ const textRequired = response.agent_acknowledgement_text_required
1087
1196
  if (response.agent_acknowledgement !== null) {
1197
+ const recorded = `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}`
1088
1198
  deps.io.out(
1089
- `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}: ${response.agent_acknowledgement.text}`,
1199
+ response.agent_acknowledgement.text.length > 0
1200
+ ? `${recorded}: ${response.agent_acknowledgement.text}`
1201
+ : `${recorded}.`,
1090
1202
  )
1091
1203
  return
1092
1204
  }
1093
1205
  if (response.replies.length > 0) {
1094
1206
  deps.io.out('Agent Acknowledgement required.')
1095
1207
  deps.io.out(
1096
- `next: Run \`${acknowledgementCommand(response.request_id, true, null)}\` with concrete text saying what you will do because of the reply.`,
1208
+ textRequired
1209
+ ? `next: Run \`${acknowledgeInvocation(response.request_id, true)}\` with concrete text saying what you will do because of the reply.`
1210
+ : `next: Run \`${acknowledgeInvocation(response.request_id, false)}\` so the user sees you read the reply; this account turned acknowledgement text off.`,
1097
1211
  )
1098
1212
  return
1099
1213
  }
@@ -1233,7 +1347,8 @@ const MEDIA_TYPES: Record<string, 'image/jpeg' | 'image/png' | 'image/gif'> = {
1233
1347
 
1234
1348
  type UploadResult =
1235
1349
  | { ok: true; mediaId: string }
1236
- | { ok: false; error: string; exit: number }
1350
+ /** `error: null` means `reportError` already said it; do not print it twice. */
1351
+ | { ok: false; error: string | null; exit: number }
1237
1352
 
1238
1353
  /** `--image` accepts a media id, a local file path, or an http(s) URL. */
1239
1354
  async function uploadImage(deps: CommandDeps, client: ApiClient, source: string): Promise<UploadResult> {
@@ -1268,7 +1383,13 @@ async function uploadImage(deps: CommandDeps, client: ApiClient, source: string)
1268
1383
  await client.uploadMedia(grant, bytes)
1269
1384
  return { ok: true, mediaId: grant.media_id }
1270
1385
  } catch (err) {
1271
- return { ok: false, error: err instanceof Error ? err.message : String(err), exit: EXIT.network }
1386
+ // Every other API failure in this file goes through `reportError`, which
1387
+ // maps a revoked credential to the auth code, a server fault to the network
1388
+ // one and everything else to plain failure, and records it in the local
1389
+ // log with whatever next step the server named. This used to answer
1390
+ // `network` to all of them, so an image too large for the account exited
1391
+ // the same way an unreachable server did, and nothing about it was logged.
1392
+ return { ok: false, error: null, exit: reportError(deps, err) }
1272
1393
  }
1273
1394
  }
1274
1395
 
@@ -1311,7 +1432,11 @@ export async function hookRunCommand(
1311
1432
  // would grant slow setup a second budget and let the harness kill us before
1312
1433
  // an accepted answer is journaled or written to stdout.
1313
1434
  const now = deps.now ?? Date.now
1314
- const processDeadlineAt = now() + WAITER_CEILING_SECONDS * 1000
1435
+ // The waiter may spend a long wall clock exactly when no turn is held open
1436
+ // for it, which is the same condition the installer used to declare
1437
+ // `async: true`. One predicate decides it for both.
1438
+ const detachedWaiter = stopHandlerIsDetached(harness, deps.hookPlatform ?? process.platform)
1439
+ const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000
1315
1440
 
1316
1441
  const logger = log(deps)
1317
1442
  logger.bind({ cmd: `hook ${event}` })
@@ -1504,20 +1629,7 @@ export async function hookRunCommand(
1504
1629
  ...outcome.log,
1505
1630
  })
1506
1631
  for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
1507
- if (outcome.stdout !== undefined) {
1508
- let stdout = outcome.stdout
1509
- if (
1510
- harness !== undefined &&
1511
- event === 'stop' &&
1512
- HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message'
1513
- ) {
1514
- const decision = JSON.parse(outcome.stdout) as { decision?: unknown; reason?: unknown }
1515
- if (decision.decision === 'block' && typeof decision.reason === 'string') {
1516
- stdout = JSON.stringify({ followup_message: decision.reason })
1517
- }
1518
- }
1519
- deps.io.out(stdout)
1520
- }
1632
+ if (outcome.stdout !== undefined) deps.io.out(outcome.stdout)
1521
1633
  return EXIT.ok
1522
1634
  } catch (err) {
1523
1635
  // SessionEnd defers its start record until after cleanup; if cleanup itself
@@ -1637,14 +1749,20 @@ function rejectedPaths(details: unknown): string[] {
1637
1749
  }
1638
1750
 
1639
1751
  export interface AskFlags {
1752
+ /** Emit the registration and its turn obligation as one JSON object. */
1753
+ json?: boolean
1640
1754
  choice?: string[]
1641
1755
  /** The single question is multi-select: several answers may be chosen. */
1642
1756
  multi?: boolean
1643
- /** Long-form markdown context; shown in the app, never on the banner. */
1644
- detail?: string
1757
+ /** Optional Markdown context appended after the question block. */
1758
+ body?: string
1645
1759
  /** Raw JSON for a multi-question form; replaces the positional question. */
1646
1760
  form?: string
1647
- session?: string
1761
+ image?: string[]
1762
+ imageAlt?: string[]
1763
+ project?: string
1764
+ sessionId?: string
1765
+ sessionLabel?: string
1648
1766
  }
1649
1767
 
1650
1768
  /** The `--form` document: what an agent writes to ask several things at once. */
@@ -1654,15 +1772,20 @@ interface AskFormQuestion {
1654
1772
  multi?: boolean
1655
1773
  }
1656
1774
 
1775
+ export interface BuiltQuestions {
1776
+ questions: QuestionT[]
1777
+ /** Canonical Markdown body: question block first, optional context second. */
1778
+ body: string
1779
+ }
1780
+
1657
1781
  /**
1658
- * Turn ask input into the question set that will ride the push. Everything is
1659
- * validated here, at registration, because the push happens inside a hook
1660
- * where a rejection becomes a stderr note the agent never reads.
1782
+ * Turn ask input into questions plus their canonical body. Everything is
1783
+ * validated at registration because a later hook failure is easy to miss.
1661
1784
  */
1662
1785
  export function buildQuestions(
1663
1786
  flags: AskFlags,
1664
1787
  question: string | undefined,
1665
- ): { ok: true; questions: QuestionT[]; detail?: string } | { ok: false; error: string } {
1788
+ ): { ok: true; questions: QuestionT[]; body: string } | { ok: false; error: string } {
1666
1789
  if (flags.form !== undefined) {
1667
1790
  if (question !== undefined || flags.choice?.length || flags.multi) {
1668
1791
  return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' }
@@ -1671,21 +1794,41 @@ export function buildQuestions(
1671
1794
  try {
1672
1795
  parsed = JSON.parse(flags.form)
1673
1796
  } catch {
1674
- return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' }
1797
+ return {
1798
+ ok: false,
1799
+ error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
1800
+ }
1801
+ }
1802
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1803
+ return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
1804
+ }
1805
+ const record = parsed as Record<string, unknown>
1806
+ const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body')
1807
+ if (unknownKeys.length > 0) {
1808
+ return {
1809
+ ok: false,
1810
+ error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
1811
+ }
1812
+ }
1813
+ if (!Array.isArray(record['questions'])) {
1814
+ return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
1675
1815
  }
1676
- if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as { questions?: unknown }).questions)) {
1677
- return { ok: false, error: '--form needs a "questions" array (1-4 entries).' }
1816
+ const formQuestions = record['questions']
1817
+ if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
1818
+ return {
1819
+ ok: false,
1820
+ error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
1821
+ }
1678
1822
  }
1679
- const form = parsed as { questions: unknown[]; detail?: unknown }
1680
- if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
1681
- return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` }
1823
+ if (record['body'] !== undefined && typeof record['body'] !== 'string') {
1824
+ return { ok: false, error: '"body" must be a Markdown string.' }
1682
1825
  }
1683
- if (form.detail !== undefined && typeof form.detail !== 'string') {
1684
- return { ok: false, error: '"detail" must be a markdown string.' }
1826
+ if (flags.body !== undefined && record['body'] !== undefined) {
1827
+ return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' }
1685
1828
  }
1686
1829
  const questions: QuestionT[] = []
1687
1830
  const usedIds = new Set<string>()
1688
- for (const [index, entry] of form.questions.entries()) {
1831
+ for (const [index, entry] of formQuestions.entries()) {
1689
1832
  if (typeof entry !== 'object' || entry === null || typeof (entry as AskFormQuestion).text !== 'string') {
1690
1833
  return { ok: false, error: `Question ${index + 1} needs a "text" string.` }
1691
1834
  }
@@ -1694,7 +1837,16 @@ export function buildQuestions(
1694
1837
  if ('error' in built) return { ok: false, error: `Question ${index + 1}: ${built.error}` }
1695
1838
  questions.push(built.question)
1696
1839
  }
1697
- return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) }
1840
+ const context = flags.body ?? (record['body'] as string | undefined)
1841
+ const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n')
1842
+ return {
1843
+ ok: true,
1844
+ questions,
1845
+ body:
1846
+ context !== undefined && context.trim() !== ''
1847
+ ? `${questionBlock}\n\n${context}`
1848
+ : questionBlock,
1849
+ }
1698
1850
  }
1699
1851
 
1700
1852
  if (question === undefined || question.trim() === '') {
@@ -1702,10 +1854,14 @@ export function buildQuestions(
1702
1854
  }
1703
1855
  const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set())
1704
1856
  if ('error' in built) return { ok: false, error: built.error }
1857
+ const context = flags.body
1705
1858
  return {
1706
1859
  ok: true,
1707
1860
  questions: [built.question],
1708
- ...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
1861
+ body:
1862
+ context !== undefined && context.trim() !== ''
1863
+ ? `${built.question.text}\n\n${context}`
1864
+ : built.question.text,
1709
1865
  }
1710
1866
  }
1711
1867
 
@@ -1722,7 +1878,7 @@ function buildOneQuestion(
1722
1878
  return {
1723
1879
  error:
1724
1880
  `a question must be readable where it is answered: keep it within ` +
1725
- `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
1881
+ `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
1726
1882
  }
1727
1883
  }
1728
1884
  const choices = parseChoices(choiceLabels)
@@ -1743,12 +1899,186 @@ function buildOneQuestion(
1743
1899
  }
1744
1900
  }
1745
1901
 
1902
+ function buildAskDraft(
1903
+ config: CliConfig,
1904
+ built: BuiltQuestions,
1905
+ flags: AskFlags,
1906
+ invocation: DraftInvocation,
1907
+ mediaIds: string[],
1908
+ ): { ok: true; draft: NotificationDraftT } | { ok: false; error: string } {
1909
+ const result = buildDraft(
1910
+ config,
1911
+ {
1912
+ title: built.questions[0]!.text,
1913
+ body: built.body,
1914
+ ...(flags.project !== undefined ? { project: flags.project } : {}),
1915
+ ...(mediaIds.length > 0 ? { image: mediaIds } : {}),
1916
+ ...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
1917
+ reply: true,
1918
+ questions: built.questions,
1919
+ },
1920
+ invocation,
1921
+ )
1922
+ if (!result.ok) return result
1923
+ const capabilities = CAPABILITIES_V1.describe(result.platform)
1924
+ if (capabilities === null) return { ok: false, error: 'No iOS capability contract is available.' }
1925
+ const validation = validateDraft(result.draft, capabilities)
1926
+ if (!validation.ok) {
1927
+ return {
1928
+ ok: false,
1929
+ error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
1930
+ }
1931
+ }
1932
+ return { ok: true, draft: result.draft }
1933
+ }
1934
+
1935
+ function recordRegisteredQuestion(
1936
+ deps: CommandDeps,
1937
+ sessionId: string,
1938
+ built: BuiltQuestions,
1939
+ draft: NotificationDraftT,
1940
+ json = false,
1941
+ ): number {
1942
+ try {
1943
+ registerQuestion(
1944
+ sessionId,
1945
+ deps.env,
1946
+ {
1947
+ question: built.questions[0]!.text,
1948
+ questions: built.questions,
1949
+ body: draft.presentation.body,
1950
+ ...(draft.project !== undefined ? { project: draft.project } : {}),
1951
+ ...(draft.source !== undefined ? { source: draft.source } : {}),
1952
+ ...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
1953
+ },
1954
+ (deps.now ?? Date.now)(),
1955
+ )
1956
+ } catch (err) {
1957
+ log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
1958
+ deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
1959
+ return EXIT.failed
1960
+ }
1961
+ log(deps).info('ask.registered', {
1962
+ ok: true,
1963
+ session: sessionId,
1964
+ questions: built.questions.length,
1965
+ text: built.questions[0]!.text,
1966
+ choices: built.questions[0]!.choices?.length ?? 0,
1967
+ media: draft.presentation.media?.length ?? 0,
1968
+ })
1969
+ // The block below is the densest guidance this CLI prints, and until now it
1970
+ // was prose only: an agent could not read back the choice ids it must branch
1971
+ // on without asking the server for them. The JSON form carries the same
1972
+ // obligation as data.
1973
+ if (json) {
1974
+ deps.io.out(
1975
+ JSON.stringify(
1976
+ {
1977
+ registered: true,
1978
+ questions: built.questions.map((entry) => ({
1979
+ id: entry.id,
1980
+ text: entry.text,
1981
+ ...(entry.choices === undefined ? {} : { choices: entry.choices }),
1982
+ ...(entry.multi === true ? { multi: true } : {}),
1983
+ })),
1984
+ next: {
1985
+ end_turn: true,
1986
+ in_this_turn:
1987
+ 'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
1988
+ route_neutral:
1989
+ 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
1990
+ on_answer:
1991
+ 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
1992
+ },
1993
+ },
1994
+ null,
1995
+ 2,
1996
+ ),
1997
+ )
1998
+ return EXIT.ok
1999
+ }
2000
+ for (const [index, entry] of built.questions.entries()) {
2001
+ const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
2002
+ if (entry.choices !== undefined) {
2003
+ const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
2004
+ deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
2005
+ } else if (built.questions.length > 1) {
2006
+ deps.io.out(`${prefix}${entry.text} — free text`)
2007
+ }
2008
+ }
2009
+ deps.io.out(
2010
+ built.questions.length > 1
2011
+ ? `${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.`
2012
+ : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
2013
+ )
2014
+ deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
2015
+ for (const [index, entry] of built.questions.entries()) {
2016
+ const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
2017
+ if (entry.choices !== undefined) {
2018
+ for (const choice of entry.choices) {
2019
+ deps.io.out(
2020
+ `- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
2021
+ )
2022
+ }
2023
+ deps.io.out(
2024
+ `- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
2025
+ )
2026
+ } else {
2027
+ deps.io.out(
2028
+ `- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
2029
+ )
2030
+ }
2031
+ }
2032
+ deps.io.out(
2033
+ '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.',
2034
+ )
2035
+ deps.io.out(
2036
+ 'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
2037
+ )
2038
+ return EXIT.ok
2039
+ }
2040
+
2041
+ async function uploadAskMedia(
2042
+ deps: CommandDeps,
2043
+ config: CliConfig,
2044
+ sessionId: string,
2045
+ built: BuiltQuestions,
2046
+ flags: AskFlags,
2047
+ invocation: DraftInvocation,
2048
+ ): Promise<number> {
2049
+ const authed = authedClient(deps, config)
2050
+ if (!authed) return EXIT.auth
2051
+ const mediaIds: string[] = []
2052
+ for (const image of flags.image ?? []) {
2053
+ if (image.startsWith('med_')) {
2054
+ mediaIds.push(image)
2055
+ continue
2056
+ }
2057
+ const uploaded = await uploadImage(deps, authed.client, image)
2058
+ if (!uploaded.ok) {
2059
+ if (uploaded.error !== null) deps.io.err(uploaded.error)
2060
+ return uploaded.exit
2061
+ }
2062
+ mediaIds.push(uploaded.mediaId)
2063
+ }
2064
+ const ready = buildAskDraft(config, built, flags, invocation, mediaIds)
2065
+ if (!ready.ok) {
2066
+ deps.io.err(ready.error)
2067
+ return EXIT.usage
2068
+ }
2069
+ return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true)
2070
+ }
2071
+
1746
2072
  /**
1747
2073
  * Registers a question for turn-end routing. Returns immediately so the agent
1748
2074
  * can ask in prose and end its turn; the terminal keeps the question to itself
1749
2075
  * for `ask_grace_seconds` before it reaches any device.
1750
2076
  */
1751
- export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
2077
+ export function askCommand(
2078
+ deps: CommandDeps,
2079
+ question: string | undefined,
2080
+ flags: AskFlags,
2081
+ ): number | Promise<number> {
1752
2082
  // Validate before route discovery. A malformed question belongs to the
1753
2083
  // caller and should not be hidden behind whichever harness setup issue
1754
2084
  // happens to exist on this machine.
@@ -1757,6 +2087,11 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1757
2087
  deps.io.err(built.error)
1758
2088
  return EXIT.usage
1759
2089
  }
2090
+ const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
2091
+ if (mediaInputError !== null) {
2092
+ deps.io.err(mediaInputError)
2093
+ return EXIT.usage
2094
+ }
1760
2095
  const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
1761
2096
  if (!routingConfig.ask_notifications.value) {
1762
2097
  deps.io.err(
@@ -1816,12 +2151,6 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1816
2151
  }
1817
2152
  return EXIT.usage
1818
2153
  }
1819
- if (flags.session !== undefined && flags.session !== active.sessionId) {
1820
- deps.io.err(
1821
- `Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`,
1822
- )
1823
- return EXIT.usage
1824
- }
1825
2154
  const routeProblems = activeQuestionRouteProblems(deps, active, installations)
1826
2155
  if (routeProblems.length > 0) {
1827
2156
  for (const problem of routeProblems) deps.io.err(`Question routing is not ready: ${problem}`)
@@ -1845,71 +2174,37 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1845
2174
  for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
1846
2175
  return EXIT.usage
1847
2176
  }
1848
- try {
1849
- registerQuestion(
1850
- sessionId,
1851
- deps.env,
1852
- {
1853
- question: built.questions[0]!.text,
1854
- questions: built.questions,
1855
- ...(built.detail !== undefined ? { detail: built.detail } : {}),
1856
- },
1857
- (deps.now ?? Date.now)(),
2177
+ const source = resolveDraftInvocation(deps, flags, active)
2178
+ if (!source.ok) {
2179
+ deps.io.err(source.error)
2180
+ return EXIT.usage
2181
+ }
2182
+ if (source.invocation.source?.session_id !== sessionId) {
2183
+ deps.io.err(
2184
+ `Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`,
1858
2185
  )
1859
- } catch (err) {
1860
- log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
1861
- deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
1862
- return EXIT.failed
2186
+ return EXIT.usage
1863
2187
  }
1864
- // `ask` returns immediately and the push happens later inside a hook, so this
1865
- // is the only local evidence that the question was ever registered — the
1866
- // starting point for "the agent asked, and nothing ever reached my phone".
1867
- log(deps).info('ask.registered', {
1868
- ok: true,
1869
- session: sessionId,
1870
- questions: built.questions.length,
1871
- text: built.questions[0]!.text,
1872
- choices: built.questions[0]!.choices?.length ?? 0,
1873
- })
1874
- for (const [index, entry] of built.questions.entries()) {
1875
- const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
1876
- if (entry.choices !== undefined) {
1877
- const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
1878
- deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
1879
- } else if (built.questions.length > 1) {
1880
- deps.io.out(`${prefix}${entry.text} — free text`)
1881
- }
2188
+
2189
+ // Placeholders let every body, source, project, media, and payload limit fail
2190
+ // before an upload starts. The real ids replace them only after this passes.
2191
+ const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`)
2192
+ const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders)
2193
+ if (!preflight.ok) {
2194
+ deps.io.err(preflight.error)
2195
+ return EXIT.usage
1882
2196
  }
1883
- deps.io.out(
1884
- built.questions.length > 1
1885
- ? `${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.`
1886
- : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
1887
- )
1888
- deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
1889
- for (const [index, entry] of built.questions.entries()) {
1890
- const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
1891
- if (entry.choices !== undefined) {
1892
- for (const choice of entry.choices) {
1893
- deps.io.out(
1894
- `- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
1895
- )
1896
- }
1897
- deps.io.out(
1898
- `- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
1899
- )
1900
- } else {
1901
- deps.io.out(
1902
- `- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
1903
- )
1904
- }
2197
+ if ((flags.image?.length ?? 0) > 0) {
2198
+ return uploadAskMedia(
2199
+ deps,
2200
+ routingConfig,
2201
+ sessionId,
2202
+ built,
2203
+ flags,
2204
+ source.invocation,
2205
+ )
1905
2206
  }
1906
- deps.io.out(
1907
- '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.',
1908
- )
1909
- deps.io.out(
1910
- 'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
1911
- )
1912
- return EXIT.ok
2207
+ return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true)
1913
2208
  }
1914
2209
 
1915
2210
  /**
@@ -2187,20 +2482,27 @@ function hookActivationAdvice(installations: Installation[]): string {
2187
2482
  return `${advice.join('. ')}.`
2188
2483
  }
2189
2484
 
2190
- /** Record the one Agent Acknowledgement associated with a replied-to request. */
2485
+ /**
2486
+ * Record the one Agent Acknowledgement associated with a replied-to request.
2487
+ *
2488
+ * `--text` is optional here because the acknowledgement is not: an account may
2489
+ * turn the agent's written reply off, and the receipt must still be recorded so
2490
+ * the user sees that an agent read the answer. The service holds the account's
2491
+ * snapshot, so it — not this process — decides whether text was owed.
2492
+ */
2191
2493
  export async function acknowledgeCommand(
2192
2494
  deps: CommandDeps,
2193
2495
  requestId: string,
2194
2496
  flags: { text?: string; json?: boolean },
2195
2497
  ): Promise<number> {
2196
2498
  const text = flags.text?.trim() ?? ''
2197
- if (text.length === 0) {
2198
- deps.io.err('--text is required and must contain non-whitespace text.')
2499
+ if (flags.text !== undefined && text.length === 0) {
2500
+ deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.')
2199
2501
  return EXIT.usage
2200
2502
  }
2201
2503
  if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
2202
2504
  deps.io.err(
2203
- `--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming.`,
2505
+ `--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming. Shorten it: an acknowledgement is a receipt, not a report.`,
2204
2506
  )
2205
2507
  return EXIT.usage
2206
2508
  }
@@ -2215,7 +2517,10 @@ export async function acknowledgeCommand(
2215
2517
  const authed = authedClient(deps, config)
2216
2518
  if (!authed) return EXIT.auth
2217
2519
  try {
2218
- const result = await authed.client.putAgentAcknowledgement(requestId, { text })
2520
+ const result = await authed.client.putAgentAcknowledgement(
2521
+ requestId,
2522
+ text.length > 0 ? { text } : {},
2523
+ )
2219
2524
  const output = {
2220
2525
  request_id: requestId,
2221
2526
  outcome: result.status,
@@ -2264,6 +2569,7 @@ export async function closeCommand(
2264
2569
  acknowledgement_command: acknowledgementCommand(
2265
2570
  response.request_id,
2266
2571
  response.agent_acknowledgement_required,
2572
+ response.agent_acknowledgement_text_required,
2267
2573
  response.agent_acknowledgement,
2268
2574
  response.replies.length > 0,
2269
2575
  ),
@@ -2342,7 +2648,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2342
2648
  if (flags.harness === undefined) {
2343
2649
  const detected = detectedHarnesses(deps.cwd, deps.env)
2344
2650
  if (detected.length === 0) {
2345
- deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
2651
+ deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`)
2346
2652
  return EXIT.usage
2347
2653
  }
2348
2654
  let ok = true
@@ -2467,6 +2773,13 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2467
2773
  for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
2468
2774
  deps.io.out(problem)
2469
2775
  }
2776
+ for (const note of codexCoexistenceNotes(deps.cwd, deps.env, hookPlatform)) {
2777
+ deps.io.out(note)
2778
+ }
2779
+ if (flags.global) {
2780
+ const home = codexHomeNote(deps.env, hookPlatform)
2781
+ if (home !== null) deps.io.out(home)
2782
+ }
2470
2783
  }
2471
2784
  return EXIT.ok
2472
2785
  }
@@ -2632,13 +2945,6 @@ function assertOwnedRegularFile(file: string): void {
2632
2945
  }
2633
2946
  }
2634
2947
 
2635
- const HARNESS_LABEL: Record<Harness, string> = {
2636
- 'claude-code': 'Claude Code',
2637
- codex: 'Codex',
2638
- cursor: 'Cursor',
2639
- opencode: 'OpenCode',
2640
- }
2641
-
2642
2948
  function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
2643
2949
  if (requested !== undefined) {
2644
2950
  if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
@@ -2675,7 +2981,7 @@ async function pickHarnessesToInstall(
2675
2981
  'Which agent harnesses should Notifai wire here?',
2676
2982
  HARNESSES.map((name) => ({
2677
2983
  value: name,
2678
- label: HARNESS_LABEL[name],
2984
+ label: HARNESS_LABELS[name],
2679
2985
  ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
2680
2986
  })),
2681
2987
  detected,
@@ -3148,8 +3454,8 @@ export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
3148
3454
  query.since = since
3149
3455
  }
3150
3456
  if (flags.level !== undefined) {
3151
- if (!(LOG_LEVELS as readonly string[]).includes(flags.level)) {
3152
- deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`)
3457
+ if (!(RECORD_LEVELS as readonly string[]).includes(flags.level)) {
3458
+ deps.io.err(`--level takes one of: ${RECORD_LEVELS.join(', ')} — not "${flags.level}".`)
3153
3459
  return EXIT.usage
3154
3460
  }
3155
3461
  query.level = flags.level as LogLevel
@@ -3294,14 +3600,9 @@ async function skillReadiness(
3294
3600
  }
3295
3601
  }
3296
3602
 
3297
- /** Derive a contract-valid project slug from a directory name. */
3603
+ /** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
3298
3604
  export function projectSlugFrom(name: string): string {
3299
- const slug = name
3300
- .toLowerCase()
3301
- .replaceAll(/[^a-z0-9._-]+/g, '-')
3302
- .replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
3303
- .slice(0, 64)
3304
- return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project'
3605
+ return inferredProjectSlugFrom(name) ?? 'project'
3305
3606
  }
3306
3607
 
3307
3608
  export interface InitFlags {
@@ -4036,7 +4337,11 @@ async function printInitClose(
4036
4337
  ]
4037
4338
  await deps.io.note?.(lines.join('\n'), 'Ready')
4038
4339
  printOptionalLeftovers(deps, leftovers)
4039
- deps.io.out('All set. Agents in this project can notify you and ask questions.')
4340
+ deps.io.out(
4341
+ questions
4342
+ ? 'All set. Agents in this project can notify you and ask you questions.'
4343
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
4344
+ )
4040
4345
  await deps.io.outro?.('All set ✨')
4041
4346
  return
4042
4347
  }
@@ -4052,7 +4357,11 @@ async function printInitClose(
4052
4357
 
4053
4358
  if (blocker === null) {
4054
4359
  printOptionalLeftovers(deps, leftovers)
4055
- deps.io.out('All set. Agents in this project can notify you and ask questions.')
4360
+ deps.io.out(
4361
+ questions
4362
+ ? 'All set. Agents in this project can notify you and ask you questions.'
4363
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
4364
+ )
4056
4365
  return
4057
4366
  }
4058
4367
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
@@ -4106,29 +4415,40 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
4106
4415
  }
4107
4416
 
4108
4417
  function projectReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
4109
- const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
4110
- const projectSlug = config.project.value
4111
- return projectSlug !== null
4112
- ? {
4113
- id: 'project',
4114
- title: 'Project identity',
4115
- status: 'ready',
4116
- detail: `"${projectSlug}" (${config.project.source})`,
4117
- }
4118
- : {
4119
- id: 'project',
4120
- title: 'Project identity',
4121
- // Not a blocker: a send without a project simply carries no project
4122
- // identity. init always sets one because it is free and reversible,
4123
- // but an unlabelled setup works, so this must not go red.
4124
- status: 'optional-gap',
4125
- detail: `not set in ${configPath} — sends from here carry no project identity`,
4126
- remedy: {
4127
- by: 'cli',
4128
- summary: 'name this project after its directory',
4129
- command: 'notifai init',
4130
- },
4131
- }
4418
+ const configured = config.project.value
4419
+ if (configured !== null) {
4420
+ return {
4421
+ id: 'project',
4422
+ title: 'Project identity',
4423
+ status: 'ready',
4424
+ detail: `"${configured}" (${config.project.source})`,
4425
+ }
4426
+ }
4427
+ const inferred = inferInvocationContext(deps.cwd).project
4428
+ if (inferred !== null) {
4429
+ return {
4430
+ id: 'project',
4431
+ title: 'Project identity',
4432
+ status: 'optional-gap',
4433
+ detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
4434
+ remedy: {
4435
+ by: 'cli',
4436
+ summary: 'make the inferred Project identity explicit for every checkout',
4437
+ command: 'notifai init',
4438
+ },
4439
+ }
4440
+ }
4441
+ return {
4442
+ id: 'project',
4443
+ title: 'Project identity',
4444
+ status: 'optional-gap',
4445
+ detail: 'the directory name has no characters a Project identifier can use',
4446
+ remedy: {
4447
+ by: 'cli',
4448
+ summary: 'choose an explicit Project identifier',
4449
+ command: 'notifai init --project-id my-project',
4450
+ },
4451
+ }
4132
4452
  }
4133
4453
 
4134
4454
  function remoteStatesFrom(previous: Readiness): {
@@ -4627,15 +4947,41 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
4627
4947
  ]
4628
4948
  }
4629
4949
 
4950
+ /**
4951
+ * A human title per check.
4952
+ *
4953
+ * Three checks used to collapse onto "Question routing", so a reader could not
4954
+ * tell which of them had failed, and the rest fell through to their internal
4955
+ * name — `hooks (stale)` beside `Delivery proof`. The `id` stays the stable
4956
+ * thing to branch on; this is only what a person reads.
4957
+ */
4958
+ const CHECK_TITLES: Readonly<Record<string, string>> = {
4959
+ hooks: 'Question routing',
4960
+ 'hooks (detected)': 'Harnesses detected',
4961
+ 'hooks (active harness)': 'Routing for this harness',
4962
+ 'hooks (active session)': 'Routing for this session',
4963
+ 'hooks (stale)': 'Hook definitions current',
4964
+ 'hooks (adapter)': 'Hook adapter',
4965
+ 'hooks (trust)': 'Codex hook trust',
4966
+ 'hooks (stop shape)': 'Turn-end hook shape',
4967
+ 'hooks (duplicates)': 'Duplicate hook installs',
4968
+ 'hooks (codex representation)': 'Codex hook representation',
4969
+ 'hooks (question admission)': 'Question admission',
4970
+ 'hooks (fired)': 'Hooks have run here',
4971
+ 'hooks (answer continuation)': 'How an answer returns',
4972
+ 'hooks (wake route)': 'Direct wake route',
4973
+ }
4974
+
4975
+ function checkTitle(name: string): string {
4976
+ return CHECK_TITLES[name] ?? name
4977
+ }
4978
+
4630
4979
  /** Real but not in the way; see the note above. */
4631
4980
  const informational = new Set<string>()
4632
4981
  return [
4633
4982
  ...hookChecks(deps).map((check) => ({
4634
4983
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
4635
- title:
4636
- check.name === 'hooks' || check.name.startsWith('hooks (active')
4637
- ? 'Question routing'
4638
- : check.name,
4984
+ title: checkTitle(check.name),
4639
4985
  status: check.ok
4640
4986
  ? 'ready' as const
4641
4987
  : check.informational === true || informational.has(check.name)
@@ -4685,7 +5031,7 @@ function stopShapeProblems(
4685
5031
  for (const handler of installation.handlers.filter(
4686
5032
  (entry) => handlerEvent(entry.command) === 'stop',
4687
5033
  )) {
4688
- if (installation.harness === 'claude-code' && platform !== 'win32') {
5034
+ if (stopHandlerIsDetached(installation.harness, platform)) {
4689
5035
  if (handler.async !== true) {
4690
5036
  problems.push(
4691
5037
  `${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
@@ -4746,7 +5092,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4746
5092
  name: 'hooks (detected)',
4747
5093
  ok: false,
4748
5094
  informational: true,
4749
- detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
5095
+ detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
4750
5096
  remedy: {
4751
5097
  summary: 'install hooks for every detected harness',
4752
5098
  command: 'notifai hooks install',
@@ -4922,6 +5268,20 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4922
5268
  ok: false,
4923
5269
  detail: representationProblems.join('; '),
4924
5270
  })
5271
+ } else {
5272
+ const coexistence = codexCoexistenceNotes(deps.cwd, deps.env, deps.hookPlatform)
5273
+ if (coexistence.length > 0) {
5274
+ checks.push({
5275
+ name: 'hooks (codex representation)',
5276
+ ok: true,
5277
+ detail: coexistence.join('; '),
5278
+ })
5279
+ }
5280
+ }
5281
+
5282
+ const codexHome = codexHomeNote(deps.env, deps.hookPlatform)
5283
+ if (codexHome !== null) {
5284
+ checks.push({ name: 'hooks (codex home)', ok: true, detail: codexHome })
4925
5285
  }
4926
5286
 
4927
5287
  if (active !== null && activeInstallations.length > 0) {