@raidiant/notifai 5.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 (104) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +2 -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 -387
  52. package/dist/commands.js +12 -4536
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +1 -1
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +1 -1
  57. package/dist/config.js +3 -1
  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/hooks.d.ts +2 -2
  62. package/dist/hooks.js +127 -99
  63. package/dist/hooks.js.map +1 -1
  64. package/dist/interactive.js +4 -4
  65. package/dist/interactive.js.map +1 -1
  66. package/dist/logging.js +1 -0
  67. package/dist/logging.js.map +1 -1
  68. package/dist/main.js +1 -1
  69. package/dist/main.js.map +1 -1
  70. package/dist/readiness.d.ts +3 -1
  71. package/dist/readiness.js.map +1 -1
  72. package/package.json +2 -2
  73. package/src/client.test.ts +90 -1
  74. package/src/client.ts +35 -2
  75. package/src/commands-auth.ts +174 -0
  76. package/src/commands-config.ts +307 -0
  77. package/src/commands-core.ts +240 -0
  78. package/src/commands-devices.ts +150 -0
  79. package/src/commands-doctor.ts +1209 -0
  80. package/src/commands-harness-context.ts +126 -0
  81. package/src/commands-hook-shape.ts +53 -0
  82. package/src/commands-hooks.ts +1586 -0
  83. package/src/commands-init.ts +721 -0
  84. package/src/commands-io.ts +108 -0
  85. package/src/commands-logs.ts +243 -0
  86. package/src/commands-send-support.ts +224 -0
  87. package/src/commands-send.ts +623 -0
  88. package/src/commands-setup-proof.ts +71 -0
  89. package/src/commands-skill.ts +84 -0
  90. package/src/commands.test.ts +493 -39
  91. package/src/commands.ts +58 -5614
  92. package/src/config-schema.ts +1 -1
  93. package/src/config.test.ts +9 -0
  94. package/src/config.ts +7 -2
  95. package/src/credentials.test.ts +25 -1
  96. package/src/credentials.ts +13 -3
  97. package/src/hooks.test.ts +351 -11
  98. package/src/hooks.ts +145 -97
  99. package/src/interactive.test.ts +19 -1
  100. package/src/interactive.ts +4 -4
  101. package/src/logging.test.ts +15 -2
  102. package/src/logging.ts +1 -0
  103. package/src/main.ts +1 -1
  104. package/src/readiness.ts +3 -1
@@ -1,4 +1,4 @@
1
- import { REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
1
+ import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
2
2
  import {
3
3
  existsSync,
4
4
  mkdirSync,
@@ -15,12 +15,16 @@ import { fileURLToPath } from 'node:url'
15
15
  import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH } from '@raidiant/notifai-protocol'
16
16
  import type {
17
17
  CapabilityDocument,
18
+ CompatibilityResponse,
18
19
  EvidenceSnapshot,
19
20
  ListRepliesResponse,
21
+ Platform,
20
22
  ReplyView,
21
23
  SubmissionReceipt,
22
24
  SubmitNotificationRequestT,
25
+ SupportAssessment,
23
26
  } from '@raidiant/notifai-protocol'
27
+ import { parse as parseToml } from 'smol-toml'
24
28
  import { afterEach, describe, expect, it } from 'vitest'
25
29
  import { ApiCallError, NetworkError, type ApiClient } from './client.js'
26
30
  import type { ClaudeWakeAdapters } from './claude-wake.js'
@@ -85,6 +89,71 @@ const RELEASE_REF = `v${
85
89
  ).version
86
90
  }`
87
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
+
88
157
  class CapturedIo implements CommandIo {
89
158
  outLines: string[] = []
90
159
  errLines: string[] = []
@@ -107,6 +176,10 @@ class CapturedIo implements CommandIo {
107
176
  }
108
177
  }
109
178
 
179
+ class PlainInteractiveIo extends CapturedIo {
180
+ interactive = true
181
+ }
182
+
110
183
  class InteractiveIo extends CapturedIo {
111
184
  interactive = true
112
185
  selectAnswer: string | null = 'global'
@@ -234,7 +307,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
234
307
  },
235
308
  hookAdapterHome: path.join(testRoot, 'home'),
236
309
  cwd: os.tmpdir(),
237
- clientFactory: () => client,
310
+ clientFactory: () => withCompatibilityDefaults(client),
238
311
  }
