@raidiant/notifai 5.0.0 → 7.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 (126) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +2 -2
  3. package/dist/atomic-file.js +13 -0
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +8 -3
  6. package/dist/client.js +24 -3
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands-auth.d.ts +14 -0
  9. package/dist/commands-auth.js +154 -0
  10. package/dist/commands-auth.js.map +1 -0
  11. package/dist/commands-config.d.ts +31 -0
  12. package/dist/commands-config.js +246 -0
  13. package/dist/commands-config.js.map +1 -0
  14. package/dist/commands-core.d.ts +102 -0
  15. package/dist/commands-core.js +135 -0
  16. package/dist/commands-core.js.map +1 -0
  17. package/dist/commands-devices.d.ts +31 -0
  18. package/dist/commands-devices.js +124 -0
  19. package/dist/commands-devices.js.map +1 -0
  20. package/dist/commands-doctor.d.ts +24 -0
  21. package/dist/commands-doctor.js +1011 -0
  22. package/dist/commands-doctor.js.map +1 -0
  23. package/dist/commands-harness-context.d.ts +65 -0
  24. package/dist/commands-harness-context.js +97 -0
  25. package/dist/commands-harness-context.js.map +1 -0
  26. package/dist/commands-hook-shape.d.ts +17 -0
  27. package/dist/commands-hook-shape.js +37 -0
  28. package/dist/commands-hook-shape.js.map +1 -0
  29. package/dist/commands-hooks.d.ts +115 -0
  30. package/dist/commands-hooks.js +1225 -0
  31. package/dist/commands-hooks.js.map +1 -0
  32. package/dist/commands-init.d.ts +35 -0
  33. package/dist/commands-init.js +580 -0
  34. package/dist/commands-init.js.map +1 -0
  35. package/dist/commands-io.d.ts +2 -0
  36. package/dist/commands-io.js +110 -0
  37. package/dist/commands-io.js.map +1 -0
  38. package/dist/commands-logs.d.ts +30 -0
  39. package/dist/commands-logs.js +201 -0
  40. package/dist/commands-logs.js.map +1 -0
  41. package/dist/commands-send-support.d.ts +44 -0
  42. package/dist/commands-send-support.js +183 -0
  43. package/dist/commands-send-support.js.map +1 -0
  44. package/dist/commands-send.d.ts +33 -0
  45. package/dist/commands-send.js +519 -0
  46. package/dist/commands-send.js.map +1 -0
  47. package/dist/commands-setup-proof.d.ts +15 -0
  48. package/dist/commands-setup-proof.js +53 -0
  49. package/dist/commands-setup-proof.js.map +1 -0
  50. package/dist/commands-skill.d.ts +10 -0
  51. package/dist/commands-skill.js +71 -0
  52. package/dist/commands-skill.js.map +1 -0
  53. package/dist/commands.d.ts +12 -387
  54. package/dist/commands.js +12 -4536
  55. package/dist/commands.js.map +1 -1
  56. package/dist/config-schema.js +1 -1
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +4 -4
  59. package/dist/config.js +6 -4
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.js +13 -3
  62. package/dist/credentials.js.map +1 -1
  63. package/dist/generated-session-label.d.ts +2 -0
  64. package/dist/generated-session-label.js +152 -0
  65. package/dist/generated-session-label.js.map +1 -0
  66. package/dist/hooks.d.ts +2 -2
  67. package/dist/hooks.js +127 -99
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/interactive.js +4 -4
  70. package/dist/interactive.js.map +1 -1
  71. package/dist/invocation-context.d.ts +3 -0
  72. package/dist/invocation-context.js +23 -6
  73. package/dist/invocation-context.js.map +1 -1
  74. package/dist/logging.js +1 -0
  75. package/dist/logging.js.map +1 -1
  76. package/dist/main.js +3 -3
  77. package/dist/main.js.map +1 -1
  78. package/dist/opencode-plugin.js +18 -3
  79. package/dist/opencode-plugin.js.map +1 -1
  80. package/dist/readiness.d.ts +3 -1
  81. package/dist/readiness.js.map +1 -1
  82. package/dist/session-labels.d.ts +30 -0
  83. package/dist/session-labels.js +278 -0
  84. package/dist/session-labels.js.map +1 -0
  85. package/package.json +2 -2
  86. package/src/atomic-file.ts +12 -0
  87. package/src/client.test.ts +90 -1
  88. package/src/client.ts +35 -2
  89. package/src/commands-auth.ts +174 -0
  90. package/src/commands-config.ts +307 -0
  91. package/src/commands-core.ts +240 -0
  92. package/src/commands-devices.ts +150 -0
  93. package/src/commands-doctor.ts +1209 -0
  94. package/src/commands-harness-context.ts +134 -0
  95. package/src/commands-hook-shape.ts +53 -0
  96. package/src/commands-hooks.ts +1586 -0
  97. package/src/commands-init.ts +721 -0
  98. package/src/commands-io.ts +108 -0
  99. package/src/commands-logs.ts +243 -0
  100. package/src/commands-send-support.ts +227 -0
  101. package/src/commands-send.ts +623 -0
  102. package/src/commands-setup-proof.ts +71 -0
  103. package/src/commands-skill.ts +84 -0
  104. package/src/commands.test.ts +567 -42
  105. package/src/commands.ts +58 -5614
  106. package/src/config-schema.ts +1 -1
  107. package/src/config.test.ts +9 -0
  108. package/src/config.ts +10 -5
  109. package/src/credentials.test.ts +25 -1
  110. package/src/credentials.ts +13 -3
  111. package/src/generated-session-label.ts +155 -0
  112. package/src/hooks.test.ts +355 -16
  113. package/src/hooks.ts +145 -97
  114. package/src/install-hooks.test.ts +84 -1
  115. package/src/interactive.test.ts +19 -1
  116. package/src/interactive.ts +4 -4
  117. package/src/invocation-context.test.ts +115 -16
  118. package/src/invocation-context.ts +32 -11
  119. package/src/logging.test.ts +15 -2
  120. package/src/logging.ts +1 -0
  121. package/src/main.ts +9 -3
  122. package/src/opencode-plugin.ts +18 -3
  123. package/src/readiness.ts +3 -1
  124. package/src/session-labels.test.ts +303 -0
  125. package/src/session-labels.ts +360 -0
  126. package/src/skill.test.ts +13 -0
