@raidiant/notifai 7.0.2 → 8.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 (75) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/commands-devices.d.ts +2 -1
  3. package/dist/commands-devices.js +20 -8
  4. package/dist/commands-devices.js.map +1 -1
  5. package/dist/commands-doctor.js +14 -12
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.d.ts +23 -0
  8. package/dist/commands-guidance.js +128 -0
  9. package/dist/commands-guidance.js.map +1 -0
  10. package/dist/commands-hooks.d.ts +7 -1
  11. package/dist/commands-hooks.js +3 -6
  12. package/dist/commands-hooks.js.map +1 -1
  13. package/dist/commands-init.js +25 -22
  14. package/dist/commands-init.js.map +1 -1
  15. package/dist/commands-send-support.js +5 -3
  16. package/dist/commands-send-support.js.map +1 -1
  17. package/dist/commands-send.d.ts +0 -1
  18. package/dist/commands-send.js +45 -28
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands.d.ts +1 -0
  21. package/dist/commands.js +1 -0
  22. package/dist/commands.js.map +1 -1
  23. package/dist/config-schema.js +6 -15
  24. package/dist/config-schema.js.map +1 -1
  25. package/dist/config.d.ts +1 -3
  26. package/dist/config.js +0 -3
  27. package/dist/config.js.map +1 -1
  28. package/dist/guidance-content.d.ts +30 -0
  29. package/dist/guidance-content.js +175 -0
  30. package/dist/guidance-content.js.map +1 -0
  31. package/dist/guidance.d.ts +48 -0
  32. package/dist/guidance.js +118 -0
  33. package/dist/guidance.js.map +1 -0
  34. package/dist/hooks.js +7 -7
  35. package/dist/hooks.js.map +1 -1
  36. package/dist/interactive.d.ts +1 -1
  37. package/dist/interactive.js +13 -8
  38. package/dist/interactive.js.map +1 -1
  39. package/dist/main.js +3 -474
  40. package/dist/main.js.map +1 -1
  41. package/dist/program.d.ts +49 -0
  42. package/dist/program.js +571 -0
  43. package/dist/program.js.map +1 -0
  44. package/dist/send.d.ts +4 -9
  45. package/dist/send.js +48 -28
  46. package/dist/send.js.map +1 -1
  47. package/dist/session-labels.d.ts +9 -3
  48. package/dist/session-labels.js +38 -7
  49. package/dist/session-labels.js.map +1 -1
  50. package/package.json +5 -3
  51. package/src/commands-devices.ts +28 -9
  52. package/src/commands-doctor.ts +17 -12
  53. package/src/commands-guidance.ts +170 -0
  54. package/src/commands-hooks.ts +10 -8
  55. package/src/commands-init.ts +31 -22
  56. package/src/commands-send-support.ts +5 -3
  57. package/src/commands-send.ts +50 -28
  58. package/src/commands.test.ts +374 -63
  59. package/src/commands.ts +5 -0
  60. package/src/config-schema.ts +6 -17
  61. package/src/config.test.ts +56 -1
  62. package/src/config.ts +0 -5
  63. package/src/guidance-content.ts +190 -0
  64. package/src/guidance.test.ts +268 -0
  65. package/src/guidance.ts +151 -0
  66. package/src/hooks.test.ts +37 -5
  67. package/src/hooks.ts +7 -7
  68. package/src/interactive.ts +13 -8
  69. package/src/main.ts +4 -600
  70. package/src/program.test.ts +202 -0
  71. package/src/program.ts +723 -0
  72. package/src/send.ts +53 -32
  73. package/src/session-labels.test.ts +120 -3
  74. package/src/session-labels.ts +38 -7
  75. package/src/skill.test.ts +37 -12
@@ -42,6 +42,7 @@ import {
42
42
  configUnsetCommand,
43
43
  contradictingAnswer,
44
44
  describeHookFailure,
45
+ devicesCommand,
45
46
  doctorCommand,
46
47
  EXIT,
47
48
  hooksInstallCommand,
@@ -129,6 +130,17 @@ const currentCompatibility: CompatibilityResponse = {
129
130
  replacement_available: false,
130
131
  rollout_complete: false,
131
132
  },
133
+ {
134
+ platform: 'android',
135
+ recommended_version: null,
136
+ recommended_build: null,
137
+ minimum_receive_build: null,
138
+ minimum_answer_build: null,
139
+ deprecation: null,
140
+ sunset: null,
141
+ replacement_available: false,
142
+ rollout_complete: false,
143
+ },
132
144
  ],
