@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
@@ -1,3 +1,4 @@
1
+ import { REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
1
2
  import {
2
3
  existsSync,
3
4
  mkdirSync,
@@ -11,6 +12,7 @@ import {
11
12
  import os from 'node:os'
12
13
  import path from 'node:path'
13
14
  import { fileURLToPath } from 'node:url'
15
+ import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH } from '@raidiant/notifai-protocol'
14
16
  import type {
15
17
  CapabilityDocument,
16
18
  EvidenceSnapshot,
@@ -261,6 +263,7 @@ const receipt: SubmissionReceipt = {
261
263
  request_id: 'req_reply_test',
262
264
  reply_expires_at: '2026-08-02T18:00:00.000Z',
263
265
  agent_acknowledgement_required: true,
266
+ agent_acknowledgement_text_required: true,
264
267
  replayed: false,
265
268
  overall: 'provider_accepted_all',
266
269
  deliveries: [
@@ -293,6 +296,7 @@ function replyResponse(
293
296
  replies: ReplyView[] = [],
294
297
  options: {
295
298
  required?: boolean
299
+ textRequired?: boolean
296
300
  acknowledgement?: ListRepliesResponse['agent_acknowledgement']
297
301
  } = {},
298
302
  ): ListRepliesResponse {
@@ -300,6 +304,7 @@ function replyResponse(
300
304
  request_id: receipt.request_id,
301
305
  reply_expires_at: '2026-08-02T18:00:00.000Z',
302
306
  agent_acknowledgement_required: options.required ?? true,
307
+ agent_acknowledgement_text_required: options.textRequired ?? true,
303
308
  agent_acknowledgement: options.acknowledgement ?? null,
304
309
  replies,
305
310
  }
@@ -378,7 +383,7 @@ describe('command contracts', () => {
378
383
  } as unknown as ApiClient
379
384
 
380
385
  expect(
381
- await sendCommand(makeDeps(io, client), {
386
+ await sendCommand(makeDeps(io, client), { kind: 'update',
382
387
  title: 'T',
383
388
  body: 'B',
384
389
  project: 'Invalid Project!',
@@ -388,6 +393,117 @@ describe('command contracts', () => {
388
393
  expect(io.errLines.join('\n')).toContain('project')
389
394
  })
390
395
 
396
+ it('infers Project and exact Claude Source Context without printing the opaque id', async () => {
397
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'Notifai Project '))
398
+ const io = new CapturedIo()
399
+ let submitted: SubmitNotificationRequestT | undefined
400
+ const client = {
401
+ submit: async (body: SubmitNotificationRequestT) => {
402
+ submitted = body
403
+ return receipt
404
+ },
405
+ } as unknown as ApiClient
406
+ const deps = {
407
+ ...makeDeps(io, client),
408
+ cwd,
409
+ env: {
410
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
411
+ XDG_STATE_HOME: path.join(cwd, 'state'),
412
+ CLAUDECODE: '1',
413
+ CLAUDE_CODE_SESSION_ID: 'opaque-claude-session-42',
414
+ },
415
+ }
416
+
417
+ expect(
418
+ await sendCommand(deps, {
419
+ title: 'All checks passed',
420
+ body: '**42 checks** passed.',
421
+ kind: 'done',
422
+ }),
423
+ ).toBe(EXIT.ok)
424
+
425
+ expect(submitted?.draft.project).toMatch(/^notifai-project-/)
426
+ expect(submitted?.draft.source).toMatchObject({
427
+ session_id: 'opaque-claude-session-42',
428
+ harness: 'claude-code',
429
+ })
430
+ expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
431
+ expect(io.outLines.join('\n')).not.toContain('opaque-claude-session-42')
432
+ expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
433
+ })
434
+
435
+ it('uploads repeatable images in order and sends only canonical media references', async () => {
436
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
437
+ const first = path.join(root, 'first.png')
438
+ const second = path.join(root, 'second.gif')
439
+ writeFileSync(first, 'first image')
440
+ writeFileSync(second, 'second image')
441
+ const uploaded: string[] = []
442
+ let grants = 0
443
+ let submitted: SubmitNotificationRequestT | undefined
444
+ const client = {
445
+ createMediaUpload: async () => {
446
+ grants += 1
447
+ return {
448
+ media_id: `med_uploaded_${grants}`,
449
+ upload_url: `https://upload.invalid/${grants}`,
450
+ upload_headers: {},
451
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
452
+ }
453
+ },
454
+ uploadMedia: async (grant: { media_id: string }) => {
455
+ uploaded.push(grant.media_id)
456
+ },
457
+ submit: async (body: SubmitNotificationRequestT) => {
458
+ submitted = body
459
+ return receipt
460
+ },
461
+ } as unknown as ApiClient
462
+
463
+ expect(
464
+ await sendCommand(makeDeps(new CapturedIo(), client), { kind: 'update',
465
+ title: 'Visual comparison is ready',
466
+ body:
467
+ '![first](media:1) ![second](media:2) ![ready](media:med_existing)',
468
+ image: [first, second, 'med_attached_only'],
469
+ imageAlt: ['First state', 'Second state'],
470
+ }),
471
+ ).toBe(EXIT.ok)
472
+
473
+ expect(uploaded).toEqual(['med_uploaded_1', 'med_uploaded_2'])
474
+ expect(submitted?.draft.presentation.media).toEqual([
475
+ { media_id: 'med_uploaded_1', alt: 'First state' },
476
+ { media_id: 'med_uploaded_2', alt: 'Second state' },
477
+ { media_id: 'med_attached_only' },
478
+ ])
479
+ expect(submitted?.draft.presentation.body).toBe(
480
+ '![first](media:med_uploaded_1) ![second](media:med_uploaded_2) ![ready](media:med_existing)',
481
+ )
482
+ expect(submitted?.draft.presentation.body).not.toMatch(/media:[1-8](?!\d)/)
483
+ })
484
+
485
+ it('rejects media cardinality and alt pairing before any upload', async () => {
486
+ const io = new CapturedIo()
487
+ let uploads = 0
488
+ const client = {
489
+ createMediaUpload: async () => {
490
+ uploads += 1
491
+ throw new Error('must not upload')
492
+ },
493
+ } as unknown as ApiClient
494
+
495
+ expect(
496
+ await sendCommand(makeDeps(io, client), { kind: 'update',
497
+ title: 'Visual comparison',
498
+ body: 'See it.',
499
+ image: ['one.png'],
500
+ imageAlt: ['one', 'two'],
501
+ }),
502
+ ).toBe(EXIT.usage)
503
+ expect(uploads).toBe(0)
504
+ expect(io.errLines.join(' ')).toContain('2 --image-alt')
505
+ })
506
+
391
507
  it('rejects done plus reply before authentication or submission', async () => {
392
508
  const io = new CapturedIo()
393
509
  let submitCalls = 0
@@ -412,23 +528,19 @@ describe('command contracts', () => {
412
528
 
413
529
  it.each([
414
530
  {
415
- flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
531
+ flags: { kind: 'update', title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
416
532
  warning: /titles work best around 40/i,
417
533
  },
418
534
  {
419
- flags: { title: 'Details', body: '**bold Markdown**' },
420
- warning: /--body looks like Markdown.*--detail/i,
535
+ flags: { kind: 'done', title: 'Done · build', body: 'All green.' },
536
+ warning: /keep the title to the specific substance/i,
421
537
  },
422
538
  {
423
- flags: { title: 'Done · build', body: 'All green.' },
424
- warning: /Use --kind done/i,
539
+ flags: { kind: 'failed', title: 'Failed · build', body: 'One integration test failed.' },
540
+ warning: /Put notification type in --kind/i,
425
541
  },
426
542
  {
427
- flags: { title: 'Failed · build', body: 'One integration test failed.' },
428
- warning: /Use --kind done/i,
429
- },
430
- {
431
- flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
543
+ flags: { kind: 'update', title: 'Update', body: 'Still relevant.', ttl: 259_201 },
432
544
  warning: /longer than 72 hours/i,
433
545
  },
434
546
  ])('warns without rejecting a send: $warning', async ({ flags, warning }) => {
@@ -452,18 +564,31 @@ describe('command contracts', () => {
452
564
  cwd: root,
453
565
  }
454
566
 
455
- expect(await sendCommand(deps, { title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
567
+ expect(await sendCommand(deps, { kind: 'update', title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
456
568
  expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
457
569
  })
458
570
 
459
- it('removes unmanaged provider fields from the public send flags', () => {
571
+ it('keeps only the current public notification-authoring flags', () => {
460
572
  const source = readFileSync(new URL('./main.ts', import.meta.url), 'utf8')
461
573
  expect(source).not.toContain(".option('--badge")
462
574
  expect(source).not.toContain(".option('--relevance")
463
575
  expect(source).not.toContain(".option('--target-content-id")
464
- expect(source).toContain('Kind profiles apply automatically')
465
- expect(source).toContain('Pass --sound or --level only to override')
466
- expect(source).toContain('override kind profile / saved config')
576
+ const sendGrammar = source.slice(source.indexOf(".command('send')"), source.indexOf('send.addHelpText'))
577
+ const askGrammar = source.slice(source.indexOf(".command('ask [question]')"), source.indexOf(".command('close"))
578
+ for (const grammar of [sendGrammar, askGrammar]) {
579
+ expect(grammar).not.toContain(".option('--detail")
580
+ expect(grammar).not.toContain(".option('--session <")
581
+ expect(grammar).toContain(".option('--body-file")
582
+ expect(grammar).toContain(".option('--session-id")
583
+ expect(grammar).toContain(".option('--session-label")
584
+ expect(grammar).toContain("'--image <path|url|media_id>'")
585
+ expect(grammar).toContain("'--image-alt <text>'")
586
+ }
587
+ // Kind now selects the sound, so the help must say so — and must not carry
588
+ // the retired separation it replaced.
589
+ expect(source).toContain('Kind is required, and it selects the sound')
590
+ expect(source).not.toContain('it never chooses banner sound or interruption level')
591
+ expect(source).not.toContain('Kind profiles apply automatically')
467
592
  })
468
593
 
469
594
  it('rejects a question nobody will wait for', async () => {
@@ -542,8 +667,8 @@ describe('command contracts', () => {
542
667
  })
543
668
 
544
669
  it.each([
545
- { title: 'Deploy? ', body: 'Ready.' },
546
- { title: 'Deployment', body: 'Should I deploy?\n' },
670
+ { kind: 'update', title: 'Deploy? ', body: 'Ready.' },
671
+ { kind: 'update', title: 'Deployment', body: 'Should I deploy?\n' },
547
672
  ])('warns on stderr when $title / $body ends in a question after trimming', async (flags) => {
548
673
  const io = new CapturedIo()
549
674
  const client = { submit: async () => receipt } as unknown as ApiClient
@@ -583,7 +708,7 @@ describe('command contracts', () => {
583
708
  } as unknown as ApiClient
584
709
 
585
710
  expect(
586
- await sendCommand(makeDeps(io, client), {
711
+ await sendCommand(makeDeps(io, client), { kind: 'update',
587
712
  title: 'Deploy?',
588
713
  body: 'Choose when ready.',
589
714
  replyChoice: ['Now', 'Later'],
@@ -600,7 +725,7 @@ describe('command contracts', () => {
600
725
  const client = { submit: async () => receipt } as unknown as ApiClient
601
726
 
602
727
  expect(
603
- await sendCommand(makeDeps(io, client), {
728
+ await sendCommand(makeDeps(io, client), { kind: 'update',
604
729
  title: 'Deployment',
605
730
  body: 'Should I deploy?',
606
731
  json: true,
@@ -822,6 +947,7 @@ describe('command contracts', () => {
822
947
  reply_expires_at: '2026-08-02T18:00:00.000Z',
823
948
  replies: [],
824
949
  agent_acknowledgement_required: true,
950
+ agent_acknowledgement_text_required: true,
825
951
  agent_acknowledgement: null,
826
952
  acknowledgement_command: null,
827
953
  degraded: false,
@@ -855,6 +981,7 @@ describe('command contracts', () => {
855
981
  reply_expires_at: '2026-08-02T18:00:00.000Z',
856
982
  replies: [reply],
857
983
  agent_acknowledgement_required: true,
984
+ agent_acknowledgement_text_required: true,
858
985
  agent_acknowledgement: null,
859
986
  acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
860
987
  degraded: false,
@@ -968,9 +1095,11 @@ describe('command contracts', () => {
968
1095
  })
969
1096
 
970
1097
  it.each([
971
- { text: undefined, message: '--text is required' },
972
- { text: ' ', message: '--text is required' },
973
- { text: 'x'.repeat(1001), message: 'at most 1000 characters' },
1098
+ { text: ' ', message: 'must contain non-whitespace text' },
1099
+ {
1100
+ text: 'x'.repeat(AGENT_ACKNOWLEDGEMENT_MAX_LENGTH + 1),
1101
+ message: `at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters`,
1102
+ },
974
1103
  ])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
975
1104
  const io = new CapturedIo()
976
1105
  let calls = 0
@@ -988,6 +1117,60 @@ describe('command contracts', () => {
988
1117
  expect(io.errLines.join('\n')).toContain(message)
989
1118
  })
990
1119
 
1120
+ it('records an acknowledgement with no text at all', async () => {
1121
+ const io = new CapturedIo()
1122
+ let submitted: { requestId: string; body: { text?: string } } | undefined
1123
+ const client = {
1124
+ putAgentAcknowledgement: async (requestId: string, body: { text?: string }) => {
1125
+ submitted = { requestId, body }
1126
+ return {
1127
+ status: 'recorded' as const,
1128
+ agent_acknowledgement: { text: '', created_at: '2026-08-13T12:01:00.000Z' },
1129
+ }
1130
+ },
1131
+ } as unknown as ApiClient
1132
+
1133
+ expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
1134
+ // No empty string either: an omitted field is what "no text" means on the
1135
+ // wire, and the account's snapshot decides whether that was allowed.
1136
+ expect(submitted).toEqual({ requestId: receipt.request_id, body: {} })
1137
+ expect(io.outLines.join('\n')).toContain(`Agent Acknowledgement recorded for ${receipt.request_id}`)
1138
+ })
1139
+
1140
+ it('names the missing text, not a contract mismatch, when the account requires it', async () => {
1141
+ const io = new CapturedIo()
1142
+ const client = {
1143
+ putAgentAcknowledgement: async () => {
1144
+ throw new ApiCallError(
1145
+ 422,
1146
+ 'acknowledgement_text_required',
1147
+ 'This account requires Agent Acknowledgement text.',
1148
+ 'Re-send the acknowledgement with text saying what you will do because of the reply.',
1149
+ )
1150
+ },
1151
+ } as unknown as ApiClient
1152
+
1153
+ expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.failed)
1154
+ const err = io.errLines.join('\n')
1155
+ expect(err).toContain('acknowledgement_text_required')
1156
+ expect(err).toContain('next: Re-send the acknowledgement with text')
1157
+ // The contract held; only this body fell short of the account's choice.
1158
+ expect(err).not.toContain('disagree about the contract')
1159
+ })
1160
+
1161
+ it('asks for a text-free acknowledgement when the account turned text off', async () => {
1162
+ const io = new CapturedIo()
1163
+ const client = {
1164
+ replies: async () => replyResponse([reply], { textRequired: false }),
1165
+ } as unknown as ApiClient
1166
+
1167
+ expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
1168
+ const out = io.outLines.join('\n')
1169
+ expect(out).toContain('Agent Acknowledgement required.')
1170
+ expect(out).toContain(`notifai acknowledge ${receipt.request_id}\``)
1171
+ expect(out).not.toContain('--text')
1172
+ })
1173
+
991
1174
  it.each([
992
1175
  [new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
993
1176
  [new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
@@ -1596,7 +1779,13 @@ describe('Codex hook representation', () => {
1596
1779
  expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
1597
1780
  })
1598
1781
 
1599
- it('installs into hooks.json when it alone already contains hooks', () => {
1782
+ /**
1783
+ * The foreign `hooks.json` stays byte-identical and Notifai lands inline.
1784
+ * Codex will now warn that the layer uses both representations; the install
1785
+ * output says why, because that warning is Codex reporting a merge, not a
1786
+ * fault, and a silent one would read as Notifai's bug.
1787
+ */
1788
+ it('leaves a foreign hooks.json alone and installs inline instead', () => {
1600
1789
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
1601
1790
  const layer = path.join(cwd, '.codex')
1602
1791
  const json = path.join(layer, 'hooks.json')
@@ -1614,11 +1803,17 @@ describe('Codex hook representation', () => {
1614
1803
 
1615
1804
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1616
1805
 
1617
- expect(readFileSync(toml, 'utf8')).toBe(beforeToml)
1618
- expect(readFileSync(json, 'utf8')).toContain('gdh-stop')
1619
- expect(readFileSync(json, 'utf8')).toContain('--owner notifai')
1620
- expect(io.outLines.join('\n')).toContain(json)
1621
- expect(io.outLines.join('\n')).not.toMatch(/loading hooks from both/i)
1806
+ const beforeJson = readFileSync(json, 'utf8')
1807
+ expect(beforeJson).toContain('gdh-stop')
1808
+ expect(beforeJson).not.toContain('--owner notifai')
1809
+ const after = readFileSync(toml, 'utf8')
1810
+ // The user's file arrives back as the file they wrote, plus our hooks.
1811
+ expect(after).toContain(beforeToml.trimEnd())
1812
+ expect(after).toContain('--owner notifai')
1813
+ expect(io.outLines.join('\n')).toContain(toml)
1814
+ expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
1815
+ expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
1816
+ expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
1622
1817
  })
1623
1818
 
1624
1819
  it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
@@ -1635,8 +1830,14 @@ describe('Codex hook representation', () => {
1635
1830
  expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
1636
1831
  })
1637
1832
 
1638
- it('does not migrate our hooks when both representations already exist', async () => {
1639
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-dual-'))
1833
+ /**
1834
+ * Notifai in one file, someone else's hooks in the other: every handler
1835
+ * fires once, so doctor has nothing to report and the foreign file is not
1836
+ * ours to rewrite. Doctor used to fail here — the check read "two files" as
1837
+ * "two Notifai copies" and pointed at configuration another program owns.
1838
+ */
1839
+ it('passes doctor when only the other representation is foreign', async () => {
1840
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-rep-'))
1640
1841
  const toml = writeInlineStop(cwd, 'gdh-stop')
1641
1842
  const json = path.join(cwd, '.codex', 'hooks.json')
1642
1843
  const env = isolatedEnv(cwd)
@@ -1656,12 +1857,12 @@ describe('Codex hook representation', () => {
1656
1857
  })
1657
1858
 
1658
1859
  io.outLines = []
1659
- expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1660
- const before = io.outLines.join('\n')
1661
- expect(before).toMatch(/hooks \(codex representation\)/)
1662
- expect(before).toMatch(/prefer a single representation/)
1663
- expect(before).toMatch(/Stop handlers will both (run|fire)/i)
1664
- expect(before).not.toContain('notifai hooks install --harness codex')
1860
+ await doctorCommand(deps, {})
1861
+ // Their assertions, this branch's check title.
1862
+ const report = io.outLines.join('\n')
1863
+ expect(report).not.toMatch(/FAIL {2}Codex hook representation/)
1864
+ expect(report).toMatch(/ok {4}Codex hook representation/)
1865
+ expect(report).toMatch(/Notifai will not modify it/)
1665
1866
 
1666
1867
  io.outLines = []
1667
1868
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
@@ -1669,7 +1870,7 @@ describe('Codex hook representation', () => {
1669
1870
  expect(readFileSync(json, 'utf8')).toContain('--owner notifai')
1670
1871
  expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
1671
1872
  expect(readFileSync(toml, 'utf8')).not.toContain('--owner notifai')
1672
- expect(io.outLines.join('\n')).toMatch(/loading hooks from both/i)
1873
+ expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
1673
1874
  expect(io.outLines.join('\n')).not.toMatch(/collaps/i)
1674
1875
 
1675
1876
  const installations = findInstallations(cwd, env, deps.hookAdapterHome).filter(
@@ -1678,6 +1879,44 @@ describe('Codex hook representation', () => {
1678
1879
  expect(installations).toHaveLength(1)
1679
1880
  expect(installations[0]?.file).toBe(json)
1680
1881
  })
1882
+
1883
+ it('fails doctor when Notifai itself is installed in both representations', async () => {
1884
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-dual-'))
1885
+ const json = path.join(cwd, '.codex', 'hooks.json')
1886
+ const env = isolatedEnv(cwd)
1887
+ const io = new CapturedIo()
1888
+ const client = {
1889
+ health: async () => false,
1890
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1891
+ listDevices: async () => ({ devices: [] }),
1892
+ } as unknown as ApiClient
1893
+ const deps = { ...makeDeps(io, client), cwd, env }
1894
+
1895
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1896
+ applyPlan(json, {
1897
+ hooks: buildHookConfig({
1898
+ adapterPath: hookAdapterPath(deps.hookAdapterHome),
1899
+ harness: 'codex',
1900
+ }),
1901
+ })
1902
+
1903
+ io.outLines = []
1904
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1905
+ const reported = io.outLines.join('\n')
1906
+ expect(reported).toMatch(/Codex hook representation/)
1907
+ expect(reported).toMatch(/installed in both/i)
1908
+ expect(reported).toMatch(/notifies twice per turn/)
1909
+ expect(reported).toMatch(/notifai hooks uninstall --harness codex/)
1910
+
1911
+ // The named remedy has to actually clear it, including the copy in the
1912
+ // file Notifai would not have chosen to write.
1913
+ io.outLines = []
1914
+ expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
1915
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1916
+ io.outLines = []
1917
+ await doctorCommand(deps, {})
1918
+ expect(io.outLines.join('\n')).not.toMatch(/Codex hook representation/)
1919
+ })
1681
1920
  })
1682
1921
 
1683
1922
  describe('harness activation guidance', () => {
@@ -1793,14 +2032,14 @@ describe('harness activation guidance', () => {
1793
2032
 
1794
2033
  io.outLines = []
1795
2034
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1796
- expect(io.outLines.join('\n')).toContain('hooks (answer continuation)')
2035
+ expect(io.outLines.join('\n')).toContain('How an answer returns')
1797
2036
  expect(io.outLines.join('\n')).toContain('no proven answer continuation')
1798
- expect(io.outLines.join('\n')).not.toContain('hooks (adapter)')
2037
+ expect(io.outLines.join('\n')).not.toContain('Hook adapter')
1799
2038
 
1800
2039
  writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
1801
2040
  io.outLines = []
1802
2041
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1803
- expect(io.outLines.join('\n')).toContain('hooks (adapter)')
2042
+ expect(io.outLines.join('\n')).toContain('Hook adapter')
1804
2043
  expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
1805
2044
  })
1806
2045
 
@@ -1899,7 +2138,7 @@ describe('stable hook installation', () => {
1899
2138
 
1900
2139
  io.outLines = []
1901
2140
  await doctorCommand(deps, {})
1902
- expect(io.outLines.join('\n')).not.toMatch(/hooks \(duplicates\)/)
2141
+ expect(io.outLines.join('\n')).not.toMatch(/Duplicate hook installs/)
1903
2142
 
1904
2143
  expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
1905
2144
  expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
@@ -3732,6 +3971,74 @@ describe('asking before the hooks have ever run', () => {
3732
3971
  expect(io.outLines).toContain(
3733
3972
  'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
3734
3973
  )
3974
+ expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
3975
+ session_id: 'codex-current-thread',
3976
+ harness: 'codex',
3977
+ })
3978
+ expect(
3979
+ readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
3980
+ ).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
3981
+ })
3982
+
3983
+ it('uploads ask images before registration and freezes canonical body media', async () => {
3984
+ const cwd = scratchDir('notifai-ask-media-')
3985
+ const first = path.join(cwd, 'first.png')
3986
+ const second = path.join(cwd, 'second.gif')
3987
+ writeFileSync(first, 'first')
3988
+ writeFileSync(second, 'second')
3989
+ const io = new CapturedIo()
3990
+ const env = {
3991
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3992
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3993
+ HOME: path.join(cwd, 'home'),
3994
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3995
+ CODEX_THREAD_ID: 'codex-media-thread',
3996
+ }
3997
+ let grant = 0
3998
+ const uploaded: string[] = []
3999
+ const client = {
4000
+ createMediaUpload: async () => {
4001
+ grant += 1
4002
+ return {
4003
+ media_id: `med_ask_${grant}`,
4004
+ upload_url: `https://upload.invalid/${grant}`,
4005
+ upload_headers: {},
4006
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
4007
+ }
4008
+ },
4009
+ uploadMedia: async (value: { media_id: string }) => {
4010
+ uploaded.push(value.media_id)
4011
+ },
4012
+ } as unknown as ApiClient
4013
+ const deps = { ...makeDeps(io, client), cwd, env, now: () => 42 }
4014
+
4015
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
4016
+ trustInstalledCodexHooks(cwd, env)
4017
+ writeSessionState('codex-media-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
4018
+ writeProjectSession(cwd, env, 'codex-media-thread', 42, 'codex')
4019
+ io.outLines = []
4020
+
4021
+ expect(
4022
+ await askCommand(deps, 'Which visual should I use?', {
4023
+ body: 'Compare ![first](media:1) with ![second](media:2).',
4024
+ image: [first, second],
4025
+ imageAlt: ['First option', 'Second option'],
4026
+ }),
4027
+ ).toBe(EXIT.ok)
4028
+
4029
+ const pending = readSessionState('codex-media-thread', env).pending?.[0]
4030
+ expect(uploaded).toEqual(['med_ask_1', 'med_ask_2'])
4031
+ expect(pending?.body).toBe(
4032
+ 'Which visual should I use?\n\nCompare ![first](media:med_ask_1) with ![second](media:med_ask_2).',
4033
+ )
4034
+ expect(pending?.media).toEqual([
4035
+ { media_id: 'med_ask_1', alt: 'First option' },
4036
+ { media_id: 'med_ask_2', alt: 'Second option' },
4037
+ ])
4038
+ expect(pending?.source).toMatchObject({
4039
+ session_id: 'codex-media-thread',
4040
+ harness: 'codex',
4041
+ })
3735
4042
  })
3736
4043
 
3737
4044
  // A harness exports its markers into everything it starts, so a nested
@@ -3763,7 +4070,10 @@ describe('asking before the hooks have ever run', () => {
3763
4070
  io.outLines = []
3764
4071
 
3765
4072
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3766
- expect(readSessionState('codex-current-thread', env).pending?.[0]?.question).toBe('Ship it?')
4073
+ expect(readSessionState('codex-current-thread', env).pending?.[0]).toMatchObject({
4074
+ question: 'Ship it?',
4075
+ source: { session_id: 'codex-current-thread', harness: 'codex' },
4076
+ })
3767
4077
  expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
3768
4078
  })
3769
4079
 
@@ -3792,7 +4102,10 @@ describe('asking before the hooks have ever run', () => {
3792
4102
  io.outLines = []
3793
4103
 
3794
4104
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3795
- expect(readSessionState('claude-current', env).pending?.[0]?.question).toBe('Ship it?')
4105
+ expect(readSessionState('claude-current', env).pending?.[0]).toMatchObject({
4106
+ question: 'Ship it?',
4107
+ source: { session_id: 'claude-current', harness: 'claude-code' },
4108
+ })
3796
4109
  expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
3797
4110
  })
3798
4111
 
@@ -3944,7 +4257,7 @@ describe('asking before the hooks have ever run', () => {
3944
4257
 
3945
4258
  io.outLines = []
3946
4259
  await doctorCommand(deps, {})
3947
- expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(trust\).*Stop/is)
4260
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
3948
4261
  expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
3949
4262
  })
3950
4263
 
@@ -4197,7 +4510,7 @@ describe('asking before the hooks have ever run', () => {
4197
4510
  io.outLines = []
4198
4511
 
4199
4512
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
4200
- expect(io.outLines.join('\n')).toMatch(/FAIL\s+Question routing:.*active Claude Code/is)
4513
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+Routing for this harness.*active Claude Code/is)
4201
4514
  })
4202
4515
 
4203
4516
  it('treats an unfired pointer as informational, with a prompt as the remedy', async () => {
@@ -4266,7 +4579,7 @@ describe('asking before the hooks have ever run', () => {
4266
4579
  io.outLines = []
4267
4580
 
4268
4581
  await doctorCommand(deps, {})
4269
- expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(stop shape\)/)
4582
+ expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
4270
4583
  })
4271
4584
 
4272
4585
  it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
@@ -4295,7 +4608,7 @@ describe('asking before the hooks have ever run', () => {
4295
4608
 
4296
4609
  await doctorCommand(deps, {})
4297
4610
  const out = io.outLines.join('\n')
4298
- expect(out).toMatch(/FAIL\s+hooks \(stop shape\)/)
4611
+ expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
4299
4612
  expect(out).toContain('needs `async: true`')
4300
4613
  expect(out).toContain('kills the backgrounded waiter at its 600s default')
4301
4614
  })
@@ -4338,7 +4651,7 @@ describe('asking before the hooks have ever run', () => {
4338
4651
 
4339
4652
  await doctorCommand(deps, {})
4340
4653
  const out = io.outLines.join('\n')
4341
- expect(out).toMatch(/ok\s+hooks \(wake route\)/)
4654
+ expect(out).toMatch(/ok\s+Direct wake route/)
4342
4655
  expect(out).toContain(socket)
4343
4656
  expect(out).toContain('an answer can start a turn here without you')
4344
4657
  // Doctor never widens the user's inbound policy to make Notifai work: the
@@ -4355,10 +4668,10 @@ describe('asking before the hooks have ever run', () => {
4355
4668
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
4356
4669
  const out = io.outLines.join('\n')
4357
4670
  // Honest, and not a blocker: nothing is lost, it simply arrives later.
4358
- expect(out).toMatch(/--\s+hooks \(wake route\)/)
4671
+ expect(out).toMatch(/--\s+Direct wake route/)
4359
4672
  expect(out).toContain('`--bare` binds no inbox socket')
4360
4673
  expect(out).toContain("at this session's next turn")
4361
- expect(out).not.toMatch(/FAIL\s+hooks \(wake route\)/)
4674
+ expect(out).not.toMatch(/FAIL\s+Direct wake route/)
4362
4675
  })
4363
4676
 
4364
4677
  it('fails closed on an inbox protocol it does not recognise', async () => {
@@ -4406,7 +4719,7 @@ describe('asking before the hooks have ever run', () => {
4406
4719
  io.outLines = []
4407
4720
  await doctorCommand(deps, {})
4408
4721
  const out = io.outLines.join('\n')
4409
- expect(out).toMatch(/ok\s+hooks \(wake route\)/)
4722
+ expect(out).toMatch(/ok\s+Direct wake route/)
4410
4723
  expect(out).toContain('can prove a stopped thread unowned before resuming it')
4411
4724
  })
4412
4725
 
@@ -4566,6 +4879,66 @@ describe('a server behind this CLI', () => {
4566
4879
  })
4567
4880
  })
4568
4881
 
4882
+ /**
4883
+ * The same class of failure through the command path. A rejected `send` printed
4884
+ * only "invalid_request: The draft was not accepted." while the server's
4885
+ * details — naming the exact offending field — went to the local log alone.
4886
+ * stderr is the one line an agent's next turn actually reads, so the diagnosis
4887
+ * has to be on it. Direction-neutral on purpose: the mismatch has been seen
4888
+ * with the server behind the CLI and with the CLI behind the server.
4889
+ */
4890
+ describe('command failures carrying server details', () => {
4891
+ const throwingClient = (status: number, details: unknown = null): ApiClient =>
4892
+ ({
4893
+ submit: async () => {
4894
+ throw new ApiCallError(status, 'invalid_request', 'The draft was not accepted.', null, details)
4895
+ },
4896
+ }) as unknown as ApiClient
4897
+
4898
+ it('names the rejected field and the contract mismatch on a 422', async () => {
4899
+ const io = new CapturedIo()
4900
+ const client = throwingClient(422, [
4901
+ { code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
4902
+ ])
4903
+
4904
+ expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
4905
+ EXIT.failed,
4906
+ )
4907
+
4908
+ const said = io.errLines.join(' ')
4909
+ expect(said).toContain('the server rejected: presentation.detail')
4910
+ expect(said).toMatch(/sent a field the server did not accept/)
4911
+ expect(said).toContain('notifai doctor')
4912
+ expect(said).not.toMatch(/older than this CLI/)
4913
+ })
4914
+
4915
+ it('prints the rejected paths without the contract line when the status is not 422', async () => {
4916
+ const io = new CapturedIo()
4917
+ const client = throwingClient(400, [
4918
+ { code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
4919
+ ])
4920
+
4921
+ expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
4922
+ EXIT.failed,
4923
+ )
4924
+
4925
+ const said = io.errLines.join(' ')
4926
+ expect(said).toContain('the server rejected: presentation.detail')
4927
+ expect(said).not.toContain('notifai doctor')
4928
+ expect(said).not.toMatch(/did not accept/)
4929
+ })
4930
+
4931
+ it('prints exactly the code and message when the error carries no details', async () => {
4932
+ const io = new CapturedIo()
4933
+
4934
+ expect(await sendCommand(makeDeps(io, throwingClient(409)), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
4935
+ EXIT.failed,
4936
+ )
4937
+
4938
+ expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
4939
+ })
4940
+ })
4941
+
4569
4942
  describe('question sets', () => {
4570
4943
  it('maps --reply-multi into the single question', async () => {
4571
4944
  const io = new CapturedIo()
@@ -4595,26 +4968,76 @@ describe('question sets', () => {
4595
4968
  expect(submitted?.draft.reply?.questions?.[0]?.choices).toHaveLength(3)
4596
4969
  })
4597
4970
 
4598
- it('rejects a question body too long for the answering surface', async () => {
4971
+ it('derives the answerable question from the first banner block', async () => {
4599
4972
  const io = new CapturedIo()
4600
- let submitCalls = 0
4973
+ let submitted: SubmitNotificationRequestT | undefined
4601
4974
  const client = {
4602
- submit: async () => {
4603
- submitCalls += 1
4975
+ submit: async (body: SubmitNotificationRequestT) => {
4976
+ submitted = body
4604
4977
  return receipt
4605
4978
  },
4979
+ replies: async () => replyResponse([reply]),
4606
4980
  } as unknown as ApiClient
4607
4981
 
4982
+ const body = `Which environment?\n\n${'Long Markdown context. '.repeat(30)}`
4608
4983
  expect(
4609
4984
  await sendCommand(makeDeps(io, client), {
4610
- title: 'Question',
4611
- body: 'x'.repeat(501),
4985
+ title: 'Choose the deployment environment',
4986
+ body,
4612
4987
  reply: true,
4613
4988
  replyTimeout: 30,
4614
4989
  }),
4615
- ).toBe(EXIT.usage)
4616
- expect(submitCalls).toBe(0)
4617
- expect(io.errLines.join(' ')).toContain('--detail')
4990
+ ).toBe(EXIT.ok)
4991
+ expect(submitted?.draft.presentation.body).toBe(body)
4992
+ expect(submitted?.draft.reply?.questions[0]?.text).toBe('Which environment?')
4993
+ })
4994
+
4995
+ it('composes one question before optional Markdown context', () => {
4996
+ expect(
4997
+ buildQuestions(
4998
+ { body: '## Why\nThe release window closes today.' },
4999
+ 'Which environment?',
5000
+ ),
5001
+ ).toMatchObject({
5002
+ ok: true,
5003
+ body: 'Which environment?\n\n## Why\nThe release window closes today.',
5004
+ questions: [{ text: 'Which environment?' }],
5005
+ })
5006
+ })
5007
+
5008
+ it('composes numbered form questions before form context', () => {
5009
+ expect(
5010
+ buildQuestions(
5011
+ {
5012
+ form: JSON.stringify({
5013
+ questions: [{ text: 'Deploy where?' }, { text: 'What should I monitor?' }],
5014
+ body: '## Context\nTraffic is elevated.',
5015
+ }),
5016
+ },
5017
+ undefined,
5018
+ ),
5019
+ ).toMatchObject({
5020
+ ok: true,
5021
+ body:
5022
+ '1. Deploy where?\n2. What should I monitor?\n\n## Context\nTraffic is elevated.',
5023
+ })
5024
+ })
5025
+
5026
+ it('rejects the deleted form detail key instead of treating it as context', () => {
5027
+ expect(
5028
+ buildQuestions(
5029
+ {
5030
+ form: JSON.stringify({
5031
+ questions: [{ text: 'Deploy?' }],
5032
+ detail: 'legacy context',
5033
+ }),
5034
+ },
5035
+ undefined,
5036
+ ),
5037
+ ).toMatchObject({
5038
+ ok: false,
5039
+ error: expect.stringContaining('Unknown --form key: detail'),
5040
+ })
4618
5041
  })
4619
5042
 
4620
5043
  it('generates unique question ids when texts collide', () => {
@@ -4632,6 +5055,17 @@ describe('question sets', () => {
4632
5055
  }
4633
5056
  })
4634
5057
 
5058
+ it('reports a registration as data, with the ids an agent must branch on', () => {
5059
+ // The prose block is the densest guidance this CLI prints, but a choice id
5060
+ // cannot be read back out of prose.
5061
+ const built = buildQuestions(
5062
+ { choice: ['Deploy now', 'Hold'] },
5063
+ 'Deploy migration 0007 to production?',
5064
+ )
5065
+ if (!built.ok) throw new Error(built.error)
5066
+ expect(built.questions[0]?.choices?.map((choice) => choice.id)).toEqual(['deploy-now', 'hold'])
5067
+ })
5068
+
4635
5069
  it('rejects forms outside the documented shape', () => {
4636
5070
  expect(buildQuestions({ form: 'not json' }, undefined)).toMatchObject({ ok: false })
4637
5071
  expect(
@@ -4639,10 +5073,14 @@ describe('question sets', () => {
4639
5073
  ).toMatchObject({ ok: false })
4640
5074
  expect(
4641
5075
  buildQuestions(
4642
- { form: JSON.stringify({ questions: Array.from({ length: 5 }, (_, i) => ({ text: `Q${i}?` })) }) },
5076
+ {
5077
+ form: JSON.stringify({
5078
+ questions: Array.from({ length: REPLY_MAX_QUESTIONS + 1 }, (_, i) => ({ text: `Q${i}?` })),
5079
+ }),
5080
+ },
4643
5081
  undefined,
4644
5082
  ),
4645
- ).toMatchObject({ ok: false, error: expect.stringContaining('1-4') })
5083
+ ).toMatchObject({ ok: false, error: expect.stringContaining(`1-${REPLY_MAX_QUESTIONS}`) })
4646
5084
  expect(
4647
5085
  buildQuestions(
4648
5086
  { form: JSON.stringify({ questions: [{ text: 'Pick?', multi: true }] }) },