@raidiant/notifai 4.0.0 → 6.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 (140) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +7 -2
  3. package/dist/client.d.ts +8 -3
  4. package/dist/client.js +24 -3
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands-auth.d.ts +14 -0
  7. package/dist/commands-auth.js +154 -0
  8. package/dist/commands-auth.js.map +1 -0
  9. package/dist/commands-config.d.ts +31 -0
  10. package/dist/commands-config.js +246 -0
  11. package/dist/commands-config.js.map +1 -0
  12. package/dist/commands-core.d.ts +102 -0
  13. package/dist/commands-core.js +135 -0
  14. package/dist/commands-core.js.map +1 -0
  15. package/dist/commands-devices.d.ts +31 -0
  16. package/dist/commands-devices.js +124 -0
  17. package/dist/commands-devices.js.map +1 -0
  18. package/dist/commands-doctor.d.ts +24 -0
  19. package/dist/commands-doctor.js +1011 -0
  20. package/dist/commands-doctor.js.map +1 -0
  21. package/dist/commands-harness-context.d.ts +61 -0
  22. package/dist/commands-harness-context.js +93 -0
  23. package/dist/commands-harness-context.js.map +1 -0
  24. package/dist/commands-hook-shape.d.ts +17 -0
  25. package/dist/commands-hook-shape.js +37 -0
  26. package/dist/commands-hook-shape.js.map +1 -0
  27. package/dist/commands-hooks.d.ts +115 -0
  28. package/dist/commands-hooks.js +1225 -0
  29. package/dist/commands-hooks.js.map +1 -0
  30. package/dist/commands-init.d.ts +35 -0
  31. package/dist/commands-init.js +580 -0
  32. package/dist/commands-init.js.map +1 -0
  33. package/dist/commands-io.d.ts +2 -0
  34. package/dist/commands-io.js +110 -0
  35. package/dist/commands-io.js.map +1 -0
  36. package/dist/commands-logs.d.ts +30 -0
  37. package/dist/commands-logs.js +201 -0
  38. package/dist/commands-logs.js.map +1 -0
  39. package/dist/commands-send-support.d.ts +44 -0
  40. package/dist/commands-send-support.js +180 -0
  41. package/dist/commands-send-support.js.map +1 -0
  42. package/dist/commands-send.d.ts +33 -0
  43. package/dist/commands-send.js +519 -0
  44. package/dist/commands-send.js.map +1 -0
  45. package/dist/commands-setup-proof.d.ts +15 -0
  46. package/dist/commands-setup-proof.js +53 -0
  47. package/dist/commands-setup-proof.js.map +1 -0
  48. package/dist/commands-skill.d.ts +10 -0
  49. package/dist/commands-skill.js +71 -0
  50. package/dist/commands-skill.js.map +1 -0
  51. package/dist/commands.d.ts +12 -378
  52. package/dist/commands.js +12 -4420
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +15 -6
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +4 -2
  57. package/dist/config.js +13 -0
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.js +13 -3
  60. package/dist/credentials.js.map +1 -1
  61. package/dist/file-lock.d.ts +6 -1
  62. package/dist/file-lock.js +55 -9
  63. package/dist/file-lock.js.map +1 -1
  64. package/dist/harnesses.d.ts +1 -1
  65. package/dist/harnesses.js.map +1 -1
  66. package/dist/hooks.d.ts +74 -7
  67. package/dist/hooks.js +522 -166
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/install-hooks.d.ts +91 -14
  70. package/dist/install-hooks.js +226 -34
  71. package/dist/install-hooks.js.map +1 -1
  72. package/dist/interactive.d.ts +1 -1
  73. package/dist/interactive.js +5 -5
  74. package/dist/interactive.js.map +1 -1
  75. package/dist/logging.d.ts +8 -0
  76. package/dist/logging.js +9 -0
  77. package/dist/logging.js.map +1 -1
  78. package/dist/main.js +45 -18
  79. package/dist/main.js.map +1 -1
  80. package/dist/opencode-plugin.d.ts +8 -0
  81. package/dist/opencode-plugin.js +24 -162
  82. package/dist/opencode-plugin.js.map +1 -1
  83. package/dist/readiness.d.ts +3 -1
  84. package/dist/readiness.js.map +1 -1
  85. package/dist/send.d.ts +6 -0
  86. package/dist/send.js +25 -14
  87. package/dist/send.js.map +1 -1
  88. package/dist/ui/help.d.ts +0 -1
  89. package/dist/ui/help.js +6 -3
  90. package/dist/ui/help.js.map +1 -1
  91. package/dist/ui/theme.d.ts +0 -15
  92. package/dist/ui/theme.js +2 -18
  93. package/dist/ui/theme.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/client.test.ts +90 -1
  96. package/src/client.ts +35 -2
  97. package/src/commands-auth.ts +174 -0
  98. package/src/commands-config.ts +307 -0
  99. package/src/commands-core.ts +240 -0
  100. package/src/commands-devices.ts +150 -0
  101. package/src/commands-doctor.ts +1209 -0
  102. package/src/commands-harness-context.ts +126 -0
  103. package/src/commands-hook-shape.ts +53 -0
  104. package/src/commands-hooks.ts +1586 -0
  105. package/src/commands-init.ts +721 -0
  106. package/src/commands-io.ts +108 -0
  107. package/src/commands-logs.ts +243 -0
  108. package/src/commands-send-support.ts +224 -0
  109. package/src/commands-send.ts +623 -0
  110. package/src/commands-setup-proof.ts +71 -0
  111. package/src/commands-skill.ts +84 -0
  112. package/src/commands.test.ts +677 -89
  113. package/src/commands.ts +58 -5472
  114. package/src/config-schema.test.ts +1 -1
  115. package/src/config-schema.ts +17 -6
  116. package/src/config.test.ts +50 -9
  117. package/src/config.ts +19 -1
  118. package/src/credentials.test.ts +25 -1
  119. package/src/credentials.ts +13 -3
  120. package/src/file-lock.test.ts +41 -0
  121. package/src/file-lock.ts +59 -9
  122. package/src/harnesses.test.ts +37 -29
  123. package/src/harnesses.ts +1 -4
  124. package/src/hooks.test.ts +703 -26
  125. package/src/hooks.ts +590 -182
  126. package/src/install-hooks.test.ts +232 -12
  127. package/src/install-hooks.ts +241 -37
  128. package/src/interactive.test.ts +19 -1
  129. package/src/interactive.ts +5 -5
  130. package/src/logging-process-worker.test.ts +8 -2
  131. package/src/logging.test.ts +48 -20
  132. package/src/logging.ts +12 -0
  133. package/src/main.ts +58 -24
  134. package/src/opencode-plugin.ts +24 -164
  135. package/src/question-caps.test.ts +49 -0
  136. package/src/readiness.ts +3 -1
  137. package/src/send.ts +32 -16
  138. package/src/skill.test.ts +199 -146
  139. package/src/ui/help.ts +8 -3
  140. package/src/ui/theme.ts +2 -16
@@ -1,3 +1,4 @@
1
+ import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
1
2
  import {
2
3
  existsSync,
3
4
  mkdirSync,
@@ -11,14 +12,19 @@ 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,
18
+ CompatibilityResponse,
16
19
  EvidenceSnapshot,
17
20
  ListRepliesResponse,
21
+ Platform,
18
22
  ReplyView,
19
23
  SubmissionReceipt,
20
24
  SubmitNotificationRequestT,
25
+ SupportAssessment,
21
26
  } from '@raidiant/notifai-protocol'
27
+ import { parse as parseToml } from 'smol-toml'
22
28
  import { afterEach, describe, expect, it } from 'vitest'
23
29
  import { ApiCallError, NetworkError, type ApiClient } from './client.js'
24
30
  import type { ClaudeWakeAdapters } from './claude-wake.js'
@@ -83,6 +89,71 @@ const RELEASE_REF = `v${
83
89
  ).version
84
90
  }`
85
91
 