133
145
  server_capabilities: ['answer', 'agent_acknowledgement'],
134
146
  }
@@ -453,6 +465,93 @@ describe('command contracts', () => {
453
465
  expect(io.outLines[0]).toBe('macos capability contract v1 (payload limit 4096 bytes)')
454
466
  })
455
467
 
468
+ it('passes the selected Android platform through to the capability client', async () => {
469
+ const io = new CapturedIo()
470
+ let requestedPlatform: string | undefined
471
+ const document = CAPABILITIES_V1.describe('android')!
472
+ const client = {
473
+ capabilities: async (platform?: string) => {
474
+ requestedPlatform = platform
475
+ return document
476
+ },
477
+ } as unknown as ApiClient
478
+
479
+ expect(await capabilitiesCommand(makeDeps(io, client), { platform: 'android' })).toBe(
480
+ EXIT.ok,
481
+ )
482
+ expect(requestedPlatform).toBe('android')
483
+ expect(io.outLines[0]).toBe(
484
+ 'android capability contract v1 (payload limit 4096 bytes)',
485
+ )
486
+ })
487
+
488
+ it('filters Device Installations by Android in human and JSON output', async () => {
489
+ const devices = [
490
+ {
491
+ device_id: 'dev_ios',
492
+ display_name: 'iPhone',
493
+ platform: 'ios' as const,
494
+ permission_status: 'authorized',
495
+ registration_healthy: true,
496
+ app_version: '1.0.0',
497
+ app_build: '1',
498
+ os_version: '19',
499
+ capabilities: ['answer'] as const,
500
+ support: currentSupport,
501
+ support_state: 'current' as const,
502
+ derived_status: 'working' as const,
503
+ status_message: null,
504
+ last_seen_at: null,
505
+ },
506
+ {
507
+ device_id: 'dev_android',
508
+ display_name: 'Pixel',
509
+ platform: 'android' as const,
510
+ permission_status: 'authorized',
511
+ registration_healthy: true,
512
+ app_version: '1.0.0',
513
+ app_build: '2',
514
+ os_version: '16',
515
+ capabilities: ['answer'] as const,
516
+ support: currentSupport,
517
+ support_state: 'current' as const,
518
+ derived_status: 'working' as const,
519
+ status_message: null,
520
+ last_seen_at: null,
521
+ },
522
+ ]
523
+ const client = { listDevices: async () => ({ devices }) } as unknown as ApiClient
524
+
525
+ const human = new CapturedIo()
526
+ expect(
527
+ await devicesCommand(makeDeps(human, client), { platform: 'android' }),
528
+ ).toBe(EXIT.ok)
529
+ expect(human.outLines).toEqual(['dev_android Pixel android Working'])
530
+
531
+ const json = new CapturedIo()
532
+ expect(
533
+ await devicesCommand(makeDeps(json, client), { platform: 'android', json: true }),
534
+ ).toBe(EXIT.ok)
535
+ expect(JSON.parse(json.outLines[0]!)['devices']).toEqual([devices[1]])
536
+ })
537
+
538
+ it('rejects an unknown Device Installation platform filter locally', async () => {
539
+ const io = new CapturedIo()
540
+ let calls = 0
541
+ const client = {
542
+ listDevices: async () => {
543
+ calls += 1
544
+ return { devices: [] }
545
+ },
546
+ } as unknown as ApiClient
547
+
548
+ expect(await devicesCommand(makeDeps(io, client), { platform: 'linux' })).toBe(
549
+ EXIT.usage,
550
+ )
551
+ expect(calls).toBe(0)
552
+ expect(io.errLines.join('\n')).toContain('ios or macos or android')
553
+ })
554
+
456
555
  it('rejects an invalid draft before calling submit', async () => {
457
556
  const io = new CapturedIo()
458
557
  let submitCalls = 0
@@ -474,6 +573,86 @@ describe('command contracts', () => {
474
573
  expect(io.errLines.join('\n')).toContain('project')
475
574
  })
476
575
 
576
+ it('authors and validates an Android-specific Notification Request', async () => {
577
+ const io = new CapturedIo()
578
+ let submitted: SubmitNotificationRequestT | undefined
579
+ const client = {
580
+ submit: async (body: SubmitNotificationRequestT) => {
581
+ submitted = body
582
+ return receipt
583
+ },
584
+ } as unknown as ApiClient
585
+
586
+ expect(
587
+ await sendCommand(makeDeps(io, client), {
588
+ kind: 'done',
589
+ title: 'Android build finished',
590
+ body: 'All checks passed.',
591
+ platform: 'android',
592
+ sound: 'none',
593
+ data: ['run_id=42'],
594
+ }),
595
+ ).toBe(EXIT.ok)
596
+ expect(submitted?.draft.platform).toEqual({
597
+ android: { sound: null, custom_data: { run_id: '42' } },
598
+ })
599
+ expect(io.errLines).toEqual([])
600
+ })
601
+
602
+ it('prints Android downgrade warnings before submitting', async () => {
603
+ const io = new CapturedIo()
604
+ let submitCalls = 0
605
+ const client = {
606
+ submit: async () => {
607
+ submitCalls += 1
608
+ return receipt
609
+ },
610
+ } as unknown as ApiClient
611
+
612
+ expect(
613
+ await sendCommand(makeDeps(io, client), {
614
+ kind: 'update',
615
+ title: 'Android evidence',
616
+ body: 'See the attached result.',
617
+ platform: 'android',
618
+ image: ['med_result'],
619
+ threadId: 'android-results',
620
+ }),
621
+ ).toBe(EXIT.ok)
622
+ expect(submitCalls).toBe(1)
623
+ expect(io.errLines).toEqual(
624
+ expect.arrayContaining([
625
+ expect.stringContaining('presentation.media'),
626
+ expect.stringContaining('platform.android.thread_id'),
627
+ ]),
628
+ )
629
+ })
630
+
631
+ it('rejects an Android interruption level before submission', async () => {
632
+ const io = new CapturedIo()
633
+ let submitCalls = 0
634
+ const client = {
635
+ submit: async () => {
636
+ submitCalls += 1
637
+ return receipt
638
+ },
639
+ } as unknown as ApiClient
640
+
641
+ expect(
642
+ await sendCommand(makeDeps(io, client), {
643
+ kind: 'update',
644
+ title: 'Android update',
645
+ body: 'Ready.',
646
+ platform: 'android',
647
+ level: 'active',
648
+ }),
649
+ ).toBe(EXIT.usage)
650
+ expect(submitCalls).toBe(0)
651
+ expect(io.errLines.join('\n')).toContain(
652
+ 'Android does not support caller-selected interruption levels',
653
+ )
654
+ })
655
+
477
656
  it('infers Project and exact Claude Source Context without printing the opaque id', async () => {
478
657
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'Notifai Project '))
479
658
  const io = new CapturedIo()
@@ -557,6 +736,48 @@ describe('command contracts', () => {
557
736
  })
558
737
  })