239
312
  }
240
313
 
@@ -1672,7 +1745,7 @@ describe('Cursor hook commands', () => {
1672
1745
 
1673
1746
  it('reports a native Cursor installation through doctor', async () => {
1674
1747
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-doctor-'))
1675
- const io = new CapturedIo()
1748
+ const io = new PlainInteractiveIo()
1676
1749
  const client = {
1677
1750
  health: async () => true,
1678
1751
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
@@ -1841,7 +1914,7 @@ describe('Codex hook representation', () => {
1841
1914
  const toml = writeInlineStop(cwd, 'gdh-stop')
1842
1915
  const json = path.join(cwd, '.codex', 'hooks.json')
1843
1916
  const env = isolatedEnv(cwd)
1844
- const io = new CapturedIo()
1917
+ const io = new PlainInteractiveIo()
1845
1918
  const client = {
1846
1919
  health: async () => false,
1847
1920
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
@@ -2434,6 +2507,39 @@ describe('config surfaces', () => {
2434
2507
  expect(resolved.sound.source).toMatch(/^global:/)
2435
2508
  })
2436
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
+
2437
2543
  it('refuses to create a redundant machine override equal to the shipped default', async () => {
2438
2544
  const io = new CapturedIo()
2439
2545
  const deps = configDeps(io)
@@ -2638,7 +2744,7 @@ describe('interactive command UX', () => {
2638
2744
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2639
2745
  expect(io.intros).toEqual(['Notifai doctor'])
2640
2746
  expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
2641
- 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)
2642
2748
  expect(io.outLines).toEqual([])
2643
2749
 
2644
2750
  // Four tones, not two. A boolean has to round `optional-gap` and `unknown`
@@ -2647,7 +2753,6 @@ describe('interactive command UX', () => {
2647
2753
  const tone = (prefix: string): Tone | undefined =>
2648
2754
  io.checks.find((check) => check.message.startsWith(prefix))?.tone
2649
2755
  expect(tone('This machine:')).toBe('bad')
2650
- expect(tone('Protocol version:')).toBe('ok')
2651
2756
  // Never evaluated: the account cannot be checked without a credential.
2652
2757
  expect(tone('Account:')).toBe('pending')
2653
2758
  // Legitimately declined rather than broken.
@@ -2681,6 +2786,210 @@ describe('interactive command UX', () => {
2681
2786
  })
2682
2787
  })
2683
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
+
2684
2993
  describe('init', () => {
2685
2994
  const readyIphone = {
2686
2995
  device_id: 'dev_iphone',
@@ -2688,9 +2997,22 @@ describe('init', () => {
2688
2997
  platform: 'ios' as const,
2689
2998
  permission_status: 'authorized',
2690
2999
  registration_healthy: true,
2691
- 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,
2692
3008
  last_seen_at: '2026-08-05T18:00:00.000Z',
2693
3009
  }
3010
+ const readyMac = {
3011
+ ...readyIphone,
3012
+ device_id: 'dev_mac',
3013
+ display_name: 'Mac',
3014
+ platform: 'macos' as const,
3015
+ }
2694
3016
 
2695
3017
  function setupEvidence(
2696
3018
  requestId: string,
@@ -2816,6 +3138,58 @@ describe('init', () => {
2816
3138
  expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
2817
3139
  })
2818
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
+
2819
3193
  it('surfaces one next step, not the whole remaining list', async () => {
2820
3194
  // The behavioural core of the design: someone handed five things to do
2821
3195
  // does none of them. Signing in gates the device check, so naming both
@@ -3197,7 +3571,7 @@ describe('init', () => {
3197
3571
  expect(out).toContain('Next: Your devices')
3198
3572
  expect(out).toContain('https://test.notifai.invalid/support')
3199
3573
  expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
3200
- 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')
3201
3575
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3202
3576
  })
3203
3577
 
@@ -3246,7 +3620,7 @@ describe('init', () => {
3246
3620
  })
3247
3621
 
3248
3622
  it.each([
3249
- ['denied', 'system settings'],
3623
+ ['denied', 'iPhone Settings'],
3250
3624
  ['not_determined', 'allow its notification prompt'],
3251
3625
  ])('gives one permission-specific next action for %s', async (permission, expected) => {
3252
3626
  const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
@@ -3271,7 +3645,7 @@ describe('init', () => {
3271
3645
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3272
3646
  })
3273
3647
 
3274
- 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 () => {
3275
3649
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
3276
3650
  const io = new InteractiveIo()
3277
3651
  let now = 0
@@ -3281,7 +3655,7 @@ describe('init', () => {
3281
3655
  const client = {
3282
3656
  health: async () => true,
3283
3657
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3284
- listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
3658
+ listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [readyMac] }),
3285
3659
  accessStatus: async () => ({
3286
3660
  status: 'active',
3287
3661
  reason: 'alpha_grant',
@@ -3318,6 +3692,7 @@ describe('init', () => {
3318
3692
  ])
3319
3693
  expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
3320
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…')
3321
3696
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
3322
3697
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
3323
3698
  expect(io.outLines.join('\n')).toContain(
@@ -3508,7 +3883,7 @@ describe('init', () => {
3508
3883
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
3509
3884
  })
3510
3885
 
3511
- 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 () => {
3512
3887
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
3513
3888
  const io = new CapturedIo()
3514
3889
  let submitCalls = 0
@@ -3516,7 +3891,7 @@ describe('init', () => {
3516
3891
  health: async () => true,
3517
3892
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3518
3893
  listDevices: async () => ({
3519
- devices: [{ ...readyIphone, device_id: 'dev_mac', display_name: 'Mac', platform: 'macos' }],
3894
+ devices: [readyMac],
3520
3895
  }),
3521
3896
  submit: async () => {
3522
3897
  submitCalls += 1
@@ -3529,16 +3904,16 @@ describe('init', () => {
3529
3904
  env: isolatedEnv(cwd),
3530
3905
  }
3531
3906
 
3532
- expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
3907
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
3533
3908
  expect(submitCalls).toBe(0)
3534
3909
  const out = io.outLines.join('\n')
3535
- expect(out).toContain('All set.')
3536
- expect(out).toContain('receipt proof needs an iPhone in this release')
3537
- 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.')
3538
3913
  expect(out).not.toMatch(/Companion Receipt observed/i)
3539
3914
 
3540
- // doctor must render the same non-blocking state (not FAIL / not a Next:).
3541
- const doctorIo = new CapturedIo()
3915
+ // A human doctor must render the same iPhone-readiness failure.
3916
+ const doctorIo = new PlainInteractiveIo()
3542
3917
  expect(
3543
3918
  await doctorCommand(
3544
3919
  {
@@ -3548,11 +3923,11 @@ describe('init', () => {
3548
3923
  },
3549
3924
  {},
3550
3925
  ),
3551
- ).toBe(EXIT.ok)
3926
+ ).toBe(EXIT.failed)
3552
3927
  const doctorOut = doctorIo.outLines.join('\n')
3553
- expect(doctorOut).toMatch(/--\s+Delivery proof:/)
3554
- expect(doctorOut).toContain('receipt proof needs an iPhone')
3555
- 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')
3556
3931
  })
3557
3932
 
3558
3933
  it('stops login when the approval page reports no Alpha access', async () => {
@@ -4228,7 +4603,7 @@ describe('asking before the hooks have ever run', () => {
4228
4603
 
4229
4604
  it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
4230
4605
  const cwd = scratchDir('notifai-codex-stale-trust-')
4231
- const io = new CapturedIo()
4606
+ const io = new PlainInteractiveIo()
4232
4607
  const env = {
4233
4608
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
4234
4609
  XDG_STATE_HOME: path.join(cwd, 'state'),
@@ -4493,7 +4868,7 @@ describe('asking before the hooks have ever run', () => {
4493
4868
 
4494
4869
  it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
4495
4870
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-doctor-'))
4496
- const io = new CapturedIo()
4871
+ const io = new PlainInteractiveIo()
4497
4872
  const env = {
4498
4873
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
4499
4874
  XDG_STATE_HOME: path.join(cwd, 'state'),
@@ -4553,7 +4928,7 @@ describe('asking before the hooks have ever run', () => {
4553
4928
  const localFile = personalProjectConfigPath(cwd, env)
4554
4929
  mkdirSync(path.dirname(localFile), { recursive: true })
4555
4930
  writeFileSync(localFile, 'ask_notifications = false\nask_grace_seconds = 90\n')
4556
- const io = new CapturedIo()
4931
+ const io = new PlainInteractiveIo()
4557
4932
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
4558
4933
 
4559
4934
  await doctorCommand(deps, {})
@@ -4569,7 +4944,7 @@ describe('asking before the hooks have ever run', () => {
4569
4944
 
4570
4945
  it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
4571
4946
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
4572
- const io = new CapturedIo()
4947
+ const io = new PlainInteractiveIo()
4573
4948
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
4574
4949
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
4575
4950
  EXIT.ok,
@@ -4584,7 +4959,7 @@ describe('asking before the hooks have ever run', () => {
4584
4959
 
4585
4960
  it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
4586
4961
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
4587
- const io = new CapturedIo()
4962
+ const io = new PlainInteractiveIo()
4588
4963
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
4589
4964
  // Exactly what every build before the asynchronous waiter wrote: a blocking
4590
4965
  // handler with the old ceiling. It looks installed and it fires, but the
@@ -4634,7 +5009,7 @@ describe('asking before the hooks have ever run', () => {
4634
5009
  }
4635
5010
 
4636
5011
  it('reports a live Claude session as reachable without connecting to its socket', async () => {
4637
- const io = new CapturedIo()
5012
+ const io = new PlainInteractiveIo()
4638
5013
  const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
4639
5014
  writeFileSync(socket, '')
4640
5015
  const { deps } = claudeWakeDeps(io, (pid) => ({
@@ -4660,7 +5035,7 @@ describe('asking before the hooks have ever run', () => {
4660
5035
  })
4661
5036
 
4662
5037
  it('names a --bare session as the reason an answer would wait for the next turn', async () => {
4663
- const io = new CapturedIo()
5038
+ const io = new PlainInteractiveIo()
4664
5039
  const { deps } = claudeWakeDeps(io, () => {
4665
5040
  throw new Error('ENOENT: no such file or directory')
4666
5041
  })
@@ -4698,7 +5073,7 @@ describe('asking before the hooks have ever run', () => {
4698
5073
 
4699
5074
  it('reports Codex resume readiness from its writer-lock directory alone', async () => {
4700
5075
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
4701
- const io = new CapturedIo()
5076
+ const io = new PlainInteractiveIo()
4702
5077
  const env = {
4703
5078
  ...isolatedEnv(cwd),
4704
5079
  CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
@@ -4833,29 +5208,45 @@ describe('a server behind this CLI', () => {
4833
5208
  expect(said).not.toMatch(/older than this CLI/)
4834
5209
  })
4835
5210
 
4836
- it('doctor uses alpha-user wording when the server is behind the CLI', async () => {
4837
- 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
4838
5214
  const client = {
4839
5215
  health: async () => true,
4840
- // A server one schema version behind this build.
4841
- capabilities: async () => ({ schema_version: 0, platform: 'ios' }),
5216
+ compatibility: async () => {
5217
+ throw new ApiCallError(404, 'not_found', 'Compatibility metadata is unavailable.')
5218
+ },
4842
5219
  listDevices: async () => ({ devices: [] }),
5220
+ accessStatus: async () => ({ email: 'user@example.test' }),
5221
+ submit: async () => {
5222
+ submissions += 1
5223
+ return receipt
5224
+ },
4843
5225
  } as unknown as ApiClient
4844
5226
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skew-'))
4845
5227
  const deps = {
4846
5228
  ...makeDeps(io, client),
4847
5229
  cwd,
4848
5230
  env: { XDG_STATE_HOME: cwd, XDG_CONFIG_HOME: cwd },
4849
- store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
4850
5231
  } as CommandDeps
4851
5232
 
4852
5233
  await doctorCommand(deps, {})
4853
5234
 
4854
5235
  const said = io.outLines.concat(io.errLines).join(' ')
4855
- // The label is the user's word for it; the detail is what must survive.
4856
- expect(said).toMatch(/Protocol version/)
4857
5236
  expect(said).toMatch(/service is being updated/)
4858
- 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([])
4859
5250
  })
4860
5251
 
4861
5252
  it('doctor is quiet when both sides agree', async () => {
@@ -4937,6 +5328,69 @@ describe('command failures carrying server details', () => {
4937
5328
 
4938
5329
  expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
4939
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
+ })
4940
5394
  })
4941
5395
 
4942
5396
  describe('question sets', () => {