92
+ const currentSupport: SupportAssessment = {
93
+ state: 'current',
94
+ reason: 'current',
95
+ affected_operation: null,
96
+ recovery_action: null,
97
+ current_version: '5.0.0',
98
+ current_build: null,
99
+ recommended_version: '5.0.0',
100
+ recommended_build: null,
101
+ minimum_version: null,
102
+ minimum_build: null,
103
+ deprecation: null,
104
+ sunset: null,
105
+ }
106
+
107
+ const currentCompatibility: CompatibilityResponse = {
108
+ cli: currentSupport,
109
+ platforms: [
110
+ {
111
+ platform: 'ios',
112
+ recommended_version: null,
113
+ recommended_build: null,
114
+ minimum_receive_build: null,
115
+ minimum_answer_build: null,
116
+ deprecation: null,
117
+ sunset: null,
118
+ replacement_available: false,
119
+ rollout_complete: false,
120
+ },
121
+ {
122
+ platform: 'macos',
123
+ recommended_version: null,
124
+ recommended_build: null,
125
+ minimum_receive_build: null,
126
+ minimum_answer_build: null,
127
+ deprecation: null,
128
+ sunset: null,
129
+ replacement_available: false,
130
+ rollout_complete: false,
131
+ },
132
+ ],
133
+ server_capabilities: ['answer', 'agent_acknowledgement'],
134
+ }
135
+
136
+ function compatibilityWithCli(
137
+ overrides: Partial<SupportAssessment>,
138
+ ): CompatibilityResponse {
139
+ return {
140
+ ...currentCompatibility,
141
+ cli: { ...currentSupport, ...overrides },
142
+ }
143
+ }
144
+
145
+ function withCompatibilityDefaults(client: ApiClient): ApiClient {
146
+ return {
147
+ capabilities: async (platform: Platform = 'ios') => {
148
+ const document = CAPABILITIES_V1.describe(platform)
149
+ if (document === null) throw new Error(`missing ${platform} capability document`)
150
+ return document
151
+ },
152
+ compatibility: async () => currentCompatibility,
153
+ ...client,
154
+ } as ApiClient
155
+ }
156
+
86
157
  class CapturedIo implements CommandIo {
87
158
  outLines: string[] = []
88
159
  errLines: string[] = []
@@ -105,6 +176,10 @@ class CapturedIo implements CommandIo {
105
176
  }
106
177
  }
107
178
 
179
+ class PlainInteractiveIo extends CapturedIo {
180
+ interactive = true
181
+ }
182
+
108
183
  class InteractiveIo extends CapturedIo {
109
184
  interactive = true
110
185
  selectAnswer: string | null = 'global'
@@ -232,7 +307,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
232
307
  },
233
308
  hookAdapterHome: path.join(testRoot, 'home'),
234
309
  cwd: os.tmpdir(),
235
- clientFactory: () => client,
310
+ clientFactory: () => withCompatibilityDefaults(client),
236
311
  }
237
312
  }
238
313
 