559
738
 
739
+ it('uses the exact Orca worktree title for an active Codex session send', async () => {
740
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
741
+ const io = new CapturedIo()
742
+ let submitted: SubmitNotificationRequestT | undefined
743
+ const client = {
744
+ submit: async (body: SubmitNotificationRequestT) => {
745
+ submitted = body
746
+ return receipt
747
+ },
748
+ } as unknown as ApiClient
749
+ const worktreeId = `repo-123::${cwd}`
750
+ const deps = {
751
+ ...makeDeps(io, client),
752
+ cwd,
753
+ env: {
754
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
755
+ XDG_STATE_HOME: path.join(cwd, 'state'),
756
+ CODEX_THREAD_ID: 'orca-codex-thread',
757
+ TERM_PROGRAM: 'Orca',
758
+ ORCA_WORKTREE_ID: worktreeId,
759
+ },
760
+ orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
761
+ expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
762
+ return 'Worker - semantic session implementation'
763
+ },
764
+ }
765
+
766
+ expect(
767
+ await sendCommand(deps, {
768
+ title: 'Resolver implemented',
769
+ body: 'The worktree title belongs to every harness Orca starts in it.',
770
+ kind: 'done',
771
+ }),
772
+ ).toBe(EXIT.ok)
773
+
774
+ expect(submitted?.draft.source).toMatchObject({
775
+ session_id: 'orca-codex-thread',
776
+ session_label: 'Worker - semantic session implementation',
777
+ harness: 'codex',
778
+ })
779
+ })
780
+
560
781
  it('falls back safely when Orca returns a private path as its title', async () => {
561
782
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-private-title-'))
562
783
  const io = new CapturedIo()
@@ -801,7 +1022,7 @@ describe('command contracts', () => {
801
1022
  })
802
1023
 
803
1024
  it('keeps only the current public notification-authoring flags', () => {
804
- const source = readFileSync(new URL('./main.ts', import.meta.url), 'utf8')
1025
+ const source = readFileSync(new URL('./program.ts', import.meta.url), 'utf8')
805
1026
  expect(source).not.toContain(".option('--badge")
806
1027
  expect(source).not.toContain(".option('--relevance")
807
1028
  expect(source).not.toContain(".option('--target-content-id")
@@ -823,33 +1044,6 @@ describe('command contracts', () => {
823
1044
  expect(source).not.toContain('Kind profiles apply automatically')
824
1045
  })
825
1046
 
826
- it('rejects a question nobody will wait for', async () => {
827
- // --reply asks; --no-block declares nothing will wait. The answer would be
828
- // captured server-side and then reachable only by hand, so the user taps a
829
- // real button and nothing happens — worse than never asking, because it
830
- // spends their attention and their trust in the channel.
831
- const io = new CapturedIo()
832
- let submitCalls = 0
833
- const client = {
834
- submit: async () => {
835
- submitCalls += 1
836
- return receipt
837
- },
838
- } as unknown as ApiClient
839
-
840
- expect(
841
- await sendCommand(makeDeps(io, client), {
842
- title: 'Question',
843
- body: 'Deploy?',
844
- reply: true,
845
- replyWindow: 3_600,
846
- noBlock: true,
847
- }),
848
- ).toBe(EXIT.usage)
849
- expect(submitCalls).toBe(0)
850
- expect(io.errLines.join('\n')).toContain('notifai ask')
851
- })
852
-
853
1047
  it('maps reply flags into the draft and waits for the answer', async () => {
854
1048
  const io = new CapturedIo()
855
1049
  let submitted: SubmitNotificationRequestT | undefined
@@ -907,7 +1101,7 @@ describe('command contracts', () => {
907
1101
 
908
1102
  expect(await sendCommand(makeDeps(io, client), flags)).toBe(EXIT.ok)
909
1103
  expect(io.errLines).toEqual([
910
- 'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --reply-choice) so it can be answered from the notification.',
1104
+ 'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --choice) so it can be answered from the notification.',
911
1105
  ])
912
1106
  })
913
1107
 
@@ -929,7 +1123,7 @@ describe('command contracts', () => {
929
1123
  expect(io.errLines).toEqual([])
930
1124
  })
931
1125
 
932
- it('rejects --reply-choice without the --reply action it configures', async () => {
1126
+ it('rejects --choice without the --reply action it configures', async () => {
933
1127
  const io = new CapturedIo()
934
1128
  let submitCalls = 0
935
1129
  const client = {
@@ -943,12 +1137,12 @@ describe('command contracts', () => {
943
1137
  await sendCommand(makeDeps(io, client), { kind: 'update',
944
1138
  title: 'Deploy?',
945
1139
  body: 'Choose when ready.',
946
- replyChoice: ['Now', 'Later'],
1140
+ choice: ['Now', 'Later'],
947
1141
  }),
948
1142
  ).toBe(EXIT.usage)
949
1143
  expect(submitCalls).toBe(0)
950
1144
  expect(io.errLines).toEqual([
951
- 'Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.',
1145
+ 'Use --reply with --reply-timeout, --reply-window, or --choice.',
952
1146
  ])
953
1147
  })
954
1148
 
@@ -1148,13 +1342,14 @@ describe('command contracts', () => {
1148
1342
  })
1149
1343
  })
1150
1344
 
1151
- it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
1345
+ it('prints one combined reply_result object on exit 3, nothing before the wait', async () => {
1152
1346
  const io = new CapturedIo()
1153
1347
  let now = 0
1154
1348
  const client = {
1155
1349
  submit: async () => receipt,
1156
1350
  replies: async (_requestId: string, options: { waitSeconds: number }) => {
1157
- expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
1351
+ // Nothing on stdout mid-wait: the single object arrives at the end.
1352
+ expect(io.outLines).toHaveLength(0)
1158
1353
  now += options.waitSeconds * 1_000
1159
1354
  return replyResponse()
1160
1355
  },
@@ -1170,11 +1365,12 @@ describe('command contracts', () => {
1170
1365
  json: true,
1171
1366
  }),
1172
1367
  ).toBe(EXIT.noReply)
1173
- expect(io.outLines).toHaveLength(2)
1368
+ expect(io.outLines).toHaveLength(1)
1174
1369
  // `degraded` is part of the shape on every reply wait, not only when it is
1175
1370
  // true: an agent must be able to read it without knowing it might be absent.
1176
- expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
1371
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1177
1372
  type: 'reply_result',
1373
+ receipt,
1178
1374
  request_id: receipt.request_id,
1179
1375
  reply_expires_at: '2026-08-02T18:00:00.000Z',
1180
1376
  replies: [],
@@ -1189,7 +1385,7 @@ describe('command contracts', () => {
1189
1385
  expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
1190
1386
  })
1191
1387
 
1192
- it('prints the receipt and reply result as two NDJSON records when answered', async () => {
1388
+ it('prints one combined reply_result object with the receipt when answered', async () => {
1193
1389
  const io = new CapturedIo()
1194
1390
  const client = {
1195
1391
  submit: async () => receipt,
@@ -1205,10 +1401,10 @@ describe('command contracts', () => {
1205
1401
  json: true,
1206
1402
  }),
1207
1403
  ).toBe(EXIT.ok)
1208
- expect(io.outLines).toHaveLength(2)
1209
- expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
1210
- expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
1404
+ expect(io.outLines).toHaveLength(1)
1405
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1211
1406
  type: 'reply_result',
1407
+ receipt,
1212
1408
  request_id: receipt.request_id,
1213
1409
  reply_expires_at: '2026-08-02T18:00:00.000Z',
1214
1410
  replies: [reply],
@@ -1220,6 +1416,41 @@ describe('command contracts', () => {
1220
1416
  })
1221
1417
  })
1222
1418
 
1419
+ it('still prints the one reply_result object when the wait itself faults', async () => {
1420
+ const io = new CapturedIo()
1421
+ const client = {
1422
+ submit: async () => receipt,
1423
+ replies: async () => {
1424
+ throw new ApiCallError(404, 'not_found', 'no such request')
1425
+ },
1426
+ } as unknown as ApiClient
1427
+
1428
+ expect(
1429
+ await sendCommand(makeDeps(io, client), {
1430
+ title: 'Question',
1431
+ body: 'Deploy?',
1432
+ reply: true,
1433
+ replyTimeout: 10,
1434
+ json: true,
1435
+ }),
1436
+ ).toBe(EXIT.failed)
1437
+ expect(io.outLines).toHaveLength(1)
1438
+ // The send is durable even though the wait failed; `degraded: true` says
1439
+ // "could not find out", not "no answer".
1440
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1441
+ type: 'reply_result',
1442
+ receipt,
1443
+ request_id: receipt.request_id,
1444
+ replies: [],
1445
+ agent_acknowledgement_required: true,
1446
+ agent_acknowledgement_text_required: true,
1447
+ agent_acknowledgement: null,
1448
+ acknowledgement_command: null,
1449
+ degraded: true,
1450
+ })
1451
+ expect(io.errLines.join('\n')).toContain('reply wait failed')
1452
+ })
1453
+
1223
1454
  it('acknowledges non-interactively, trims text, emits JSON, and logs request identity', async () => {
1224
1455
  const io = new CapturedIo()
1225
1456
  let submitted: { requestId: string; text: string } | undefined
@@ -3175,11 +3406,18 @@ describe('init', () => {
3175
3406
  display_name: 'Mac',
3176
3407
  platform: 'macos' as const,
3177
3408
  }
3409
+ const readyAndroid = {
3410
+ ...readyIphone,
3411
+ device_id: 'dev_android',
3412
+ display_name: 'Pixel',
3413
+ platform: 'android' as const,
3414
+ os_version: '16',
3415
+ }
3178
3416
 
3179
3417
  function setupEvidence(
3180
3418
  requestId: string,
3181
3419
  companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
3182
- device = readyIphone,
3420
+ device: typeof readyIphone | typeof readyAndroid = readyIphone,
3183
3421
  ): EvidenceSnapshot {
3184
3422
  return {
3185
3423
  request_id: requestId,
@@ -3215,15 +3453,18 @@ describe('init', () => {
3215
3453
  }
3216
3454
  }
3217
3455
 
3218
- function setupReceipt(requestId = 'req_setup'): SubmissionReceipt {
3456
+ function setupReceipt(
3457
+ requestId = 'req_setup',
3458
+ device: typeof readyIphone | typeof readyAndroid = readyIphone,
3459
+ ): SubmissionReceipt {
3219
3460
  return {
3220
3461
  ...receipt,
3221
3462
  request_id: requestId,
3222
3463
  deliveries: [
3223
3464
  {
3224
3465
  ...receipt.deliveries[0]!,
3225
- device_id: readyIphone.device_id,
3226
- device_name: readyIphone.display_name,
3466
+ device_id: device.device_id,
3467
+ device_name: device.display_name,
3227
3468
  },
3228
3469
  ],
3229
3470
  }
@@ -3735,7 +3976,9 @@ describe('init', () => {
3735
3976
  expect(out).toContain('Next: Your devices')
3736
3977
  expect(out).toContain('https://test.notifai.invalid/support')
3737
3978
  expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
3738
- expect(out).toContain('install Notifai on iPhone via https://test.notifai.invalid/support')
3979
+ expect(out).toContain(
3980
+ 'no active Companion device registered yet; install Notifai via https://test.notifai.invalid/support',
3981
+ )
3739
3982
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3740
3983
  })
3741
3984
 
@@ -3784,7 +4027,7 @@ describe('init', () => {
3784
4027
  })
3785
4028
 
3786
4029
  it.each([
3787
- ['denied', 'iPhone Settings'],
4030
+ ['denied', "the device's Settings"],
3788
4031
  ['not_determined', 'allow its notification prompt'],
3789
4032
  ])('gives one permission-specific next action for %s', async (permission, expected) => {
3790
4033
  const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
@@ -3804,12 +4047,12 @@ describe('init', () => {
3804
4047
 
3805
4048
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
3806
4049
  const out = io.outLines.join('\n')
3807
- expect(out).toContain(`iPhone (${permission})`)
4050
+ expect(out).toContain(`iPhone (ios, ${permission})`)
3808
4051
  expect(out).toContain(expected)
3809
4052
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3810
4053
  })
3811
4054
 
3812
- it('ignores a dormant Mac installation while waiting for an iPhone, then proves the iPhone receipt', async () => {
4055
+ it('ignores a dormant Mac installation while waiting for an active Companion, then proves the iPhone receipt', async () => {
3813
4056
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
3814
4057
  const io = new InteractiveIo()
3815
4058
  let now = 0
@@ -3856,7 +4099,9 @@ describe('init', () => {
3856
4099
  ])
3857
4100
  expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
3858
4101
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
3859
- expect(io.spinnerEvents).toContain('message:Waiting for the iPhone app to sign in and register…')
4102
+ expect(io.spinnerEvents).toContain(
4103
+ 'message:Waiting for a Companion App to sign in and register…',
4104
+ )
3860
4105
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
3861
4106
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
3862
4107
  expect(io.outLines.join('\n')).toContain(
@@ -4047,6 +4292,51 @@ describe('init', () => {
4047
4292
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
4048
4293
  })
4049
4294
 
4295
+ it('treats Android as active Companion readiness and sends a platform-correct proof', async () => {
4296
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-android-proof-'))
4297
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
4298
+ writeFileSync(
4299
+ path.join(cwd, '.notifai', 'config.toml'),
4300
+ 'project = "android-project"\ninterruption_level = "passive"\n',
4301
+ )
4302
+ const io = new CapturedIo()
4303
+ let submitted: SubmitNotificationRequestT | null = null
4304
+ const client = {
4305
+ health: async () => true,
4306
+ listDevices: async () => ({ devices: [readyAndroid] }),
4307
+ submit: async (body: SubmitNotificationRequestT) => {
4308
+ submitted = body
4309
+ return setupReceipt('req_android_setup', readyAndroid)
4310
+ },
4311
+ evidence: async (requestId: string) =>
4312
+ setupEvidence(
4313
+ requestId,
4314
+ {
4315
+ state: 'observed',
4316
+ observed_at: '2026-08-05T18:00:02.000Z',
4317
+ latency_ms: 1_000,
4318
+ },
4319
+ readyAndroid,
4320
+ ),
4321
+ } as unknown as ApiClient
4322
+ const deps = {
4323
+ ...makeDeps(io, client),
4324
+ cwd,
4325
+ env: isolatedEnv(cwd),
4326
+ }
4327
+
4328
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
4329
+ expect(submitted?.draft.targets).toEqual({
4330
+ mode: 'selected',
4331
+ device_ids: [readyAndroid.device_id],
4332
+ })
4333
+ expect(submitted?.draft.platform).toEqual({ android: { sound: null } })
4334
+ expect(io.outLines.join('\n')).toContain(
4335
+ "Companion Receipt (the app's delivery confirmation) observed from Pixel.",
4336
+ )
4337
+ expect(io.outLines.join('\n')).toContain('All set.')
4338
+ })
4339
+
4050
4340
  it('does not treat a macOS-only installation as active-release readiness', async () => {
4051
4341
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
4052
4342
  const io = new CapturedIo()
@@ -4072,11 +4362,11 @@ describe('init', () => {
4072
4362
  expect(submitCalls).toBe(0)
4073
4363
  const out = io.outLines.join('\n')
4074
4364
  expect(out).toContain('Next: Your devices')
4075
- expect(out).toContain('no iPhone registered yet')
4365
+ expect(out).toContain('no active Companion device registered yet')
4076
4366
  expect(out).not.toContain('All set.')
4077
4367
  expect(out).not.toMatch(/Companion Receipt observed/i)
4078
4368
 
4079
- // A human doctor must render the same iPhone-readiness failure.
4369
+ // A human doctor must render the same active-Companion readiness failure.
4080
4370
  const doctorIo = new PlainInteractiveIo()
4081
4371
  expect(
4082
4372
  await doctorCommand(
@@ -4090,8 +4380,10 @@ describe('init', () => {
4090
4380
  ).toBe(EXIT.failed)
4091
4381
  const doctorOut = doctorIo.outLines.join('\n')
4092
4382
  expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
4093
- expect(doctorOut).toContain('no iPhone registered yet')
4094
- expect(doctorOut).toContain('Delivery proof: not checked — no iPhone is ready')
4383
+ expect(doctorOut).toContain('no active Companion device registered yet')
4384
+ expect(doctorOut).toContain(
4385
+ 'Delivery proof: not checked — no iPhone or Android Companion App is ready',
4386
+ )
4095
4387
  })
4096
4388
 
4097
4389
  it('stops login when the approval page reports no Alpha access', async () => {
@@ -4387,7 +4679,8 @@ describe('an outage is not an answer', () => {
4387
4679
  json: true,
4388
4680
  })
4389
4681
 
4390
- const payload = JSON.parse(io.outLines[1] ?? '{}') as { degraded: boolean }
4682
+ expect(io.outLines).toHaveLength(1)
4683
+ const payload = JSON.parse(io.outLines[0] ?? '{}') as { degraded: boolean }
4391
4684
  expect(payload.degraded).toBe(true)
4392
4685
  })
4393
4686
 
@@ -4568,7 +4861,7 @@ describe('asking before the hooks have ever run', () => {
4568
4861
  const pending = readSessionState('codex-media-thread', env).pending?.[0]
4569
4862
  expect(uploaded).toEqual(['med_ask_1', 'med_ask_2'])
4570
4863
  expect(pending?.body).toBe(
4571
- 'Which visual should I use?\n\nCompare ![first](media:med_ask_1) with ![second](media:med_ask_2).',
4864
+ 'Compare ![first](media:med_ask_1) with ![second](media:med_ask_2).',
4572
4865
  )
4573
4866
  expect(pending?.media).toEqual([
4574
4867
  { media_id: 'med_ask_1', alt: 'First option' },
@@ -5591,7 +5884,7 @@ describe('command failures carrying server details', () => {
5591
5884
  })
5592
5885
 
5593
5886
  describe('question sets', () => {
5594
- it('maps --reply-multi into the single question', async () => {
5887
+ it('maps --multi into the single question', async () => {
5595
5888
  const io = new CapturedIo()
5596
5889
  let submitted: SubmitNotificationRequestT | undefined
5597
5890
  const client = {
@@ -5608,8 +5901,8 @@ describe('question sets', () => {
5608
5901
  body: 'Which fronts?',
5609
5902
  reply: true,
5610
5903
  replyTimeout: 30,
5611
- replyChoice: ['CLI', 'Server', 'Apps'],
5612
- replyMulti: true,
5904
+ choice: ['CLI', 'Server', 'Apps'],
5905
+ multi: true,
5613
5906
  }),
5614
5907
  ).toBe(EXIT.ok)
5615
5908
  expect(submitted?.draft.reply?.questions?.[0]).toMatchObject({
@@ -5643,7 +5936,10 @@ describe('question sets', () => {
5643
5936
  expect(submitted?.draft.reply?.questions[0]?.text).toBe('Which environment?')
5644
5937
  })
5645
5938
 
5646
- it('composes one question before optional Markdown context', () => {
5939
+ it('keeps the question out of a body that carries context', () => {
5940
+ // The question already travels as the title and as structured questions;
5941
+ // repeating it in the body showed it twice on the lock screen and the
5942
+ // reply screen.
5647
5943
  expect(
5648
5944
  buildQuestions(
5649
5945
  { body: '## Why\nThe release window closes today.' },
@@ -5651,12 +5947,28 @@ describe('question sets', () => {
5651
5947
  ),
5652
5948
  ).toMatchObject({
5653
5949
  ok: true,
5654
- body: 'Which environment?\n\n## Why\nThe release window closes today.',
5950
+ body: '## Why\nThe release window closes today.',
5655
5951
  questions: [{ text: 'Which environment?' }],
5656
5952
  })
5657
5953
  })
5658
5954
 
5659
- it('composes numbered form questions before form context', () => {
5955
+ it('lets the question stand in for the body only when there is no context', () => {
5956
+ expect(buildQuestions({}, 'Which environment?')).toMatchObject({
5957
+ ok: true,
5958
+ body: 'Which environment?',
5959
+ })
5960
+ expect(
5961
+ buildQuestions(
5962
+ { form: JSON.stringify({ questions: [{ text: 'Deploy where?' }, { text: 'What should I monitor?' }] }) },
5963
+ undefined,
5964
+ ),
5965
+ ).toMatchObject({
5966
+ ok: true,
5967
+ body: '1. Deploy where?\n2. What should I monitor?',
5968
+ })
5969
+ })
5970
+
5971
+ it('keeps form questions out of a body that carries form context', () => {
5660
5972
  expect(
5661
5973
  buildQuestions(
5662
5974
  {
@@ -5669,8 +5981,7 @@ describe('question sets', () => {
5669
5981
  ),
5670
5982
  ).toMatchObject({
5671
5983
  ok: true,
5672
- body:
5673
- '1. Deploy where?\n2. What should I monitor?\n\n## Context\nTraffic is elevated.',
5984
+ body: '## Context\nTraffic is elevated.',
5674
5985
  })
5675
5986
  })
5676
5987
 
package/src/commands.ts CHANGED
@@ -47,6 +47,11 @@ export {
47
47
  configShowCommand,
48
48
  configUnsetCommand,
49
49
  } from './commands-config.js'
50
+ export {
51
+ guidanceSetCommand,
52
+ guidanceShowCommand,
53
+ guidanceUnsetCommand,
54
+ } from './commands-guidance.js'
50
55
  export { logsCommand, parseSince, type LogsFlags } from './commands-logs.js'
51
56
  export { SKILLS_SOURCE } from './commands-skill.js'
52
57
  export {