@@ -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
 
@@ -427,11 +500,82 @@ describe('command contracts', () => {
427
500
  session_id: 'opaque-claude-session-42',
428
501
  harness: 'claude-code',
429
502
  })
430
- expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
503
+ expect(submitted?.draft.source?.session_label).toBe('Rapid Antelope')
431
504
  expect(io.outLines.join('\n')).not.toContain('opaque-claude-session-42')
432
505
  expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
433
506
  })
434
507
 
508
+ it('uses the trusted OpenCode session title published by its managed adapter', async () => {
509
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-title-'))
510
+ const io = new CapturedIo()
511
+ let submitted: SubmitNotificationRequestT | undefined
512
+ const client = {
513
+ submit: async (body: SubmitNotificationRequestT) => {
514
+ submitted = body
515
+ return receipt
516
+ },
517
+ } as unknown as ApiClient
518
+ const deps = {
519
+ ...makeDeps(io, client),
520
+ cwd,
521
+ env: {
522
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
523
+ XDG_STATE_HOME: path.join(cwd, 'state'),
524
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
525
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
526
+ NOTIFAI_ACTIVE_SESSION_LABEL: 'Semantic session names',
527
+ },
528
+ }
529
+
530
+ expect(
531
+ await sendCommand(deps, {
532
+ title: 'Resolver implemented',
533
+ body: 'The first-party title was frozen locally.',
534
+ kind: 'done',
535
+ }),
536
+ ).toBe(EXIT.ok)
537
+
538
+ expect(submitted?.draft.source).toMatchObject({
539
+ session_id: 'opencode-session',
540
+ session_label: 'Semantic session names',
541
+ harness: 'opencode',
542
+ })
543
+ })
544
+
545
+ it('does not freeze or submit an OpenCode placeholder title', async () => {
546
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-pending-'))
547
+ const io = new CapturedIo()
548
+ let submitted = false
549
+ const client = {
550
+ submit: async () => {
551
+ submitted = true
552
+ return receipt
553
+ },
554
+ } as unknown as ApiClient
555
+ const deps = {
556
+ ...makeDeps(io, client),
557
+ cwd,
558
+ env: {
559
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
560
+ XDG_STATE_HOME: path.join(cwd, 'state'),
561
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
562
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
563
+ NOTIFAI_ACTIVE_SESSION_LABEL_PENDING: '1',
564
+ },
565
+ }
566
+
567
+ expect(
568
+ await sendCommand(deps, {
569
+ title: 'Resolver implemented',
570
+ body: 'Wait for the semantic title before freezing a name.',
571
+ kind: 'done',
572
+ }),
573
+ ).toBe(EXIT.usage)
574
+ expect(submitted).toBe(false)
575
+ expect(io.errLines.join('\n')).toContain('still generating this session')
576
+ expect(existsSync(path.join(cwd, 'state', 'notifai', 'session-labels.json'))).toBe(false)
577
+ })
578
+
435
579
  it('uploads repeatable images in order and sends only canonical media references', async () => {
436
580
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
437
581
  const first = path.join(root, 'first.png')
@@ -580,7 +724,7 @@ describe('command contracts', () => {
580
724
  expect(grammar).not.toContain(".option('--session <")
581
725
  expect(grammar).toContain(".option('--body-file")
582
726
  expect(grammar).toContain(".option('--session-id")
583
- expect(grammar).toContain(".option('--session-label")
727
+ expect(grammar).toContain("'--session-label <text>'")
584
728
  expect(grammar).toContain("'--image <path|url|media_id>'")
585
729
  expect(grammar).toContain("'--image-alt <text>'")
586
730
  }
@@ -1672,7 +1816,7 @@ describe('Cursor hook commands', () => {
1672
1816
 
1673
1817
  it('reports a native Cursor installation through doctor', async () => {
1674
1818
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-doctor-'))
1675
- const io = new CapturedIo()
1819
+ const io = new PlainInteractiveIo()
1676
1820
  const client = {
1677
1821
  health: async () => true,
1678
1822
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
@@ -1841,7 +1985,7 @@ describe('Codex hook representation', () => {
1841
1985
  const toml = writeInlineStop(cwd, 'gdh-stop')
1842
1986
  const json = path.join(cwd, '.codex', 'hooks.json')
1843
1987
  const env = isolatedEnv(cwd)
1844
- const io = new CapturedIo()
1988
+ const io = new PlainInteractiveIo()
1845
1989
  const client = {
1846
1990
  health: async () => false,
1847
1991
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
@@ -2434,6 +2578,39 @@ describe('config surfaces', () => {
2434
2578
  expect(resolved.sound.source).toMatch(/^global:/)
2435
2579
  })
2436
2580
 
2581
+ it('round-trips unknown root and table keys through config set and unset', async () => {
2582
+ const io = new CapturedIo()
2583
+ const deps = configDeps(io)
2584
+ const configFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
2585
+ mkdirSync(path.dirname(configFile), { recursive: true })
2586
+ writeFileSync(
2587
+ configFile,
2588
+ [
2589
+ 'future_root = "keep-root"',
2590
+ 'wait_seconds = 20',
2591
+ '',
2592
+ '[future_table]',
2593
+ 'future_key = "keep-table"',
2594
+ '',
2595
+ ].join('\n'),
2596
+ )
2597
+
2598
+ expect(await configSetCommand(deps, 'sound', 'done', { yes: true })).toBe(EXIT.ok)
2599
+ expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
2600
+ future_root: 'keep-root',
2601
+ wait_seconds: 20,
2602
+ sound: 'done',
2603
+ future_table: { future_key: 'keep-table' },
2604
+ })
2605
+
2606
+ expect(await configUnsetCommand(deps, 'wait_seconds', { yes: true })).toBe(EXIT.ok)
2607
+ expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
2608
+ future_root: 'keep-root',
2609
+ sound: 'done',
2610
+ future_table: { future_key: 'keep-table' },
2611
+ })
2612
+ })
2613
+
2437
2614
  it('refuses to create a redundant machine override equal to the shipped default', async () => {
2438
2615
  const io = new CapturedIo()
2439
2616
  const deps = configDeps(io)
@@ -2638,7 +2815,7 @@ describe('interactive command UX', () => {
2638
2815
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2639
2816
  expect(io.intros).toEqual(['Notifai doctor'])
2640
2817
  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)
2818
+ expect(io.checks.some((check) => check.message.startsWith('Notifai update:'))).toBe(false)
2642
2819
  expect(io.outLines).toEqual([])
2643
2820
 
2644
2821
  // Four tones, not two. A boolean has to round `optional-gap` and `unknown`
@@ -2647,7 +2824,6 @@ describe('interactive command UX', () => {
2647
2824
  const tone = (prefix: string): Tone | undefined =>
2648
2825
  io.checks.find((check) => check.message.startsWith(prefix))?.tone
2649
2826
  expect(tone('This machine:')).toBe('bad')
2650
- expect(tone('Protocol version:')).toBe('ok')
2651
2827
  // Never evaluated: the account cannot be checked without a credential.
2652
2828
  expect(tone('Account:')).toBe('pending')
2653
2829
  // Legitimately declined rather than broken.
@@ -2681,6 +2857,210 @@ describe('interactive command UX', () => {
2681
2857
  })
2682
2858
  })
2683
2859
 
2860
+ describe('compatibility-first update guidance', () => {
2861
+ const cases: Array<{
2862
+ name: string
2863
+ support: Partial<SupportAssessment>
2864
+ status: 'ready' | 'optional-gap' | 'gap'
2865
+ detail: string
2866
+ exit: number
2867
+ humanLines: string[]
2868
+ }> = [
2869
+ {
2870
+ name: 'current',
2871
+ support: {},
2872
+ status: 'ready',
2873
+ detail: 'Notifai can send notifications.',
2874
+ exit: EXIT.ok,
2875
+ humanLines: [],
2876
+ },
2877
+ {
2878
+ name: 'optional newer release',
2879
+ support: {
2880
+ state: 'update_available',
2881
+ reason: 'newer_release',
2882
+ recovery_action: 'update_cli',
2883
+ recommended_version: '6.0.0',
2884
+ },
2885
+ status: 'optional-gap',
2886
+ detail: 'A newer Notifai is available.',
2887
+ exit: EXIT.ok,
2888
+ humanLines: [
2889
+ 'A newer Notifai is available.',
2890
+ 'npm install -g @raidiant/notifai',
2891
+ ],
2892
+ },
2893
+ {
2894
+ name: 'scheduled Sunset',
2895
+ support: {
2896
+ state: 'update_available',
2897
+ reason: 'sunset_scheduled',
2898
+ recovery_action: 'update_cli',
2899
+ recommended_version: '6.0.0',
2900
+ deprecation: 'Tue, 18 Aug 2026 00:00:00 GMT',
2901
+ sunset: 'Tue, 15 Sep 2026 00:00:00 GMT',
2902
+ },
2903
+ status: 'optional-gap',
2904
+ detail: 'Update Notifai soon to keep sending notifications.',
2905
+ exit: EXIT.ok,
2906
+ humanLines: [
2907
+ 'Update Notifai soon to keep sending notifications.',
2908
+ 'npm install -g @raidiant/notifai',
2909
+ ],
2910
+ },
2911
+ {
2912
+ name: 'required update',
2913
+ support: {
2914
+ state: 'must_update',
2915
+ reason: 'minimum_not_met',
2916
+ affected_operation: 'send_notifications',
2917
+ recovery_action: 'update_cli',
2918
+ recommended_version: '6.0.0',
2919
+ minimum_version: '6.0.0',
2920
+ },
2921
+ status: 'gap',
2922
+ detail: "Notifai can't send notifications until you update.",
2923
+ exit: EXIT.failed,
2924
+ humanLines: [
2925
+ "Notifai can't send notifications until you update.",
2926
+ 'npm install -g @raidiant/notifai',
2927
+ ],
2928
+ },
2929
+ ]
2930
+
2931
+ it.each(cases)(
2932
+ 'maps $name policy to closed human copy and structured JSON',
2933
+ async ({ support, status, detail, exit, humanLines }) => {
2934
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-guidance-'))
2935
+ const client = {
2936
+ health: async () => true,
2937
+ compatibility: async () => compatibilityWithCli(support),
2938
+ listDevices: async () => ({ devices: [] }),
2939
+ accessStatus: async () => ({ email: 'user@example.test' }),
2940
+ } as unknown as ApiClient
2941
+ const humanIo = new PlainInteractiveIo()
2942
+ const deps = {
2943
+ ...makeDeps(humanIo, client),
2944
+ cwd,
2945
+ env: isolatedEnv(cwd),
2946
+ }
2947
+ const readiness = await assessReadiness(deps)
2948
+ const contract = readiness.states.find((state) => state.id === 'contract')
2949
+ expect(contract).toMatchObject({ status, detail })
2950
+
2951
+ expect(
2952
+ await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
2953
+ ).toBe(exit)
2954
+ expect(humanIo.outLines).toEqual(humanLines)
2955
+ expect(humanIo.errLines).toEqual([])
2956
+
2957
+ const jsonIo = new CapturedIo()
2958
+ expect(
2959
+ await doctorCommand(
2960
+ { ...deps, io: jsonIo },
2961
+ { json: true },
2962
+ { readiness: { states: [contract!] } },
2963
+ ),
2964
+ ).toBe(exit)
2965
+ expect(jsonIo.outLines).toHaveLength(1)
2966
+ const payload = JSON.parse(jsonIo.outLines[0] ?? '{}') as {
2967
+ ok: boolean
2968
+ exit_code: number
2969
+ states: Array<{
2970
+ status: string
2971
+ detail: string
2972
+ remedy?: { command?: string }
2973
+ }>
2974
+ }
2975
+ expect(payload).toMatchObject({
2976
+ ok: exit === EXIT.ok,
2977
+ exit_code: exit,
2978
+ states: [{ status, detail }],
2979
+ })
2980
+ expect(payload.states[0]?.remedy?.command ?? null).toBe(
2981
+ status === 'ready' ? null : 'npm install -g @raidiant/notifai',
2982
+ )
2983
+ expect(jsonIo.errLines).toEqual([])
2984
+
2985
+ const nonTtyIo = new CapturedIo()
2986
+ expect(
2987
+ await doctorCommand(
2988
+ { ...deps, io: nonTtyIo },
2989
+ {},
2990
+ { readiness: { states: [contract!] } },
2991
+ ),
2992
+ ).toBe(exit)
2993
+ expect(nonTtyIo.outLines).toHaveLength(1)
2994
+ expect(JSON.parse(nonTtyIo.outLines[0] ?? '{}')).toEqual(payload)
2995
+ expect(nonTtyIo.errLines).toEqual([])
2996
+ },
2997
+ )
2998
+
2999
+ it('reports a missing named CLI capability without blocking ordinary sends', async () => {
3000
+ const requestedPlatforms: Platform[] = []
3001
+ let submissions = 0
3002
+ const client = {
3003
+ health: async () => true,
3004
+ compatibility: async (): Promise<CompatibilityResponse> => ({
3005
+ ...currentCompatibility,
3006
+ // This service can still accept baseline Notification Requests, but it
3007
+ // cannot honor this CLI's Agent Acknowledgement feature yet.
3008
+ server_capabilities: ['answer'],
3009
+ }),
3010
+ capabilities: async (platform: Platform = 'ios') => {
3011
+ requestedPlatforms.push(platform)
3012
+ const document = CAPABILITIES_V1.describe(platform)
3013
+ if (document === null) throw new Error(`missing ${platform} capability document`)
3014
+ return document
3015
+ },
3016
+ listDevices: async () => ({ devices: [] }),
3017
+ accessStatus: async () => ({ email: 'user@example.test' }),
3018
+ submit: async () => {
3019
+ submissions += 1
3020
+ return receipt
3021
+ },
3022
+ } as unknown as ApiClient
3023
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-capability-guidance-'))
3024
+ const io = new PlainInteractiveIo()
3025
+ const deps = {
3026
+ ...makeDeps(io, client),
3027
+ cwd,
3028
+ env: isolatedEnv(cwd),
3029
+ }
3030
+
3031
+ const readiness = await assessReadiness(deps)
3032
+ const contract = readiness.states.find((state) => state.id === 'contract')
3033
+ expect(requestedPlatforms).toEqual([...PLATFORMS])
3034
+ expect(contract).toMatchObject({
3035
+ status: 'optional-gap',
3036
+ detail: 'The service is being updated; try again later.',
3037
+ technical: {
3038
+ capability_documents: PLATFORMS.map((platform) => ({ platform, schema_version: 1 })),
3039
+ cli_capability_intersection: {
3040
+ available: [],
3041
+ missing_on_server: ['agent_acknowledgement'],
3042
+ },
3043
+ },
3044
+ })
3045
+
3046
+ expect(
3047
+ await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
3048
+ ).toBe(EXIT.ok)
3049
+ expect(io.outLines).toEqual(['The service is being updated; try again later.'])
3050
+ expect(io.outLines.join('\n')).not.toContain('npm install -g @raidiant/notifai')
3051
+
3052
+ const sendIo = new CapturedIo()
3053
+ expect(
3054
+ await sendCommand(
3055
+ { ...deps, io: sendIo },
3056
+ { kind: 'update', title: 'Build finished', body: 'All checks passed.' },
3057
+ ),
3058
+ ).toBe(EXIT.ok)
3059
+ expect(submissions).toBe(1)
3060
+ expect(sendIo.errLines).toEqual([])
3061
+ })
3062
+ })
3063
+
2684
3064
  describe('init', () => {
2685
3065
  const readyIphone = {
2686
3066
  device_id: 'dev_iphone',
@@ -2688,9 +3068,22 @@ describe('init', () => {
2688
3068
  platform: 'ios' as const,
2689
3069
  permission_status: 'authorized',
2690
3070
  registration_healthy: true,
2691
- reply_protocol_version: 2,
3071
+ app_version: '0.1.0',
3072
+ app_build: '42',
3073
+ os_version: '19.0',
3074
+ capabilities: ['answer'] as const,
3075
+ support: currentSupport,
3076
+ support_state: 'current' as const,
3077
+ derived_status: 'working' as const,
3078
+ status_message: null,
2692
3079
  last_seen_at: '2026-08-05T18:00:00.000Z',
2693
3080
  }
3081
+ const readyMac = {
3082
+ ...readyIphone,
3083
+ device_id: 'dev_mac',
3084
+ display_name: 'Mac',
3085
+ platform: 'macos' as const,
3086
+ }
2694
3087
 
2695
3088
  function setupEvidence(
2696
3089
  requestId: string,
@@ -2816,6 +3209,58 @@ describe('init', () => {
2816
3209
  expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
2817
3210
  })
2818
3211
 
3212
+ it('resumes the same init after a required CLI update becomes current', async () => {
3213
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-init-update-resume-'))
3214
+ const io = new CapturedIo()
3215
+ let cliSupport = compatibilityWithCli({
3216
+ state: 'must_update',
3217
+ reason: 'minimum_not_met',
3218
+ affected_operation: 'send_notifications',
3219
+ recovery_action: 'update_cli',
3220
+ recommended_version: '6.0.0',
3221
+ minimum_version: '6.0.0',
3222
+ })
3223
+ let submissions = 0
3224
+ const client = {
3225
+ health: async () => true,
3226
+ compatibility: async () => cliSupport,
3227
+ listDevices: async () => ({ devices: [readyIphone] }),
3228
+ accessStatus: async () => ({ email: 'user@example.test' }),
3229
+ submit: async () => {
3230
+ submissions += 1
3231
+ return setupReceipt('req_after_update')
3232
+ },
3233
+ evidence: async () =>
3234
+ setupEvidence('req_after_update', {
3235
+ state: 'observed',
3236
+ observed_at: '2026-08-18T18:00:02.000Z',
3237
+ latency_ms: 1_000,
3238
+ }),
3239
+ } as unknown as ApiClient
3240
+ const deps = {
3241
+ ...makeDeps(io, client),
3242
+ cwd,
3243
+ env: isolatedEnv(cwd),
3244
+ }
3245
+
3246
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
3247
+ expect(io.outLines).toContain("Notifai can't send notifications until you update.")
3248
+ expect(io.outLines).toContain('npm install -g @raidiant/notifai')
3249
+ expect(submissions).toBe(0)
3250
+
3251
+ cliSupport = currentCompatibility
3252
+ io.outLines = []
3253
+ io.errLines = []
3254
+
3255
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
3256
+ expect(submissions).toBe(1)
3257
+ expect(io.outLines).toContain(
3258
+ 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
3259
+ )
3260
+ expect(io.outLines).not.toContain('npm install -g @raidiant/notifai')
3261
+ expect(io.errLines).toEqual([])
3262
+ })
3263
+
2819
3264
  it('surfaces one next step, not the whole remaining list', async () => {
2820
3265
  // The behavioural core of the design: someone handed five things to do
2821
3266
  // does none of them. Signing in gates the device check, so naming both
@@ -3197,7 +3642,7 @@ describe('init', () => {
3197
3642
  expect(out).toContain('Next: Your devices')
3198
3643
  expect(out).toContain('https://test.notifai.invalid/support')
3199
3644
  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')
3645
+ expect(out).toContain('install Notifai on iPhone via https://test.notifai.invalid/support')
3201
3646
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3202
3647
  })
3203
3648
 
@@ -3246,7 +3691,7 @@ describe('init', () => {
3246
3691
  })
3247
3692
 
3248
3693
  it.each([
3249
- ['denied', 'system settings'],
3694
+ ['denied', 'iPhone Settings'],
3250
3695
  ['not_determined', 'allow its notification prompt'],
3251
3696
  ])('gives one permission-specific next action for %s', async (permission, expected) => {
3252
3697
  const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
@@ -3271,7 +3716,7 @@ describe('init', () => {
3271
3716
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3272
3717
  })
3273
3718
 
3274
- it('waits on the supported device registry, then ends with an observed real receipt', async () => {
3719
+ it('ignores a dormant Mac installation while waiting for an iPhone, then proves the iPhone receipt', async () => {
3275
3720
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
3276
3721
  const io = new InteractiveIo()
3277
3722
  let now = 0
@@ -3281,7 +3726,7 @@ describe('init', () => {
3281
3726
  const client = {
3282
3727
  health: async () => true,
3283
3728
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3284
- listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
3729
+ listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [readyMac] }),
3285
3730
  accessStatus: async () => ({
3286
3731
  status: 'active',
3287
3732
  reason: 'alpha_grant',
@@ -3318,6 +3763,7 @@ describe('init', () => {
3318
3763
  ])
3319
3764
  expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
3320
3765
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
3766
+ expect(io.spinnerEvents).toContain('message:Waiting for the iPhone app to sign in and register…')
3321
3767
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
3322
3768
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
3323
3769
  expect(io.outLines.join('\n')).toContain(
@@ -3508,7 +3954,7 @@ describe('init', () => {
3508
3954
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
3509
3955
  })
3510
3956
 
3511
- it('treats macOS-only delivery proof as an honest non-blocking caveat', async () => {
3957
+ it('does not treat a macOS-only installation as active-release readiness', async () => {
3512
3958
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
3513
3959
  const io = new CapturedIo()
3514
3960
  let submitCalls = 0
@@ -3516,7 +3962,7 @@ describe('init', () => {
3516
3962
  health: async () => true,
3517
3963
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3518
3964
  listDevices: async () => ({
3519
- devices: [{ ...readyIphone, device_id: 'dev_mac', display_name: 'Mac', platform: 'macos' }],
3965
+ devices: [readyMac],
3520
3966
  }),
3521
3967
  submit: async () => {
3522
3968
  submitCalls += 1
@@ -3529,16 +3975,16 @@ describe('init', () => {
3529
3975
  env: isolatedEnv(cwd),
3530
3976
  }
3531
3977
 
3532
- expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
3978
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
3533
3979
  expect(submitCalls).toBe(0)
3534
3980
  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')
3981
+ expect(out).toContain('Next: Your devices')
3982
+ expect(out).toContain('no iPhone registered yet')
3983
+ expect(out).not.toContain('All set.')
3538
3984
  expect(out).not.toMatch(/Companion Receipt observed/i)
3539
3985
 
3540
- // doctor must render the same non-blocking state (not FAIL / not a Next:).
3541
- const doctorIo = new CapturedIo()
3986
+ // A human doctor must render the same iPhone-readiness failure.
3987
+ const doctorIo = new PlainInteractiveIo()
3542
3988
  expect(
3543
3989
  await doctorCommand(
3544
3990
  {
@@ -3548,11 +3994,11 @@ describe('init', () => {
3548
3994
  },
3549
3995
  {},
3550
3996
  ),
3551
- ).toBe(EXIT.ok)
3997
+ ).toBe(EXIT.failed)
3552
3998
  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:/)
3999
+ expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
4000
+ expect(doctorOut).toContain('no iPhone registered yet')
4001
+ expect(doctorOut).toContain('Delivery proof: not checked — no iPhone is ready')
3556
4002
  })
3557
4003
 
3558
4004
  it('stops login when the approval page reports no Alpha access', async () => {
@@ -3977,7 +4423,7 @@ describe('asking before the hooks have ever run', () => {
3977
4423
  })
3978
4424
  expect(
3979
4425
  readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
3980
- ).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
4426
+ ).toBe('Gentle Salmon')
3981
4427
  })
3982
4428
 
3983
4429
  it('uploads ask images before registration and freezes canonical body media', async () => {
@@ -4228,7 +4674,7 @@ describe('asking before the hooks have ever run', () => {
4228
4674
 
4229
4675
  it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
4230
4676
  const cwd = scratchDir('notifai-codex-stale-trust-')
4231
- const io = new CapturedIo()
4677
+ const io = new PlainInteractiveIo()
4232
4678
  const env = {
4233
4679
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
4234
4680
  XDG_STATE_HOME: path.join(cwd, 'state'),
@@ -4493,7 +4939,7 @@ describe('asking before the hooks have ever run', () => {
4493
4939
 
4494
4940
  it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
4495
4941
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-doctor-'))
4496
- const io = new CapturedIo()
4942
+ const io = new PlainInteractiveIo()
4497
4943
  const env = {
4498
4944
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
4499
4945
  XDG_STATE_HOME: path.join(cwd, 'state'),
@@ -4553,7 +4999,7 @@ describe('asking before the hooks have ever run', () => {
4553
4999
  const localFile = personalProjectConfigPath(cwd, env)
4554
5000
  mkdirSync(path.dirname(localFile), { recursive: true })
4555
5001
  writeFileSync(localFile, 'ask_notifications = false\nask_grace_seconds = 90\n')
4556
- const io = new CapturedIo()
5002
+ const io = new PlainInteractiveIo()
4557
5003
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
4558
5004
 
4559
5005
  await doctorCommand(deps, {})
@@ -4569,7 +5015,7 @@ describe('asking before the hooks have ever run', () => {
4569
5015
 
4570
5016
  it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
4571
5017
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
4572
- const io = new CapturedIo()
5018
+ const io = new PlainInteractiveIo()
4573
5019
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
4574
5020
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
4575
5021
  EXIT.ok,
@@ -4584,7 +5030,7 @@ describe('asking before the hooks have ever run', () => {
4584
5030
 
4585
5031
  it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
4586
5032
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
4587
- const io = new CapturedIo()
5033
+ const io = new PlainInteractiveIo()
4588
5034
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
4589
5035
  // Exactly what every build before the asynchronous waiter wrote: a blocking
4590
5036
  // handler with the old ceiling. It looks installed and it fires, but the
@@ -4634,7 +5080,7 @@ describe('asking before the hooks have ever run', () => {
4634
5080
  }
4635
5081
 
4636
5082
  it('reports a live Claude session as reachable without connecting to its socket', async () => {
4637
- const io = new CapturedIo()
5083
+ const io = new PlainInteractiveIo()
4638
5084
  const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
4639
5085
  writeFileSync(socket, '')
4640
5086
  const { deps } = claudeWakeDeps(io, (pid) => ({
@@ -4660,7 +5106,7 @@ describe('asking before the hooks have ever run', () => {
4660
5106
  })
4661
5107
 
4662
5108
  it('names a --bare session as the reason an answer would wait for the next turn', async () => {
4663
- const io = new CapturedIo()
5109
+ const io = new PlainInteractiveIo()
4664
5110
  const { deps } = claudeWakeDeps(io, () => {
4665
5111
  throw new Error('ENOENT: no such file or directory')
4666
5112
  })
@@ -4698,7 +5144,7 @@ describe('asking before the hooks have ever run', () => {
4698
5144
 
4699
5145
  it('reports Codex resume readiness from its writer-lock directory alone', async () => {
4700
5146
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
4701
- const io = new CapturedIo()
5147
+ const io = new PlainInteractiveIo()
4702
5148
  const env = {
4703
5149
  ...isolatedEnv(cwd),
4704
5150
  CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
@@ -4833,29 +5279,45 @@ describe('a server behind this CLI', () => {
4833
5279
  expect(said).not.toMatch(/older than this CLI/)
4834
5280
  })
4835
5281
 
4836
- it('doctor uses alpha-user wording when the server is behind the CLI', async () => {
4837
- const io = new CapturedIo()
5282
+ it('keeps baseline sends available while an older server lacks compatibility metadata', async () => {
5283
+ const io = new PlainInteractiveIo()
5284
+ let submissions = 0
4838
5285
  const client = {
4839
5286
  health: async () => true,
4840
- // A server one schema version behind this build.
4841
- capabilities: async () => ({ schema_version: 0, platform: 'ios' }),
5287
+ compatibility: async () => {
5288
+ throw new ApiCallError(404, 'not_found', 'Compatibility metadata is unavailable.')
5289
+ },
4842
5290
  listDevices: async () => ({ devices: [] }),
5291
+ accessStatus: async () => ({ email: 'user@example.test' }),
5292
+ submit: async () => {
5293
+ submissions += 1
5294
+ return receipt
5295
+ },
4843
5296
  } as unknown as ApiClient
4844
5297
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skew-'))
4845
5298
  const deps = {
4846
5299
  ...makeDeps(io, client),
4847
5300
  cwd,
4848
5301
  env: { XDG_STATE_HOME: cwd, XDG_CONFIG_HOME: cwd },
4849
- store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
4850
5302
  } as CommandDeps
4851
5303
 
4852
5304
  await doctorCommand(deps, {})
4853
5305
 
4854
5306
  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
5307
  expect(said).toMatch(/service is being updated/)
4858
- expect(said).not.toMatch(/needs deploying/)
5308
+ expect(said).not.toContain('npm install -g @raidiant/notifai')
5309
+ expect(said).not.toMatch(/Protocol version|schema v/i)
5310
+
5311
+ const sendIo = new CapturedIo()
5312
+ expect(
5313
+ await sendCommand(makeDeps(sendIo, client), {
5314
+ kind: 'update',
5315
+ title: 'Build finished',
5316
+ body: 'All checks passed.',
5317
+ }),
5318
+ ).toBe(EXIT.ok)
5319
+ expect(submissions).toBe(1)
5320
+ expect(sendIo.errLines).toEqual([])
4859
5321
  })
4860
5322
 
4861
5323
  it('doctor is quiet when both sides agree', async () => {
@@ -4937,6 +5399,69 @@ describe('command failures carrying server details', () => {
4937
5399
 
4938
5400
  expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
4939
5401
  })
5402
+
5403
+ it('ignores an unknown typed recovery action without opening or executing anything', async () => {
5404
+ const io = new CapturedIo()
5405
+ const client = {
5406
+ submit: async () => {
5407
+ throw new ApiCallError(
5408
+ 422,
5409
+ 'feature_unavailable',
5410
+ 'This operation is unavailable.',
5411
+ 'open https://attacker.invalid and run cleanup',
5412
+ null,
5413
+ 'future_action' as never,
5414
+ )
5415
+ },
5416
+ } as unknown as ApiClient
5417
+
5418
+ expect(
5419
+ await sendCommand(makeDeps(io, client), {
5420
+ kind: 'update',
5421
+ title: 'Deploy finished',
5422
+ body: 'All green.',
5423
+ }),
5424
+ ).toBe(EXIT.failed)
5425
+ expect(io.errLines).toEqual(['feature_unavailable: This operation is unavailable.'])
5426
+ expect(io.openedUrls).toEqual([])
5427
+ })
5428
+
5429
+ it('renders feature recovery locally and ignores a server-supplied command', async () => {
5430
+ const io = new CapturedIo()
5431
+ const client = {
5432
+ submit: async () => {
5433
+ throw new ApiCallError(
5434
+ 422,
5435
+ 'feature_unavailable',
5436
+ "The selected device can't answer questions.",
5437
+ 'run an untrusted server command',
5438
+ {
5439
+ affected_operation: 'answer_questions',
5440
+ missing_capabilities: ['answer'],
5441
+ device_names: ['Old phone'],
5442
+ },
5443
+ 'update_companion',
5444
+ )
5445
+ },
5446
+ } as unknown as ApiClient
5447
+
5448
+ expect(
5449
+ await sendCommand(makeDeps(io, client), {
5450
+ kind: 'question',
5451
+ title: 'Ship it?',
5452
+ body: 'Should I ship this build?',
5453
+ reply: true,
5454
+ replyTimeout: 30,
5455
+ }),
5456
+ ).toBe(EXIT.failed)
5457
+
5458
+ expect(io.errLines).toEqual([
5459
+ "feature_unavailable: The selected device can't answer questions.",
5460
+ 'next: Update Notifai on the named device.',
5461
+ ])
5462
+ expect(io.errLines.join(' ')).not.toContain('untrusted')
5463
+ expect(io.errLines.join(' ')).not.toContain('disagree about the contract')
5464
+ })
4940
5465
  })
4941
5466
 
4942
5467
  describe('question sets', () => {