@@ -261,6 +336,7 @@ const receipt: SubmissionReceipt = {
261
336
  request_id: 'req_reply_test',
262
337
  reply_expires_at: '2026-08-02T18:00:00.000Z',
263
338
  agent_acknowledgement_required: true,
339
+ agent_acknowledgement_text_required: true,
264
340
  replayed: false,
265
341
  overall: 'provider_accepted_all',
266
342
  deliveries: [
@@ -293,6 +369,7 @@ function replyResponse(
293
369
  replies: ReplyView[] = [],
294
370
  options: {
295
371
  required?: boolean
372
+ textRequired?: boolean
296
373
  acknowledgement?: ListRepliesResponse['agent_acknowledgement']
297
374
  } = {},
298
375
  ): ListRepliesResponse {
@@ -300,6 +377,7 @@ function replyResponse(
300
377
  request_id: receipt.request_id,
301
378
  reply_expires_at: '2026-08-02T18:00:00.000Z',
302
379
  agent_acknowledgement_required: options.required ?? true,
380
+ agent_acknowledgement_text_required: options.textRequired ?? true,
303
381
  agent_acknowledgement: options.acknowledgement ?? null,
304
382
  replies,
305
383
  }
@@ -378,7 +456,7 @@ describe('command contracts', () => {
378
456
  } as unknown as ApiClient
379
457
 
380
458
  expect(
381
- await sendCommand(makeDeps(io, client), {
459
+ await sendCommand(makeDeps(io, client), { kind: 'update',
382
460
  title: 'T',
383
461
  body: 'B',
384
462
  project: 'Invalid Project!',
@@ -456,7 +534,7 @@ describe('command contracts', () => {
456
534
  } as unknown as ApiClient
457
535
 
458
536
  expect(
459
- await sendCommand(makeDeps(new CapturedIo(), client), {
537
+ await sendCommand(makeDeps(new CapturedIo(), client), { kind: 'update',
460
538
  title: 'Visual comparison is ready',
461
539
  body:
462
540
  '![first](media:1) ![second](media:2) ![ready](media:med_existing)',
@@ -488,7 +566,7 @@ describe('command contracts', () => {
488
566
  } as unknown as ApiClient
489
567
 
490
568
  expect(
491
- await sendCommand(makeDeps(io, client), {
569
+ await sendCommand(makeDeps(io, client), { kind: 'update',
492
570
  title: 'Visual comparison',
493
571
  body: 'See it.',
494
572
  image: ['one.png'],
@@ -523,19 +601,19 @@ describe('command contracts', () => {
523
601
 
524
602
  it.each([
525
603
  {
526
- flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
604
+ flags: { kind: 'update', title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
527
605
  warning: /titles work best around 40/i,
528
606
  },
529
607
  {
530
- flags: { title: 'Done · build', body: 'All green.' },
608
+ flags: { kind: 'done', title: 'Done · build', body: 'All green.' },
531
609
  warning: /keep the title to the specific substance/i,
532
610
  },
533
611
  {
534
- flags: { title: 'Failed · build', body: 'One integration test failed.' },
612
+ flags: { kind: 'failed', title: 'Failed · build', body: 'One integration test failed.' },
535
613
  warning: /Put notification type in --kind/i,
536
614
  },
537
615
  {
538
- flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
616
+ flags: { kind: 'update', title: 'Update', body: 'Still relevant.', ttl: 259_201 },
539
617
  warning: /longer than 72 hours/i,
540
618
  },
541
619
  ])('warns without rejecting a send: $warning', async ({ flags, warning }) => {
@@ -559,7 +637,7 @@ describe('command contracts', () => {
559
637
  cwd: root,
560
638
  }
561
639
 
562
- expect(await sendCommand(deps, { title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
640
+ expect(await sendCommand(deps, { kind: 'update', title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
563
641
  expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
564
642
  })
565
643
 
@@ -579,11 +657,11 @@ describe('command contracts', () => {
579
657
  expect(grammar).toContain("'--image <path|url|media_id>'")
580
658
  expect(grammar).toContain("'--image-alt <text>'")
581
659
  }
582
- expect(source).toContain('Kind describes status in the Companion Apps')
583
- expect(source).toContain('it never chooses banner sound or interruption level')
584
- expect(source).toContain('override saved sound')
660
+ // Kind now selects the sound, so the help must say so — and must not carry
661
+ // the retired separation it replaced.
662
+ expect(source).toContain('Kind is required, and it selects the sound')
663
+ expect(source).not.toContain('it never chooses banner sound or interruption level')
585
664
  expect(source).not.toContain('Kind profiles apply automatically')
586
- expect(source).not.toContain('sound alert level active')
587
665
  })
588
666
 
589
667
  it('rejects a question nobody will wait for', async () => {
@@ -662,8 +740,8 @@ describe('command contracts', () => {
662
740
  })
663
741
 
664
742
  it.each([
665
- { title: 'Deploy? ', body: 'Ready.' },
666
- { title: 'Deployment', body: 'Should I deploy?\n' },
743
+ { kind: 'update', title: 'Deploy? ', body: 'Ready.' },
744
+ { kind: 'update', title: 'Deployment', body: 'Should I deploy?\n' },
667
745
  ])('warns on stderr when $title / $body ends in a question after trimming', async (flags) => {
668
746
  const io = new CapturedIo()
669
747
  const client = { submit: async () => receipt } as unknown as ApiClient
@@ -703,7 +781,7 @@ describe('command contracts', () => {
703
781
  } as unknown as ApiClient
704
782
 
705
783
  expect(
706
- await sendCommand(makeDeps(io, client), {
784
+ await sendCommand(makeDeps(io, client), { kind: 'update',
707
785
  title: 'Deploy?',
708
786
  body: 'Choose when ready.',
709
787
  replyChoice: ['Now', 'Later'],
@@ -720,7 +798,7 @@ describe('command contracts', () => {
720
798
  const client = { submit: async () => receipt } as unknown as ApiClient
721
799
 
722
800
  expect(
723
- await sendCommand(makeDeps(io, client), {
801
+ await sendCommand(makeDeps(io, client), { kind: 'update',
724
802
  title: 'Deployment',
725
803
  body: 'Should I deploy?',
726
804
  json: true,
@@ -942,6 +1020,7 @@ describe('command contracts', () => {
942
1020
  reply_expires_at: '2026-08-02T18:00:00.000Z',
943
1021
  replies: [],
944
1022
  agent_acknowledgement_required: true,
1023
+ agent_acknowledgement_text_required: true,
945
1024
  agent_acknowledgement: null,
946
1025
  acknowledgement_command: null,
947
1026
  degraded: false,
@@ -975,6 +1054,7 @@ describe('command contracts', () => {
975
1054
  reply_expires_at: '2026-08-02T18:00:00.000Z',
976
1055
  replies: [reply],
977
1056
  agent_acknowledgement_required: true,
1057
+ agent_acknowledgement_text_required: true,
978
1058
  agent_acknowledgement: null,
979
1059
  acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
980
1060
  degraded: false,
@@ -1088,9 +1168,11 @@ describe('command contracts', () => {
1088
1168
  })
1089
1169
 
1090
1170
  it.each([
1091
- { text: undefined, message: '--text is required' },
1092
- { text: ' ', message: '--text is required' },
1093
- { text: 'x'.repeat(1001), message: 'at most 1000 characters' },
1171
+ { text: ' ', message: 'must contain non-whitespace text' },
1172
+ {
1173
+ text: 'x'.repeat(AGENT_ACKNOWLEDGEMENT_MAX_LENGTH + 1),
1174
+ message: `at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters`,
1175
+ },
1094
1176
  ])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
1095
1177
  const io = new CapturedIo()
1096
1178
  let calls = 0
@@ -1108,6 +1190,60 @@ describe('command contracts', () => {
1108
1190
  expect(io.errLines.join('\n')).toContain(message)
1109
1191
  })
1110
1192
 
1193
+ it('records an acknowledgement with no text at all', async () => {
1194
+ const io = new CapturedIo()
1195
+ let submitted: { requestId: string; body: { text?: string } } | undefined
1196
+ const client = {
1197
+ putAgentAcknowledgement: async (requestId: string, body: { text?: string }) => {
1198
+ submitted = { requestId, body }
1199
+ return {
1200
+ status: 'recorded' as const,
1201
+ agent_acknowledgement: { text: '', created_at: '2026-08-13T12:01:00.000Z' },
1202
+ }
1203
+ },
1204
+ } as unknown as ApiClient
1205
+
1206
+ expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
1207
+ // No empty string either: an omitted field is what "no text" means on the
1208
+ // wire, and the account's snapshot decides whether that was allowed.
1209
+ expect(submitted).toEqual({ requestId: receipt.request_id, body: {} })
1210
+ expect(io.outLines.join('\n')).toContain(`Agent Acknowledgement recorded for ${receipt.request_id}`)
1211
+ })
1212
+
1213
+ it('names the missing text, not a contract mismatch, when the account requires it', async () => {
1214
+ const io = new CapturedIo()
1215
+ const client = {
1216
+ putAgentAcknowledgement: async () => {
1217
+ throw new ApiCallError(
1218
+ 422,
1219
+ 'acknowledgement_text_required',
1220
+ 'This account requires Agent Acknowledgement text.',
1221
+ 'Re-send the acknowledgement with text saying what you will do because of the reply.',
1222
+ )
1223
+ },
1224
+ } as unknown as ApiClient
1225
+
1226
+ expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.failed)
1227
+ const err = io.errLines.join('\n')
1228
+ expect(err).toContain('acknowledgement_text_required')
1229
+ expect(err).toContain('next: Re-send the acknowledgement with text')
1230
+ // The contract held; only this body fell short of the account's choice.
1231
+ expect(err).not.toContain('disagree about the contract')
1232
+ })
1233
+
1234
+ it('asks for a text-free acknowledgement when the account turned text off', async () => {
1235
+ const io = new CapturedIo()
1236
+ const client = {
1237
+ replies: async () => replyResponse([reply], { textRequired: false }),
1238
+ } as unknown as ApiClient
1239
+
1240
+ expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
1241
+ const out = io.outLines.join('\n')
1242
+ expect(out).toContain('Agent Acknowledgement required.')
1243
+ expect(out).toContain(`notifai acknowledge ${receipt.request_id}\``)
1244
+ expect(out).not.toContain('--text')
1245
+ })
1246
+
1111
1247
  it.each([
1112
1248
  [new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
1113
1249
  [new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
@@ -1609,7 +1745,7 @@ describe('Cursor hook commands', () => {
1609
1745
 
1610
1746
  it('reports a native Cursor installation through doctor', async () => {
1611
1747
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-doctor-'))
1612
- const io = new CapturedIo()
1748
+ const io = new PlainInteractiveIo()
1613
1749
  const client = {
1614
1750
  health: async () => true,
1615
1751
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
@@ -1716,7 +1852,13 @@ describe('Codex hook representation', () => {
1716
1852
  expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
1717
1853
  })
1718
1854
 
1719
- it('installs into hooks.json when it alone already contains hooks', () => {
1855
+ /**
1856
+ * The foreign `hooks.json` stays byte-identical and Notifai lands inline.
1857
+ * Codex will now warn that the layer uses both representations; the install
1858
+ * output says why, because that warning is Codex reporting a merge, not a
1859
+ * fault, and a silent one would read as Notifai's bug.
1860
+ */
1861
+ it('leaves a foreign hooks.json alone and installs inline instead', () => {
1720
1862
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
1721
1863
  const layer = path.join(cwd, '.codex')
1722
1864
  const json = path.join(layer, 'hooks.json')
@@ -1734,11 +1876,17 @@ describe('Codex hook representation', () => {
1734
1876
 
1735
1877
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1736
1878
 
1737
- expect(readFileSync(toml, 'utf8')).toBe(beforeToml)
1738
- expect(readFileSync(json, 'utf8')).toContain('gdh-stop')
1739
- expect(readFileSync(json, 'utf8')).toContain('--owner notifai')
1740
- expect(io.outLines.join('\n')).toContain(json)
1741
- expect(io.outLines.join('\n')).not.toMatch(/loading hooks from both/i)
1879
+ const beforeJson = readFileSync(json, 'utf8')
1880
+ expect(beforeJson).toContain('gdh-stop')
1881
+ expect(beforeJson).not.toContain('--owner notifai')
1882
+ const after = readFileSync(toml, 'utf8')
1883
+ // The user's file arrives back as the file they wrote, plus our hooks.
1884
+ expect(after).toContain(beforeToml.trimEnd())
1885
+ expect(after).toContain('--owner notifai')
1886
+ expect(io.outLines.join('\n')).toContain(toml)
1887
+ expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
1888
+ expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
1889
+ expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
1742
1890
  })
1743
1891
 
1744
1892
  it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
@@ -1755,12 +1903,18 @@ describe('Codex hook representation', () => {
1755
1903
  expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
1756
1904
  })
1757
1905
 
1758
- it('does not migrate our hooks when both representations already exist', async () => {
1759
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-dual-'))
1906
+ /**
1907
+ * Notifai in one file, someone else's hooks in the other: every handler
1908
+ * fires once, so doctor has nothing to report and the foreign file is not
1909
+ * ours to rewrite. Doctor used to fail here — the check read "two files" as
1910
+ * "two Notifai copies" and pointed at configuration another program owns.
1911
+ */
1912
+ it('passes doctor when only the other representation is foreign', async () => {
1913
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-rep-'))
1760
1914
  const toml = writeInlineStop(cwd, 'gdh-stop')
1761
1915
  const json = path.join(cwd, '.codex', 'hooks.json')
1762
1916
  const env = isolatedEnv(cwd)
1763
- const io = new CapturedIo()
1917
+ const io = new PlainInteractiveIo()
1764
1918
  const client = {
1765
1919
  health: async () => false,
1766
1920
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
@@ -1776,12 +1930,12 @@ describe('Codex hook representation', () => {
1776
1930
  })
1777
1931
 
1778
1932
  io.outLines = []
1779
- expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1780
- const before = io.outLines.join('\n')
1781
- expect(before).toMatch(/hooks \(codex representation\)/)
1782
- expect(before).toMatch(/prefer a single representation/)
1783
- expect(before).toMatch(/Stop handlers will both (run|fire)/i)
1784
- expect(before).not.toContain('notifai hooks install --harness codex')
1933
+ await doctorCommand(deps, {})
1934
+ // Their assertions, this branch's check title.
1935
+ const report = io.outLines.join('\n')
1936
+ expect(report).not.toMatch(/FAIL {2}Codex hook representation/)
1937
+ expect(report).toMatch(/ok {4}Codex hook representation/)
1938
+ expect(report).toMatch(/Notifai will not modify it/)
1785
1939
 
1786
1940
  io.outLines = []
1787
1941
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
@@ -1789,7 +1943,7 @@ describe('Codex hook representation', () => {
1789
1943
  expect(readFileSync(json, 'utf8')).toContain('--owner notifai')
1790
1944
  expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
1791
1945
  expect(readFileSync(toml, 'utf8')).not.toContain('--owner notifai')
1792
- expect(io.outLines.join('\n')).toMatch(/loading hooks from both/i)
1946
+ expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
1793
1947
  expect(io.outLines.join('\n')).not.toMatch(/collaps/i)
1794
1948
 
1795
1949
  const installations = findInstallations(cwd, env, deps.hookAdapterHome).filter(
@@ -1798,6 +1952,44 @@ describe('Codex hook representation', () => {
1798
1952
  expect(installations).toHaveLength(1)
1799
1953
  expect(installations[0]?.file).toBe(json)
1800
1954
  })
1955
+
1956
+ it('fails doctor when Notifai itself is installed in both representations', async () => {
1957
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-dual-'))
1958
+ const json = path.join(cwd, '.codex', 'hooks.json')
1959
+ const env = isolatedEnv(cwd)
1960
+ const io = new CapturedIo()
1961
+ const client = {
1962
+ health: async () => false,
1963
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1964
+ listDevices: async () => ({ devices: [] }),
1965
+ } as unknown as ApiClient
1966
+ const deps = { ...makeDeps(io, client), cwd, env }
1967
+
1968
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1969
+ applyPlan(json, {
1970
+ hooks: buildHookConfig({
1971
+ adapterPath: hookAdapterPath(deps.hookAdapterHome),
1972
+ harness: 'codex',
1973
+ }),
1974
+ })
1975
+
1976
+ io.outLines = []
1977
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1978
+ const reported = io.outLines.join('\n')
1979
+ expect(reported).toMatch(/Codex hook representation/)
1980
+ expect(reported).toMatch(/installed in both/i)
1981
+ expect(reported).toMatch(/notifies twice per turn/)
1982
+ expect(reported).toMatch(/notifai hooks uninstall --harness codex/)
1983
+
1984
+ // The named remedy has to actually clear it, including the copy in the
1985
+ // file Notifai would not have chosen to write.
1986
+ io.outLines = []
1987
+ expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
1988
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1989
+ io.outLines = []
1990
+ await doctorCommand(deps, {})
1991
+ expect(io.outLines.join('\n')).not.toMatch(/Codex hook representation/)
1992
+ })
1801
1993
  })
1802
1994
 
1803
1995
  describe('harness activation guidance', () => {
@@ -1913,14 +2105,14 @@ describe('harness activation guidance', () => {
1913
2105
 
1914
2106
  io.outLines = []
1915
2107
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1916
- expect(io.outLines.join('\n')).toContain('hooks (answer continuation)')
2108
+ expect(io.outLines.join('\n')).toContain('How an answer returns')
1917
2109
  expect(io.outLines.join('\n')).toContain('no proven answer continuation')
1918
- expect(io.outLines.join('\n')).not.toContain('hooks (adapter)')
2110
+ expect(io.outLines.join('\n')).not.toContain('Hook adapter')
1919
2111
 
1920
2112
  writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
1921
2113
  io.outLines = []
1922
2114
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1923
- expect(io.outLines.join('\n')).toContain('hooks (adapter)')
2115
+ expect(io.outLines.join('\n')).toContain('Hook adapter')
1924
2116
  expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
1925
2117
  })
1926
2118
 
@@ -2019,7 +2211,7 @@ describe('stable hook installation', () => {
2019
2211
 
2020
2212
  io.outLines = []
2021
2213
  await doctorCommand(deps, {})
2022
- expect(io.outLines.join('\n')).not.toMatch(/hooks \(duplicates\)/)
2214
+ expect(io.outLines.join('\n')).not.toMatch(/Duplicate hook installs/)
2023
2215
 
2024
2216
  expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
2025
2217
  expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
@@ -2315,6 +2507,39 @@ describe('config surfaces', () => {
2315
2507
  expect(resolved.sound.source).toMatch(/^global:/)
2316
2508
  })
2317
2509
 
2510
+ it('round-trips unknown root and table keys through config set and unset', async () => {
2511
+ const io = new CapturedIo()
2512
+ const deps = configDeps(io)
2513
+ const configFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
2514
+ mkdirSync(path.dirname(configFile), { recursive: true })
2515
+ writeFileSync(
2516
+ configFile,
2517
+ [
2518
+ 'future_root = "keep-root"',
2519
+ 'wait_seconds = 20',
2520
+ '',
2521
+ '[future_table]',
2522
+ 'future_key = "keep-table"',
2523
+ '',
2524
+ ].join('\n'),
2525
+ )
2526
+
2527
+ expect(await configSetCommand(deps, 'sound', 'done', { yes: true })).toBe(EXIT.ok)
2528
+ expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
2529
+ future_root: 'keep-root',
2530
+ wait_seconds: 20,
2531
+ sound: 'done',
2532
+ future_table: { future_key: 'keep-table' },
2533
+ })
2534
+
2535
+ expect(await configUnsetCommand(deps, 'wait_seconds', { yes: true })).toBe(EXIT.ok)
2536
+ expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
2537
+ future_root: 'keep-root',
2538
+ sound: 'done',
2539
+ future_table: { future_key: 'keep-table' },
2540
+ })
2541
+ })
2542
+
2318
2543
  it('refuses to create a redundant machine override equal to the shipped default', async () => {
2319
2544
  const io = new CapturedIo()
2320
2545
  const deps = configDeps(io)
@@ -2519,7 +2744,7 @@ describe('interactive command UX', () => {
2519
2744
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2520
2745
  expect(io.intros).toEqual(['Notifai doctor'])
2521
2746
  expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
2522
- expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
2747
+ expect(io.checks.some((check) => check.message.startsWith('Notifai update:'))).toBe(false)
2523
2748
  expect(io.outLines).toEqual([])
2524
2749
 
2525
2750
  // Four tones, not two. A boolean has to round `optional-gap` and `unknown`
@@ -2528,7 +2753,6 @@ describe('interactive command UX', () => {
2528
2753
  const tone = (prefix: string): Tone | undefined =>
2529
2754
  io.checks.find((check) => check.message.startsWith(prefix))?.tone
2530
2755
  expect(tone('This machine:')).toBe('bad')
2531
- expect(tone('Protocol version:')).toBe('ok')
2532
2756
  // Never evaluated: the account cannot be checked without a credential.
2533
2757
  expect(tone('Account:')).toBe('pending')
2534
2758
  // Legitimately declined rather than broken.
@@ -2562,6 +2786,210 @@ describe('interactive command UX', () => {
2562
2786
  })
2563
2787
  })
2564
2788
 
2789
+ describe('compatibility-first update guidance', () => {
2790
+ const cases: Array<{
2791
+ name: string
2792
+ support: Partial<SupportAssessment>
2793
+ status: 'ready' | 'optional-gap' | 'gap'
2794
+ detail: string
2795
+ exit: number
2796
+ humanLines: string[]
2797
+ }> = [
2798
+ {
2799
+ name: 'current',
2800
+ support: {},
2801
+ status: 'ready',
2802
+ detail: 'Notifai can send notifications.',
2803
+ exit: EXIT.ok,
2804
+ humanLines: [],
2805
+ },
2806
+ {
2807
+ name: 'optional newer release',
2808
+ support: {
2809
+ state: 'update_available',
2810
+ reason: 'newer_release',
2811
+ recovery_action: 'update_cli',
2812
+ recommended_version: '6.0.0',
2813
+ },
2814
+ status: 'optional-gap',
2815
+ detail: 'A newer Notifai is available.',
2816
+ exit: EXIT.ok,
2817
+ humanLines: [
2818
+ 'A newer Notifai is available.',
2819
+ 'npm install -g @raidiant/notifai',
2820
+ ],
2821
+ },
2822
+ {
2823
+ name: 'scheduled Sunset',
2824
+ support: {
2825
+ state: 'update_available',
2826
+ reason: 'sunset_scheduled',
2827
+ recovery_action: 'update_cli',
2828
+ recommended_version: '6.0.0',
2829
+ deprecation: 'Tue, 18 Aug 2026 00:00:00 GMT',
2830
+ sunset: 'Tue, 15 Sep 2026 00:00:00 GMT',
2831
+ },
2832
+ status: 'optional-gap',
2833
+ detail: 'Update Notifai soon to keep sending notifications.',
2834
+ exit: EXIT.ok,
2835
+ humanLines: [
2836
+ 'Update Notifai soon to keep sending notifications.',
2837
+ 'npm install -g @raidiant/notifai',
2838
+ ],
2839
+ },
2840
+ {
2841
+ name: 'required update',
2842
+ support: {
2843
+ state: 'must_update',
2844
+ reason: 'minimum_not_met',
2845
+ affected_operation: 'send_notifications',
2846
+ recovery_action: 'update_cli',
2847
+ recommended_version: '6.0.0',
2848
+ minimum_version: '6.0.0',
2849
+ },
2850
+ status: 'gap',
2851
+ detail: "Notifai can't send notifications until you update.",
2852
+ exit: EXIT.failed,
2853
+ humanLines: [
2854
+ "Notifai can't send notifications until you update.",
2855
+ 'npm install -g @raidiant/notifai',
2856
+ ],
2857
+ },
2858
+ ]
2859
+
2860
+ it.each(cases)(
2861
+ 'maps $name policy to closed human copy and structured JSON',
2862
+ async ({ support, status, detail, exit, humanLines }) => {
2863
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-guidance-'))
2864
+ const client = {
2865
+ health: async () => true,
2866
+ compatibility: async () => compatibilityWithCli(support),
2867
+ listDevices: async () => ({ devices: [] }),
2868
+ accessStatus: async () => ({ email: 'user@example.test' }),
2869
+ } as unknown as ApiClient
2870
+ const humanIo = new PlainInteractiveIo()
2871
+ const deps = {
2872
+ ...makeDeps(humanIo, client),
2873
+ cwd,
2874
+ env: isolatedEnv(cwd),
2875
+ }
2876
+ const readiness = await assessReadiness(deps)
2877
+ const contract = readiness.states.find((state) => state.id === 'contract')
2878
+ expect(contract).toMatchObject({ status, detail })
2879
+
2880
+ expect(
2881
+ await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
2882
+ ).toBe(exit)
2883
+ expect(humanIo.outLines).toEqual(humanLines)
2884
+ expect(humanIo.errLines).toEqual([])
2885
+
2886
+ const jsonIo = new CapturedIo()
2887
+ expect(
2888
+ await doctorCommand(
2889
+ { ...deps, io: jsonIo },
2890
+ { json: true },
2891
+ { readiness: { states: [contract!] } },
2892
+ ),
2893
+ ).toBe(exit)
2894
+ expect(jsonIo.outLines).toHaveLength(1)
2895
+ const payload = JSON.parse(jsonIo.outLines[0] ?? '{}') as {
2896
+ ok: boolean
2897
+ exit_code: number
2898
+ states: Array<{
2899
+ status: string
2900
+ detail: string
2901
+ remedy?: { command?: string }
2902
+ }>
2903
+ }
2904
+ expect(payload).toMatchObject({
2905
+ ok: exit === EXIT.ok,
2906
+ exit_code: exit,
2907
+ states: [{ status, detail }],
2908
+ })
2909
+ expect(payload.states[0]?.remedy?.command ?? null).toBe(
2910
+ status === 'ready' ? null : 'npm install -g @raidiant/notifai',
2911
+ )
2912
+ expect(jsonIo.errLines).toEqual([])
2913
+
2914
+ const nonTtyIo = new CapturedIo()
2915
+ expect(
2916
+ await doctorCommand(
2917
+ { ...deps, io: nonTtyIo },
2918
+ {},
2919
+ { readiness: { states: [contract!] } },
2920
+ ),
2921
+ ).toBe(exit)
2922
+ expect(nonTtyIo.outLines).toHaveLength(1)
2923
+ expect(JSON.parse(nonTtyIo.outLines[0] ?? '{}')).toEqual(payload)
2924
+ expect(nonTtyIo.errLines).toEqual([])
2925
+ },
2926
+ )
2927
+
2928
+ it('reports a missing named CLI capability without blocking ordinary sends', async () => {
2929
+ const requestedPlatforms: Platform[] = []
2930
+ let submissions = 0
2931
+ const client = {
2932
+ health: async () => true,
2933
+ compatibility: async (): Promise<CompatibilityResponse> => ({
2934
+ ...currentCompatibility,
2935
+ // This service can still accept baseline Notification Requests, but it
2936
+ // cannot honor this CLI's Agent Acknowledgement feature yet.
2937
+ server_capabilities: ['answer'],
2938
+ }),
2939
+ capabilities: async (platform: Platform = 'ios') => {
2940
+ requestedPlatforms.push(platform)
2941
+ const document = CAPABILITIES_V1.describe(platform)
2942
+ if (document === null) throw new Error(`missing ${platform} capability document`)
2943
+ return document
2944
+ },
2945
+ listDevices: async () => ({ devices: [] }),
2946
+ accessStatus: async () => ({ email: 'user@example.test' }),
2947
+ submit: async () => {
2948
+ submissions += 1
2949
+ return receipt
2950
+ },
2951
+ } as unknown as ApiClient
2952
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-capability-guidance-'))
2953
+ const io = new PlainInteractiveIo()
2954
+ const deps = {
2955
+ ...makeDeps(io, client),
2956
+ cwd,
2957
+ env: isolatedEnv(cwd),
2958
+ }
2959
+
2960
+ const readiness = await assessReadiness(deps)
2961
+ const contract = readiness.states.find((state) => state.id === 'contract')
2962
+ expect(requestedPlatforms).toEqual([...PLATFORMS])
2963
+ expect(contract).toMatchObject({
2964
+ status: 'optional-gap',
2965
+ detail: 'The service is being updated; try again later.',
2966
+ technical: {
2967
+ capability_documents: PLATFORMS.map((platform) => ({ platform, schema_version: 1 })),
2968
+ cli_capability_intersection: {
2969
+ available: [],
2970
+ missing_on_server: ['agent_acknowledgement'],
2971
+ },
2972
+ },
2973
+ })
2974
+
2975
+ expect(
2976
+ await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
2977
+ ).toBe(EXIT.ok)
2978
+ expect(io.outLines).toEqual(['The service is being updated; try again later.'])
2979
+ expect(io.outLines.join('\n')).not.toContain('npm install -g @raidiant/notifai')
2980
+
2981
+ const sendIo = new CapturedIo()
2982
+ expect(
2983
+ await sendCommand(
2984
+ { ...deps, io: sendIo },
2985
+ { kind: 'update', title: 'Build finished', body: 'All checks passed.' },
2986
+ ),
2987
+ ).toBe(EXIT.ok)
2988
+ expect(submissions).toBe(1)
2989
+ expect(sendIo.errLines).toEqual([])
2990
+ })
2991
+ })
2992
+
2565
2993
  describe('init', () => {
2566
2994
  const readyIphone = {
2567
2995
  device_id: 'dev_iphone',
@@ -2569,9 +2997,22 @@ describe('init', () => {
2569
2997
  platform: 'ios' as const,
2570
2998
  permission_status: 'authorized',
2571
2999
  registration_healthy: true,
2572
- reply_protocol_version: 2,
3000
+ app_version: '0.1.0',
3001
+ app_build: '42',
3002
+ os_version: '19.0',
3003
+ capabilities: ['answer'] as const,
3004
+ support: currentSupport,
3005
+ support_state: 'current' as const,
3006
+ derived_status: 'working' as const,
3007
+ status_message: null,
2573
3008
  last_seen_at: '2026-08-05T18:00:00.000Z',
2574
3009
  }
3010
+ const readyMac = {
3011
+ ...readyIphone,
3012
+ device_id: 'dev_mac',
3013
+ display_name: 'Mac',
3014
+ platform: 'macos' as const,
3015
+ }
2575
3016
 
2576
3017
  function setupEvidence(
2577
3018
  requestId: string,
@@ -2697,6 +3138,58 @@ describe('init', () => {
2697
3138
  expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
2698
3139
  })
2699
3140
 
3141
+ it('resumes the same init after a required CLI update becomes current', async () => {
3142
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-init-update-resume-'))
3143
+ const io = new CapturedIo()
3144
+ let cliSupport = compatibilityWithCli({
3145
+ state: 'must_update',
3146
+ reason: 'minimum_not_met',
3147
+ affected_operation: 'send_notifications',
3148
+ recovery_action: 'update_cli',
3149
+ recommended_version: '6.0.0',
3150
+ minimum_version: '6.0.0',
3151
+ })
3152
+ let submissions = 0
3153
+ const client = {
3154
+ health: async () => true,
3155
+ compatibility: async () => cliSupport,
3156
+ listDevices: async () => ({ devices: [readyIphone] }),
3157
+ accessStatus: async () => ({ email: 'user@example.test' }),
3158
+ submit: async () => {
3159
+ submissions += 1
3160
+ return setupReceipt('req_after_update')
3161
+ },
3162
+ evidence: async () =>
3163
+ setupEvidence('req_after_update', {
3164
+ state: 'observed',
3165
+ observed_at: '2026-08-18T18:00:02.000Z',
3166
+ latency_ms: 1_000,
3167
+ }),
3168
+ } as unknown as ApiClient
3169
+ const deps = {
3170
+ ...makeDeps(io, client),
3171
+ cwd,
3172
+ env: isolatedEnv(cwd),
3173
+ }
3174
+
3175
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
3176
+ expect(io.outLines).toContain("Notifai can't send notifications until you update.")
3177
+ expect(io.outLines).toContain('npm install -g @raidiant/notifai')
3178
+ expect(submissions).toBe(0)
3179
+
3180
+ cliSupport = currentCompatibility
3181
+ io.outLines = []
3182
+ io.errLines = []
3183
+
3184
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
3185
+ expect(submissions).toBe(1)
3186
+ expect(io.outLines).toContain(
3187
+ 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
3188
+ )
3189
+ expect(io.outLines).not.toContain('npm install -g @raidiant/notifai')
3190
+ expect(io.errLines).toEqual([])
3191
+ })
3192
+
2700
3193
  it('surfaces one next step, not the whole remaining list', async () => {
2701
3194
  // The behavioural core of the design: someone handed five things to do
2702
3195
  // does none of them. Signing in gates the device check, so naming both
@@ -3078,7 +3571,7 @@ describe('init', () => {
3078
3571
  expect(out).toContain('Next: Your devices')
3079
3572
  expect(out).toContain('https://test.notifai.invalid/support')
3080
3573
  expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
3081
- expect(out).toContain('install Notifai on iPhone or Mac via https://test.notifai.invalid/support')
3574
+ expect(out).toContain('install Notifai on iPhone via https://test.notifai.invalid/support')
3082
3575
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3083
3576
  })
3084
3577
 
@@ -3127,7 +3620,7 @@ describe('init', () => {
3127
3620
  })
3128
3621
 
3129
3622
  it.each([
3130
- ['denied', 'system settings'],
3623
+ ['denied', 'iPhone Settings'],
3131
3624
  ['not_determined', 'allow its notification prompt'],
3132
3625
  ])('gives one permission-specific next action for %s', async (permission, expected) => {
3133
3626
  const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
@@ -3152,7 +3645,7 @@ describe('init', () => {
3152
3645
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3153
3646
  })
3154
3647
 
3155
- it('waits on the supported device registry, then ends with an observed real receipt', async () => {
3648
+ it('ignores a dormant Mac installation while waiting for an iPhone, then proves the iPhone receipt', async () => {
3156
3649
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
3157
3650
  const io = new InteractiveIo()
3158
3651
  let now = 0
@@ -3162,7 +3655,7 @@ describe('init', () => {
3162
3655
  const client = {
3163
3656
  health: async () => true,
3164
3657
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3165
- listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
3658
+ listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [readyMac] }),
3166
3659
  accessStatus: async () => ({
3167
3660
  status: 'active',
3168
3661
  reason: 'alpha_grant',
@@ -3199,6 +3692,7 @@ describe('init', () => {
3199
3692
  ])
3200
3693
  expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
3201
3694
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
3695
+ expect(io.spinnerEvents).toContain('message:Waiting for the iPhone app to sign in and register…')
3202
3696
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
3203
3697
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
3204
3698
  expect(io.outLines.join('\n')).toContain(
@@ -3389,7 +3883,7 @@ describe('init', () => {
3389
3883
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
3390
3884
  })
3391
3885
 
3392
- it('treats macOS-only delivery proof as an honest non-blocking caveat', async () => {
3886
+ it('does not treat a macOS-only installation as active-release readiness', async () => {
3393
3887
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
3394
3888
  const io = new CapturedIo()
3395
3889
  let submitCalls = 0
@@ -3397,7 +3891,7 @@ describe('init', () => {
3397
3891
  health: async () => true,
3398
3892
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3399
3893
  listDevices: async () => ({
3400
- devices: [{ ...readyIphone, device_id: 'dev_mac', display_name: 'Mac', platform: 'macos' }],
3894
+ devices: [readyMac],
3401
3895
  }),
3402
3896
  submit: async () => {
3403
3897
  submitCalls += 1
@@ -3410,16 +3904,16 @@ describe('init', () => {
3410
3904
  env: isolatedEnv(cwd),
3411
3905
  }
3412
3906
 
3413
- expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
3907
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
3414
3908
  expect(submitCalls).toBe(0)
3415
3909
  const out = io.outLines.join('\n')
3416
- expect(out).toContain('All set.')
3417
- expect(out).toContain('receipt proof needs an iPhone in this release')
3418
- expect(out).not.toContain('Next: Delivery proof')
3910
+ expect(out).toContain('Next: Your devices')
3911
+ expect(out).toContain('no iPhone registered yet')
3912
+ expect(out).not.toContain('All set.')
3419
3913
  expect(out).not.toMatch(/Companion Receipt observed/i)
3420
3914
 
3421
- // doctor must render the same non-blocking state (not FAIL / not a Next:).
3422
- const doctorIo = new CapturedIo()
3915
+ // A human doctor must render the same iPhone-readiness failure.
3916
+ const doctorIo = new PlainInteractiveIo()
3423
3917
  expect(
3424
3918
  await doctorCommand(
3425
3919
  {
@@ -3429,11 +3923,11 @@ describe('init', () => {
3429
3923
  },
3430
3924
  {},
3431
3925
  ),
3432
- ).toBe(EXIT.ok)
3926
+ ).toBe(EXIT.failed)
3433
3927
  const doctorOut = doctorIo.outLines.join('\n')
3434
- expect(doctorOut).toMatch(/--\s+Delivery proof:/)
3435
- expect(doctorOut).toContain('receipt proof needs an iPhone')
3436
- expect(doctorOut).not.toMatch(/FAIL\s+Delivery proof:/)
3928
+ expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
3929
+ expect(doctorOut).toContain('no iPhone registered yet')
3930
+ expect(doctorOut).toContain('Delivery proof: not checked — no iPhone is ready')
3437
3931
  })
3438
3932
 
3439
3933
  it('stops login when the approval page reports no Alpha access', async () => {
@@ -4109,7 +4603,7 @@ describe('asking before the hooks have ever run', () => {
4109
4603
 
4110
4604
  it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
4111
4605
  const cwd = scratchDir('notifai-codex-stale-trust-')
4112
- const io = new CapturedIo()
4606
+ const io = new PlainInteractiveIo()
4113
4607
  const env = {
4114
4608
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
4115
4609
  XDG_STATE_HOME: path.join(cwd, 'state'),
@@ -4138,7 +4632,7 @@ describe('asking before the hooks have ever run', () => {
4138
4632
 
4139
4633
  io.outLines = []
4140
4634
  await doctorCommand(deps, {})
4141
- expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(trust\).*Stop/is)
4635
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
4142
4636
  expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
4143
4637
  })
4144
4638
 
@@ -4374,7 +4868,7 @@ describe('asking before the hooks have ever run', () => {
4374
4868
 
4375
4869
  it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
4376
4870
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-doctor-'))
4377
- const io = new CapturedIo()
4871
+ const io = new PlainInteractiveIo()
4378
4872
  const env = {
4379
4873
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
4380
4874
  XDG_STATE_HOME: path.join(cwd, 'state'),
@@ -4391,7 +4885,7 @@ describe('asking before the hooks have ever run', () => {
4391
4885
  io.outLines = []
4392
4886
 
4393
4887
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
4394
- expect(io.outLines.join('\n')).toMatch(/FAIL\s+Question routing:.*active Claude Code/is)
4888
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+Routing for this harness.*active Claude Code/is)
4395
4889
  })
4396
4890
 
4397
4891
  it('treats an unfired pointer as informational, with a prompt as the remedy', async () => {
@@ -4434,7 +4928,7 @@ describe('asking before the hooks have ever run', () => {
4434
4928
  const localFile = personalProjectConfigPath(cwd, env)
4435
4929
  mkdirSync(path.dirname(localFile), { recursive: true })
4436
4930
  writeFileSync(localFile, 'ask_notifications = false\nask_grace_seconds = 90\n')
4437
- const io = new CapturedIo()
4931
+ const io = new PlainInteractiveIo()
4438
4932
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
4439
4933
 
4440
4934
  await doctorCommand(deps, {})
@@ -4450,7 +4944,7 @@ describe('asking before the hooks have ever run', () => {
4450
4944
 
4451
4945
  it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
4452
4946
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
4453
- const io = new CapturedIo()
4947
+ const io = new PlainInteractiveIo()
4454
4948
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
4455
4949
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
4456
4950
  EXIT.ok,
@@ -4460,12 +4954,12 @@ describe('asking before the hooks have ever run', () => {
4460
4954
  io.outLines = []
4461
4955
 
4462
4956
  await doctorCommand(deps, {})
4463
- expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(stop shape\)/)
4957
+ expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
4464
4958
  })
4465
4959
 
4466
4960
  it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
4467
4961
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
4468
- const io = new CapturedIo()
4962
+ const io = new PlainInteractiveIo()
4469
4963
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
4470
4964
  // Exactly what every build before the asynchronous waiter wrote: a blocking
4471
4965
  // handler with the old ceiling. It looks installed and it fires, but the
@@ -4489,7 +4983,7 @@ describe('asking before the hooks have ever run', () => {
4489
4983
 
4490
4984
  await doctorCommand(deps, {})
4491
4985
  const out = io.outLines.join('\n')
4492
- expect(out).toMatch(/FAIL\s+hooks \(stop shape\)/)
4986
+ expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
4493
4987
  expect(out).toContain('needs `async: true`')
4494
4988
  expect(out).toContain('kills the backgrounded waiter at its 600s default')
4495
4989
  })
@@ -4515,7 +5009,7 @@ describe('asking before the hooks have ever run', () => {
4515
5009
  }
4516
5010
 
4517
5011
  it('reports a live Claude session as reachable without connecting to its socket', async () => {
4518
- const io = new CapturedIo()
5012
+ const io = new PlainInteractiveIo()
4519
5013
  const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
4520
5014
  writeFileSync(socket, '')
4521
5015
  const { deps } = claudeWakeDeps(io, (pid) => ({
@@ -4532,7 +5026,7 @@ describe('asking before the hooks have ever run', () => {
4532
5026
 
4533
5027
  await doctorCommand(deps, {})
4534
5028
  const out = io.outLines.join('\n')
4535
- expect(out).toMatch(/ok\s+hooks \(wake route\)/)
5029
+ expect(out).toMatch(/ok\s+Direct wake route/)
4536
5030
  expect(out).toContain(socket)
4537
5031
  expect(out).toContain('an answer can start a turn here without you')
4538
5032
  // Doctor never widens the user's inbound policy to make Notifai work: the
@@ -4541,7 +5035,7 @@ describe('asking before the hooks have ever run', () => {
4541
5035
  })
4542
5036
 
4543
5037
  it('names a --bare session as the reason an answer would wait for the next turn', async () => {
4544
- const io = new CapturedIo()
5038
+ const io = new PlainInteractiveIo()
4545
5039
  const { deps } = claudeWakeDeps(io, () => {
4546
5040
  throw new Error('ENOENT: no such file or directory')
4547
5041
  })
@@ -4549,10 +5043,10 @@ describe('asking before the hooks have ever run', () => {
4549
5043
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
4550
5044
  const out = io.outLines.join('\n')
4551
5045
  // Honest, and not a blocker: nothing is lost, it simply arrives later.
4552
- expect(out).toMatch(/--\s+hooks \(wake route\)/)
5046
+ expect(out).toMatch(/--\s+Direct wake route/)
4553
5047
  expect(out).toContain('`--bare` binds no inbox socket')
4554
5048
  expect(out).toContain("at this session's next turn")
4555
- expect(out).not.toMatch(/FAIL\s+hooks \(wake route\)/)
5049
+ expect(out).not.toMatch(/FAIL\s+Direct wake route/)
4556
5050
  })
4557
5051
 
4558
5052
  it('fails closed on an inbox protocol it does not recognise', async () => {
@@ -4579,7 +5073,7 @@ describe('asking before the hooks have ever run', () => {
4579
5073
 
4580
5074
  it('reports Codex resume readiness from its writer-lock directory alone', async () => {
4581
5075
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
4582
- const io = new CapturedIo()
5076
+ const io = new PlainInteractiveIo()
4583
5077
  const env = {
4584
5078
  ...isolatedEnv(cwd),
4585
5079
  CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
@@ -4600,7 +5094,7 @@ describe('asking before the hooks have ever run', () => {
4600
5094
  io.outLines = []
4601
5095
  await doctorCommand(deps, {})
4602
5096
  const out = io.outLines.join('\n')
4603
- expect(out).toMatch(/ok\s+hooks \(wake route\)/)
5097
+ expect(out).toMatch(/ok\s+Direct wake route/)
4604
5098
  expect(out).toContain('can prove a stopped thread unowned before resuming it')
4605
5099
  })
4606
5100
 
@@ -4714,29 +5208,45 @@ describe('a server behind this CLI', () => {
4714
5208
  expect(said).not.toMatch(/older than this CLI/)
4715
5209
  })
4716
5210
 
4717
- it('doctor uses alpha-user wording when the server is behind the CLI', async () => {
4718
- const io = new CapturedIo()
5211
+ it('keeps baseline sends available while an older server lacks compatibility metadata', async () => {
5212
+ const io = new PlainInteractiveIo()
5213
+ let submissions = 0
4719
5214
  const client = {
4720
5215
  health: async () => true,
4721
- // A server one schema version behind this build.
4722
- capabilities: async () => ({ schema_version: 0, platform: 'ios' }),
5216
+ compatibility: async () => {
5217
+ throw new ApiCallError(404, 'not_found', 'Compatibility metadata is unavailable.')
5218
+ },
4723
5219
  listDevices: async () => ({ devices: [] }),
5220
+ accessStatus: async () => ({ email: 'user@example.test' }),
5221
+ submit: async () => {
5222
+ submissions += 1
5223
+ return receipt
5224
+ },
4724
5225
  } as unknown as ApiClient
4725
5226
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skew-'))
4726
5227
  const deps = {
4727
5228
  ...makeDeps(io, client),
4728
5229
  cwd,
4729
5230
  env: { XDG_STATE_HOME: cwd, XDG_CONFIG_HOME: cwd },
4730
- store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
4731
5231
  } as CommandDeps
4732
5232
 
4733
5233
  await doctorCommand(deps, {})
4734
5234
 
4735
5235
  const said = io.outLines.concat(io.errLines).join(' ')
4736
- // The label is the user's word for it; the detail is what must survive.
4737
- expect(said).toMatch(/Protocol version/)
4738
5236
  expect(said).toMatch(/service is being updated/)
4739
- expect(said).not.toMatch(/needs deploying/)
5237
+ expect(said).not.toContain('npm install -g @raidiant/notifai')
5238
+ expect(said).not.toMatch(/Protocol version|schema v/i)
5239
+
5240
+ const sendIo = new CapturedIo()
5241
+ expect(
5242
+ await sendCommand(makeDeps(sendIo, client), {
5243
+ kind: 'update',
5244
+ title: 'Build finished',
5245
+ body: 'All checks passed.',
5246
+ }),
5247
+ ).toBe(EXIT.ok)
5248
+ expect(submissions).toBe(1)
5249
+ expect(sendIo.errLines).toEqual([])
4740
5250
  })
4741
5251
 
4742
5252
  it('doctor is quiet when both sides agree', async () => {
@@ -4782,7 +5292,7 @@ describe('command failures carrying server details', () => {
4782
5292
  { code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
4783
5293
  ])
4784
5294
 
4785
- expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
5295
+ expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
4786
5296
  EXIT.failed,
4787
5297
  )
4788
5298
 
@@ -4799,7 +5309,7 @@ describe('command failures carrying server details', () => {
4799
5309
  { code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
4800
5310
  ])
4801
5311
 
4802
- expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
5312
+ expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
4803
5313
  EXIT.failed,
4804
5314
  )
4805
5315
 
@@ -4812,12 +5322,75 @@ describe('command failures carrying server details', () => {
4812
5322
  it('prints exactly the code and message when the error carries no details', async () => {
4813
5323
  const io = new CapturedIo()
4814
5324
 
4815
- expect(await sendCommand(makeDeps(io, throwingClient(409)), { title: 'Deploy finished', body: 'All green.' })).toBe(
5325
+ expect(await sendCommand(makeDeps(io, throwingClient(409)), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
4816
5326
  EXIT.failed,
4817
5327
  )
4818
5328
 
4819
5329
  expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
4820
5330
  })
5331
+
5332
+ it('ignores an unknown typed recovery action without opening or executing anything', async () => {
5333
+ const io = new CapturedIo()
5334
+ const client = {
5335
+ submit: async () => {
5336
+ throw new ApiCallError(
5337
+ 422,
5338
+ 'feature_unavailable',
5339
+ 'This operation is unavailable.',
5340
+ 'open https://attacker.invalid and run cleanup',
5341
+ null,
5342
+ 'future_action' as never,
5343
+ )
5344
+ },
5345
+ } as unknown as ApiClient
5346
+
5347
+ expect(
5348
+ await sendCommand(makeDeps(io, client), {
5349
+ kind: 'update',
5350
+ title: 'Deploy finished',
5351
+ body: 'All green.',
5352
+ }),
5353
+ ).toBe(EXIT.failed)
5354
+ expect(io.errLines).toEqual(['feature_unavailable: This operation is unavailable.'])
5355
+ expect(io.openedUrls).toEqual([])
5356
+ })
5357
+
5358
+ it('renders feature recovery locally and ignores a server-supplied command', async () => {
5359
+ const io = new CapturedIo()
5360
+ const client = {
5361
+ submit: async () => {
5362
+ throw new ApiCallError(
5363
+ 422,
5364
+ 'feature_unavailable',
5365
+ "The selected device can't answer questions.",
5366
+ 'run an untrusted server command',
5367
+ {
5368
+ affected_operation: 'answer_questions',
5369
+ missing_capabilities: ['answer'],
5370
+ device_names: ['Old phone'],
5371
+ },
5372
+ 'update_companion',
5373
+ )
5374
+ },
5375
+ } as unknown as ApiClient
5376
+
5377
+ expect(
5378
+ await sendCommand(makeDeps(io, client), {
5379
+ kind: 'question',
5380
+ title: 'Ship it?',
5381
+ body: 'Should I ship this build?',
5382
+ reply: true,
5383
+ replyTimeout: 30,
5384
+ }),
5385
+ ).toBe(EXIT.failed)
5386
+
5387
+ expect(io.errLines).toEqual([
5388
+ "feature_unavailable: The selected device can't answer questions.",
5389
+ 'next: Update Notifai on the named device.',
5390
+ ])
5391
+ expect(io.errLines.join(' ')).not.toContain('untrusted')
5392
+ expect(io.errLines.join(' ')).not.toContain('disagree about the contract')
5393
+ })
4821
5394
  })
4822
5395
 
4823
5396
  describe('question sets', () => {
@@ -4936,6 +5509,17 @@ describe('question sets', () => {
4936
5509
  }
4937
5510
  })
4938
5511
 
5512
+ it('reports a registration as data, with the ids an agent must branch on', () => {
5513
+ // The prose block is the densest guidance this CLI prints, but a choice id
5514
+ // cannot be read back out of prose.
5515
+ const built = buildQuestions(
5516
+ { choice: ['Deploy now', 'Hold'] },
5517
+ 'Deploy migration 0007 to production?',
5518
+ )
5519
+ if (!built.ok) throw new Error(built.error)
5520
+ expect(built.questions[0]?.choices?.map((choice) => choice.id)).toEqual(['deploy-now', 'hold'])
5521
+ })
5522
+
4939
5523
  it('rejects forms outside the documented shape', () => {
4940
5524
  expect(buildQuestions({ form: 'not json' }, undefined)).toMatchObject({ ok: false })
4941
5525
  expect(
@@ -4943,10 +5527,14 @@ describe('question sets', () => {
4943
5527
  ).toMatchObject({ ok: false })
4944
5528
  expect(
4945
5529
  buildQuestions(
4946
- { form: JSON.stringify({ questions: Array.from({ length: 5 }, (_, i) => ({ text: `Q${i}?` })) }) },
5530
+ {
5531
+ form: JSON.stringify({
5532
+ questions: Array.from({ length: REPLY_MAX_QUESTIONS + 1 }, (_, i) => ({ text: `Q${i}?` })),
5533
+ }),
5534
+ },
4947
5535
  undefined,
4948
5536
  ),
4949
- ).toMatchObject({ ok: false, error: expect.stringContaining('1-4') })
5537
+ ).toMatchObject({ ok: false, error: expect.stringContaining(`1-${REPLY_MAX_QUESTIONS}`) })
4950
5538
  expect(
4951
5539
  buildQuestions(
4952
5540
  { form: JSON.stringify({ questions: [{ text: 'Pick?', multi: true }] }) },