@raidiant/notifai 10.0.0 → 10.1.1

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 (127) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +4 -1
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +32 -14
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +10 -1
  19. package/dist/commands-core.js +13 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +158 -71
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-harness-context.js +8 -0
  27. package/dist/commands-harness-context.js.map +1 -1
  28. package/dist/commands-hooks.js +169 -15
  29. package/dist/commands-hooks.js.map +1 -1
  30. package/dist/commands-init.d.ts +16 -0
  31. package/dist/commands-init.js +254 -91
  32. package/dist/commands-init.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-update.d.ts +10 -0
  37. package/dist/commands-update.js +141 -0
  38. package/dist/commands-update.js.map +1 -0
  39. package/dist/commands.d.ts +2 -1
  40. package/dist/commands.js +2 -1
  41. package/dist/commands.js.map +1 -1
  42. package/dist/harnesses.d.ts +2 -2
  43. package/dist/harnesses.js +22 -2
  44. package/dist/harnesses.js.map +1 -1
  45. package/dist/hook-adapter.d.ts +2 -0
  46. package/dist/hook-adapter.js +12 -1
  47. package/dist/hook-adapter.js.map +1 -1
  48. package/dist/hooks.d.ts +43 -2
  49. package/dist/hooks.js +163 -21
  50. package/dist/hooks.js.map +1 -1
  51. package/dist/install-hooks.js +15 -9
  52. package/dist/install-hooks.js.map +1 -1
  53. package/dist/interactive.js +20 -23
  54. package/dist/interactive.js.map +1 -1
  55. package/dist/main-run.d.ts +1 -0
  56. package/dist/main-run.js +42 -0
  57. package/dist/main-run.js.map +1 -0
  58. package/dist/main.js +10 -31
  59. package/dist/main.js.map +1 -1
  60. package/dist/node-floor.d.ts +18 -0
  61. package/dist/node-floor.js +35 -0
  62. package/dist/node-floor.js.map +1 -0
  63. package/dist/openclaw-plugin.d.ts +49 -0
  64. package/dist/openclaw-plugin.js +350 -0
  65. package/dist/openclaw-plugin.js.map +1 -0
  66. package/dist/program.d.ts +2 -1
  67. package/dist/program.js +31 -13
  68. package/dist/program.js.map +1 -1
  69. package/dist/question-settlement-process.d.ts +15 -0
  70. package/dist/question-settlement-process.js +35 -0
  71. package/dist/question-settlement-process.js.map +1 -0
  72. package/dist/readiness.d.ts +25 -1
  73. package/dist/readiness.js +32 -2
  74. package/dist/readiness.js.map +1 -1
  75. package/dist/session-activation.js +1 -1
  76. package/dist/session-activation.js.map +1 -1
  77. package/dist/setup-destinations.d.ts +23 -0
  78. package/dist/setup-destinations.js +44 -0
  79. package/dist/setup-destinations.js.map +1 -0
  80. package/dist/ui/help.d.ts +4 -5
  81. package/dist/ui/help.js +5 -6
  82. package/dist/ui/help.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/cli-bin.test.ts +145 -7
  85. package/src/cli-bin.ts +176 -10
  86. package/src/cli-update-contract.ts +9 -0
  87. package/src/cli-update.test.ts +125 -0
  88. package/src/client.ts +7 -0
  89. package/src/codex-wake.ts +5 -1
  90. package/src/commands-auth.ts +47 -13
  91. package/src/commands-core.ts +21 -4
  92. package/src/commands-devices.ts +23 -16
  93. package/src/commands-doctor.ts +197 -75
  94. package/src/commands-harness-context.ts +8 -0
  95. package/src/commands-hooks.ts +210 -18
  96. package/src/commands-init.ts +275 -92
  97. package/src/commands-send.ts +145 -39
  98. package/src/commands-update.ts +180 -0
  99. package/src/commands.test.ts +701 -53
  100. package/src/commands.ts +2 -0
  101. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
  102. package/src/harness-integration.test.ts +82 -0
  103. package/src/harnesses.test.ts +9 -1
  104. package/src/harnesses.ts +24 -2
  105. package/src/hook-adapter.ts +14 -0
  106. package/src/hooks.test.ts +275 -0
  107. package/src/hooks.ts +270 -18
  108. package/src/install-hooks.test.ts +208 -0
  109. package/src/install-hooks.ts +19 -9
  110. package/src/interactive.test.ts +31 -0
  111. package/src/interactive.ts +20 -26
  112. package/src/main-run.ts +45 -0
  113. package/src/main.ts +9 -33
  114. package/src/node-floor.test.ts +27 -0
  115. package/src/node-floor.ts +37 -0
  116. package/src/openclaw-plugin.ts +409 -0
  117. package/src/program.test.ts +35 -0
  118. package/src/program.ts +37 -12
  119. package/src/question-settlement-process.ts +47 -0
  120. package/src/readiness.ts +35 -2
  121. package/src/session-activation.ts +1 -1
  122. package/src/setup-destinations.test.ts +41 -0
  123. package/src/setup-destinations.ts +50 -0
  124. package/src/skill.test.ts +13 -2
  125. package/src/ui/help.test.ts +16 -0
  126. package/src/ui/help.ts +6 -7
  127. package/src/commands-devices.test.ts +0 -15
@@ -2,10 +2,12 @@ import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notif
2
2
  import { execFileSync } from 'node:child_process'
3
3
  import { createHash } from 'node:crypto'
4
4
  import {
5
+ chmodSync,
5
6
  existsSync,
6
7
  mkdirSync,
7
8
  mkdtempSync,
8
9
  readFileSync,
10
+ realpathSync,
9
11
  readdirSync,
10
12
  rmSync,
11
13
  symlinkSync,
@@ -34,6 +36,7 @@ import {
34
36
  acknowledgeCommand,
35
37
  askCommand,
36
38
  accessStatusCommand,
39
+ authStatusCommand,
37
40
  buildQuestions,
38
41
  assessReadiness,
39
42
  capabilitiesCommand,
@@ -58,6 +61,7 @@ import {
58
61
  repliesCommand,
59
62
  sendCommand,
60
63
  statusCommand,
64
+ updateCliCommand,
61
65
  agentSessionRenameCommand,
62
66
  type CommandDeps,
63
67
  type CommandIo,
@@ -74,15 +78,21 @@ import {
74
78
  QUESTION_STOP_TIMEOUT_SECONDS,
75
79
  settingsFile,
76
80
  } from './install-hooks.js'
77
- import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
81
+ import {
82
+ inspectQuestionState,
83
+ readSessionState,
84
+ writeProjectSession,
85
+ writeSessionState,
86
+ } from './hooks.js'
78
87
  import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
79
88
  import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, sessionConfigPath, stateDir } from './config.js'
80
89
  import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
81
90
  import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
82
91
  import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
83
- import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
92
+ import { hookAdapterPath, inspectHookAdapter, installHookAdapter } from './hook-adapter.js'
84
93
  import type { Tone } from './ui/theme.js'
85
94
  import { projectBinding, projectEnabled } from './project-enablement.js'
95
+ import { firstRequiredBlocker } from './readiness.js'
86
96
 
87
97
  afterEach(() => {
88
98
  resetLatestPublishedCliVersionForTest()
@@ -446,6 +456,16 @@ describe('command contracts', () => {
446
456
  expect(submitted?.draft.project).toBeUndefined()
447
457
  expect(projectEnabled(projectBinding(cwd, env))).toBe(false)
448
458
  })
459
+ it('tells an unsigned machine to run init, not login', () => {
460
+ const io = new CapturedIo()
461
+ const deps = makeDeps(io, {} as ApiClient)
462
+ deps.store.load = () => null
463
+
464
+ expect(authStatusCommand(deps, {})).toBe(EXIT.auth)
465
+ expect(io.errLines.join('\n')).toMatch(/Not signed in\. Run `notifai init`/)
466
+ expect(io.errLines.join('\n')).not.toContain('notifai login')
467
+ })
468
+
449
469
  it('shows an actionable no-plan access state', async () => {
450
470
  const io = new CapturedIo()
451
471
  const client = {
@@ -458,8 +478,8 @@ describe('command contracts', () => {
458
478
 
459
479
  expect(await accessStatusCommand(makeDeps(io, client), {})).toBe(EXIT.failed)
460
480
  expect(io.outLines).toEqual([
461
- 'No active plan or temporary Alpha access for this account.',
462
- 'next: Open https://test.notifai.invalid/support to request Alpha access, then retry.',
481
+ 'This account does not have access to Notifai yet.',
482
+ 'next: Open https://test.notifai.invalid/setup/access to set up access, then retry.',
463
483
  ])
464
484
  })
465
485
 
@@ -2114,6 +2134,10 @@ describe('command contracts', () => {
2114
2134
  closed: [],
2115
2135
  })
2116
2136
  expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
2137
+ expect(inspectQuestionState('q_local', deps.env)).toMatchObject({
2138
+ found: true,
2139
+ question: { state: 'withdrawn', submitted: false, request_id: null },
2140
+ })
2117
2141
  })
2118
2142
 
2119
2143
  it('close withdraws one unpushed question by stable id from another checkout', async () => {
@@ -2171,6 +2195,7 @@ describe('command contracts', () => {
2171
2195
  { question: 'Unpushed?', question_id: 'q_local' },
2172
2196
  {
2173
2197
  question: 'Already on a device?',
2198
+ question_id: 'q_live',
2174
2199
  request_id: receipt.request_id,
2175
2200
  collapse_key: 'question-live',
2176
2201
  device_ids: ['dev_iphone'],
@@ -2187,6 +2212,14 @@ describe('command contracts', () => {
2187
2212
  closed: [receipt.request_id],
2188
2213
  })
2189
2214
  expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
2215
+ expect(inspectQuestionState('q_local', deps.env)).toMatchObject({
2216
+ found: true,
2217
+ question: { state: 'withdrawn', submitted: false, request_id: null },
2218
+ })
2219
+ expect(inspectQuestionState('q_live', deps.env)).toMatchObject({
2220
+ found: true,
2221
+ question: { state: 'retired', submitted: true, request_id: receipt.request_id },
2222
+ })
2190
2223
  })
2191
2224
 
2192
2225
  it('close requires a request id or --pending', async () => {
@@ -2596,8 +2629,88 @@ describe('credential origin pinning', () => {
2596
2629
  expect(io.errLines).toEqual([
2597
2630
  'This account has no active plan or temporary Alpha access.',
2598
2631
  'next: Ask the account owner for Alpha access.',
2599
- 'After access is granted, run `notifai login` again.',
2632
+ 'After access is granted, run `notifai init` again.',
2600
2633
  ])
2634
+ expect(io.errLines.join('\n')).not.toMatch(/Pairing expired/i)
2635
+ expect(io.errLines.join('\n')).not.toContain('notifai login')
2636
+ expect(now).toBeLessThan(10_000)
2637
+ })
2638
+
2639
+ it('reports the access blocker to whoever asked for the sign-in', async () => {
2640
+ const io = new CapturedIo()
2641
+ let now = 0
2642
+ const client = {
2643
+ beginPairing: async () => ({
2644
+ pairing_id: 'pair_test',
2645
+ code: 'ABCD-EFGH',
2646
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2647
+ expires_at: new Date(10_000).toISOString(),
2648
+ poll_interval_seconds: 1,
2649
+ }),
2650
+ pollPairing: async () => ({
2651
+ status: 'no_active_plan',
2652
+ next_action: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
2653
+ }),
2654
+ } as unknown as ApiClient
2655
+ const deps: CommandDeps = {
2656
+ ...makeDeps(io, client),
2657
+ now: () => now,
2658
+ sleep: async (milliseconds: number) => {
2659
+ now += milliseconds
2660
+ },
2661
+ }
2662
+
2663
+ let blocked: { title: string; detail: string } | null = null
2664
+ expect(
2665
+ await loginCommand(deps, { open: false }, (state) => {
2666
+ blocked = state
2667
+ }),
2668
+ ).toBe(EXIT.auth)
2669
+ // Handing the blocker over hands over the errand. Saying it here too gave
2670
+ // the caller's close a second, differently worded copy to contradict.
2671
+ expect(io.errLines).toEqual([])
2672
+ // Without this the caller falls back to the state it held before the
2673
+ // attempt — "not paired … run `notifai init`" — and prints it under the
2674
+ // correct line, in contradiction with it.
2675
+ expect(blocked).toMatchObject({
2676
+ id: 'auth',
2677
+ title: 'Access',
2678
+ status: 'gap',
2679
+ detail: 'this account does not have access to Notifai yet',
2680
+ remedy: {
2681
+ by: 'user-elsewhere',
2682
+ summary: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
2683
+ },
2684
+ })
2685
+ })
2686
+
2687
+ it('reports expiry only when pairing times out without a no-access mark', async () => {
2688
+ const io = new CapturedIo()
2689
+ let now = 0
2690
+ const client = {
2691
+ beginPairing: async () => ({
2692
+ pairing_id: 'pair_test',
2693
+ code: 'ABCD-EFGH',
2694
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2695
+ expires_at: new Date(10_000).toISOString(),
2696
+ poll_interval_seconds: 1,
2697
+ }),
2698
+ pollPairing: async () => ({ status: 'expired' }),
2699
+ } as unknown as ApiClient
2700
+ const deps: CommandDeps = {
2701
+ ...makeDeps(io, client),
2702
+ now: () => now,
2703
+ sleep: async (milliseconds: number) => {
2704
+ now += milliseconds
2705
+ },
2706
+ }
2707
+
2708
+ expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
2709
+ expect(io.errLines).toEqual([
2710
+ 'Pairing expired before it was approved. Run `notifai init` again.',
2711
+ ])
2712
+ expect(io.errLines.join('\n')).not.toMatch(/no active plan/i)
2713
+ expect(io.errLines.join('\n')).not.toContain('notifai login')
2601
2714
  })
2602
2715
 
2603
2716
  it('keeps notification titles out of send logs', async () => {
@@ -2694,6 +2807,96 @@ describe('delivery evidence status', () => {
2694
2807
  }
2695
2808
  }
2696
2809
 
2810
+ it('reports every local question state by q identity without creating or submitting anything', async () => {
2811
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-question-status-'))
2812
+ const env = {
2813
+ XDG_CONFIG_HOME: path.join(root, 'config'),
2814
+ XDG_STATE_HOME: path.join(root, 'state'),
2815
+ }
2816
+ writeSessionState('question-states', env, {
2817
+ pending: [
2818
+ { question: 'Local?', question_id: 'q_local' },
2819
+ {
2820
+ question: 'Frozen?',
2821
+ question_id: 'q_frozen',
2822
+ submission: {
2823
+ request_id: 'req_frozen',
2824
+ idempotency_key: 'idem-frozen',
2825
+ collapse_key: 'collapse-frozen',
2826
+ device_ids: ['dev_status_test'],
2827
+ draft: {} as never,
2828
+ owner_deadline_at: Date.now() + 60_000,
2829
+ },
2830
+ },
2831
+ { question: 'Live?', question_id: 'q_live', request_id: 'req_live' },
2832
+ ],
2833
+ question_history: [
2834
+ { question_id: 'q_answered', state: 'answered', request_id: 'req_answered' },
2835
+ { question_id: 'q_withdrawn', state: 'withdrawn' },
2836
+ { question_id: 'q_retired', state: 'retired', request_id: 'req_retired' },
2837
+ ],
2838
+ })
2839
+ const evidenceCalls: string[] = []
2840
+ const client = {
2841
+ evidence: async (requestId: string) => {
2842
+ evidenceCalls.push(requestId)
2843
+ return { ...snapshot({ state: 'unknown', observed_at: null, latency_ms: null }), request_id: requestId }
2844
+ },
2845
+ } as unknown as ApiClient
2846
+ const io = new CapturedIo()
2847
+ const deps = { ...makeDeps(io, client), cwd: root, env }
2848
+
2849
+ const expected = [
2850
+ ['q_local', 'local', false, null, null],
2851
+ ['q_frozen', 'frozen', null, null, 'req_frozen'],
2852
+ ['q_live', 'live', true, 'req_live', null],
2853
+ ['q_answered', 'answered', true, 'req_answered', null],
2854
+ ['q_withdrawn', 'withdrawn', false, null, null],
2855
+ ['q_retired', 'retired', true, 'req_retired', null],
2856
+ ] as const
2857
+ for (const [questionId, state, submitted, requestId, frozenRequestId] of expected) {
2858
+ io.outLines = []
2859
+ expect(await statusCommand(deps, questionId, { json: true })).toBe(EXIT.ok)
2860
+ expect(JSON.parse(io.outLines.join('\n'))).toMatchObject({
2861
+ question_id: questionId,
2862
+ state,
2863
+ submitted,
2864
+ request_id: requestId,
2865
+ frozen_request_id: frozenRequestId,
2866
+ recovery: {
2867
+ inspect: `notifai status ${questionId}`,
2868
+ close: `notifai close ${questionId}`,
2869
+ register_replacement: false,
2870
+ },
2871
+ })
2872
+ }
2873
+ expect(evidenceCalls).toEqual(['req_live', 'req_answered', 'req_retired'])
2874
+ })
2875
+
2876
+ it('states plainly that a local registration has no submission or Provider Acceptance', async () => {
2877
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-local-question-status-'))
2878
+ const env = { XDG_STATE_HOME: path.join(root, 'state') }
2879
+ writeSessionState('local-question', env, {
2880
+ pending: [{ question: 'Ship?', question_id: 'q_local_plain' }],
2881
+ })
2882
+ const io = new CapturedIo()
2883
+ const client = {
2884
+ evidence: async () => {
2885
+ throw new Error('local status must not call the service')
2886
+ },
2887
+ } as unknown as ApiClient
2888
+
2889
+ expect(
2890
+ await statusCommand({ ...makeDeps(io, client), cwd: root, env }, 'q_local_plain', {}),
2891
+ ).toBe(EXIT.ok)
2892
+ const said = io.outLines.join('\n')
2893
+ expect(said).toContain('question q_local_plain — local')
2894
+ expect(said).toContain('not submitted')
2895
+ expect(said).toContain('only a local registration')
2896
+ expect(said).toContain('Provider Acceptance: not available')
2897
+ expect(said).toContain('do not register a replacement')
2898
+ })
2899
+
2697
2900
  it('calls an unobserved first-minute receipt unknown rather than failed', async () => {
2698
2901
  const io = new CapturedIo()
2699
2902
  const client = {
@@ -3277,6 +3480,71 @@ describe('harness activation guidance', () => {
3277
3480
  expect(inspectHookAdapter(deps.hookAdapterHome).problems).toEqual([])
3278
3481
  expect(inspectHookAdapter(deps.hookAdapterHome).target?.scriptPath).toBe(scriptPath)
3279
3482
  })
3483
+
3484
+ it('installs an owned OpenClaw plugin and reports unsupported continuation', async () => {
3485
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-activation-'))
3486
+ const io = new CapturedIo()
3487
+ const client = {
3488
+ health: async () => true,
3489
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3490
+ listDevices: async () => ({ devices: [] }),
3491
+ } as unknown as ApiClient
3492
+ const home = path.join(cwd, 'home')
3493
+ const deps = {
3494
+ ...makeDeps(io, client),
3495
+ cwd,
3496
+ env: {
3497
+ HOME: home,
3498
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3499
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3500
+ OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
3501
+ },
3502
+ }
3503
+
3504
+ expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.ok)
3505
+ expect(io.outLines.join('\n')).toContain('Installed openclaw hooks in')
3506
+ expect(io.outLines.join('\n')).toContain(
3507
+ 'Restart the OpenClaw Gateway, start one fresh Agent Session, send one prompt, then run `notifai doctor`.',
3508
+ )
3509
+ const pluginFile = path.join(cwd, '.openclaw', 'extensions', 'notifai', 'index.js')
3510
+ const plugin = readFileSync(pluginFile, 'utf8')
3511
+ expect(plugin).toContain('api.on("before_prompt_build"')
3512
+ expect(plugin).toContain('api.on("resolve_exec_env"')
3513
+ expect(plugin).not.toContain('command:stop')
3514
+ const config = JSON.parse(
3515
+ readFileSync(path.join(cwd, 'openclaw-home', 'openclaw.json'), 'utf8'),
3516
+ ) as {
3517
+ plugins: { entries: { notifai: { enabled: boolean; hooks: { allowConversationAccess: boolean } } } }
3518
+ }
3519
+ expect(config.plugins.entries.notifai).toEqual({
3520
+ enabled: true,
3521
+ hooks: { allowConversationAccess: true },
3522
+ })
3523
+
3524
+ io.outLines = []
3525
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
3526
+ expect(io.outLines.join('\n')).toContain('no proven answer continuation')
3527
+ })
3528
+
3529
+ it('refuses to overwrite a foreign OpenClaw plugin', () => {
3530
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-foreign-'))
3531
+ const plugin = path.join(cwd, '.openclaw', 'extensions', 'notifai', 'index.js')
3532
+ mkdirSync(path.dirname(plugin), { recursive: true })
3533
+ writeFileSync(plugin, 'export default { id: "foreign" }\n')
3534
+ const io = new CapturedIo()
3535
+ const deps = {
3536
+ ...makeDeps(io, {} as ApiClient),
3537
+ cwd,
3538
+ env: {
3539
+ HOME: path.join(cwd, 'home'),
3540
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3541
+ OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
3542
+ },
3543
+ }
3544
+ expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.failed)
3545
+ expect(io.errLines.join('\n')).toMatch(/was not written by Notifai/)
3546
+ expect(readFileSync(plugin, 'utf8')).toBe('export default { id: "foreign" }\n')
3547
+ })
3280
3548
  })
3281
3549
 
3282
3550
  describe('stable hook installation', () => {
@@ -4074,7 +4342,7 @@ describe('interactive command UX', () => {
4074
4342
 
4075
4343
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
4076
4344
  expect(io.outLines).toContain('A newer Notifai is available.')
4077
- expect(io.outLines).toContain('npm install -g @raidiant/notifai')
4345
+ expect(io.outLines).toContain(updateCliCommand(deps))
4078
4346
  })
4079
4347
  })
4080
4348
 
@@ -4085,7 +4353,6 @@ describe('compatibility-first update guidance', () => {
4085
4353
  status: 'ready' | 'optional-gap' | 'gap'
4086
4354
  detail: string
4087
4355
  exit: number
4088
- humanLines: string[]
4089
4356
  }> = [
4090
4357
  {
4091
4358
  name: 'current',
@@ -4093,7 +4360,6 @@ describe('compatibility-first update guidance', () => {
4093
4360
  status: 'ready',
4094
4361
  detail: 'Notifai can send notifications.',
4095
4362
  exit: EXIT.ok,
4096
- humanLines: [],
4097
4363
  },
4098
4364
  {
4099
4365
  name: 'optional newer release',
@@ -4106,10 +4372,6 @@ describe('compatibility-first update guidance', () => {
4106
4372
  status: 'optional-gap',
4107
4373
  detail: 'A newer Notifai is available.',
4108
4374
  exit: EXIT.ok,
4109
- humanLines: [
4110
- 'A newer Notifai is available.',
4111
- 'npm install -g @raidiant/notifai',
4112
- ],
4113
4375
  },
4114
4376
  {
4115
4377
  name: 'scheduled Sunset',
@@ -4124,10 +4386,6 @@ describe('compatibility-first update guidance', () => {
4124
4386
  status: 'optional-gap',
4125
4387
  detail: 'Update Notifai soon to keep sending notifications.',
4126
4388
  exit: EXIT.ok,
4127
- humanLines: [
4128
- 'Update Notifai soon to keep sending notifications.',
4129
- 'npm install -g @raidiant/notifai',
4130
- ],
4131
4389
  },
4132
4390
  {
4133
4391
  name: 'required update',
@@ -4142,16 +4400,12 @@ describe('compatibility-first update guidance', () => {
4142
4400
  status: 'gap',
4143
4401
  detail: "Notifai can't send notifications until you update.",
4144
4402
  exit: EXIT.failed,
4145
- humanLines: [
4146
- "Notifai can't send notifications until you update.",
4147
- 'npm install -g @raidiant/notifai',
4148
- ],
4149
4403
  },
4150
4404
  ]
4151
4405
 
4152
4406
  it.each(cases)(
4153
4407
  'maps $name policy to closed human copy and structured JSON',
4154
- async ({ support, status, detail, exit, humanLines }) => {
4408
+ async ({ support, status, detail, exit }) => {
4155
4409
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-guidance-'))
4156
4410
  const client = {
4157
4411
  health: async () => true,
@@ -4172,7 +4426,9 @@ describe('compatibility-first update guidance', () => {
4172
4426
  expect(
4173
4427
  await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
4174
4428
  ).toBe(exit)
4175
- expect(humanIo.outLines).toEqual(humanLines)
4429
+ expect(humanIo.outLines).toEqual(
4430
+ status === 'ready' ? [] : [detail, updateCliCommand(deps)],
4431
+ )
4176
4432
  expect(humanIo.errLines).toEqual([])
4177
4433
  expect(humanIo.outros).toEqual(
4178
4434
  status === 'ready' ? ['Everything looks good'] : [],
@@ -4202,7 +4458,7 @@ describe('compatibility-first update guidance', () => {
4202
4458
  states: [{ status, detail }],
4203
4459
  })
4204
4460
  expect(payload.states[0]?.remedy?.command ?? null).toBe(
4205
- status === 'ready' ? null : 'npm install -g @raidiant/notifai',
4461
+ status === 'ready' ? null : updateCliCommand(deps),
4206
4462
  )
4207
4463
  expect(jsonIo.errLines).toEqual([])
4208
4464
 
@@ -4271,7 +4527,7 @@ describe('compatibility-first update guidance', () => {
4271
4527
  await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
4272
4528
  ).toBe(EXIT.ok)
4273
4529
  expect(io.outLines).toEqual(['The service is being updated; try again later.'])
4274
- expect(io.outLines.join('\n')).not.toContain('npm install -g @raidiant/notifai')
4530
+ expect(io.outLines.join('\n')).not.toContain('notifai update')
4275
4531
 
4276
4532
  const sendIo = new CapturedIo()
4277
4533
  expect(
@@ -4484,7 +4740,7 @@ describe('init', () => {
4484
4740
  }),
4485
4741
  ).toBe(EXIT.failed)
4486
4742
  expect(io.outLines).toContain("Notifai can't send notifications until you update.")
4487
- expect(io.outLines).toContain('npm install -g @raidiant/notifai')
4743
+ expect(io.outLines).toContain(updateCliCommand(deps))
4488
4744
  expect(io.outros).toEqual([])
4489
4745
  expect(submissions).toBe(0)
4490
4746
 
@@ -4504,10 +4760,54 @@ describe('init', () => {
4504
4760
  expect(io.outLines).toContain(
4505
4761
  'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
4506
4762
  )
4507
- expect(io.outLines).not.toContain('npm install -g @raidiant/notifai')
4763
+ expect(io.outLines).not.toContain('notifai update')
4508
4764
  expect(io.errLines).toEqual([])
4509
4765
  })
4510
4766
 
4767
+ it('retargets Question Routing while the post-update init resumes', async () => {
4768
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-init-update-adapter-'))
4769
+ const io = new CapturedIo()
4770
+ const nativeSkills: NativeSkills = {
4771
+ add: async () => 0,
4772
+ remove: async () => 0,
4773
+ list: async () => ({ skills: [] }),
4774
+ }
4775
+ const deps = setupReadyDeps(io, cwd, nativeSkills, { submit: 0 })
4776
+ const oldArtifact = path.join(cwd, 'old', 'dist', 'main.js')
4777
+ const prefix = path.join(cwd, 'current')
4778
+ const packageRoot = path.join(prefix, 'lib', 'node_modules', '@raidiant', 'notifai')
4779
+ const currentArtifact = path.join(packageRoot, 'dist', 'main.js')
4780
+ const currentCommand = path.join(prefix, 'bin', 'notifai')
4781
+ mkdirSync(path.dirname(oldArtifact), { recursive: true })
4782
+ mkdirSync(path.dirname(currentArtifact), { recursive: true })
4783
+ mkdirSync(path.dirname(currentCommand), { recursive: true })
4784
+ writeFileSync(oldArtifact, '#!/usr/bin/env node\n')
4785
+ writeFileSync(currentArtifact, '#!/usr/bin/env node\n')
4786
+ writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version: RELEASE_REF.slice(1) }))
4787
+ chmodSync(oldArtifact, 0o755)
4788
+ chmodSync(currentArtifact, 0o755)
4789
+ symlinkSync(path.relative(path.dirname(currentCommand), currentArtifact), currentCommand)
4790
+ deps.env = { ...deps.env, PATH: path.dirname(currentCommand) }
4791
+ deps.hookInstallTarget = { execPath: process.execPath, scriptPath: currentArtifact }
4792
+
4793
+ installHookAdapter({ execPath: process.execPath, scriptPath: oldArtifact }, deps.hookAdapterHome)
4794
+ applyPlan(path.join(cwd, '.codex', 'hooks.json'), {
4795
+ hooks: buildHookConfig({
4796
+ adapterPath: hookAdapterPath(deps.hookAdapterHome),
4797
+ harness: 'codex',
4798
+ }),
4799
+ })
4800
+
4801
+ expect(inspectHookAdapter(deps.hookAdapterHome).target).toMatchObject({ scriptPath: oldArtifact })
4802
+ expect(
4803
+ await initCommand(deps, { hooks: false, setupScope: 'project', skills: false }),
4804
+ ).toBe(EXIT.ok)
4805
+ expect(inspectHookAdapter(deps.hookAdapterHome).target).toMatchObject({
4806
+ scriptPath: realpathSync(currentArtifact),
4807
+ })
4808
+ expect(io.outLines.join('\n')).not.toContain('notifai hooks install')
4809
+ })
4810
+
4511
4811
  it('surfaces one next step, not the whole remaining list', async () => {
4512
4812
  // The behavioural core of the design: someone handed five things to do
4513
4813
  // does none of them. Signing in gates the device check, so naming both
@@ -4524,8 +4824,9 @@ describe('init', () => {
4524
4824
  expect(await initCommand(deps, {})).toBe(EXIT.failed)
4525
4825
  const out = io.outLines.join('\n')
4526
4826
  expect(out).toContain('Next: This machine')
4527
- expect(out).toContain('notifai login')
4528
- expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
4827
+ expect(out).toContain('notifai init')
4828
+ expect(out).not.toContain('notifai login')
4829
+ expect(out).not.toContain('Then re-run')
4529
4830
  // The device gap is real and downstream; it must stay hidden until the
4530
4831
  // sign-in that would let anyone actually check it has happened.
4531
4832
  expect(out).not.toContain('companion app')
@@ -4549,19 +4850,23 @@ describe('init', () => {
4549
4850
  )
4550
4851
  })
4551
4852
 
4552
- it('does not let an unattended readiness pass silently choose project scope', async () => {
4853
+ it('closes what a process can close when no scope was passed, and asks nothing', async () => {
4553
4854
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-agent-'))
4554
4855
  const io = new CapturedIo()
4555
4856
  const deps = { ...makeDeps(io, {} as ApiClient), cwd }
4556
4857
 
4557
4858
  expect(await initCommand(deps, { json: true, hooks: false, skills: false })).toBe(EXIT.failed)
4558
- expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
4859
+ // Naming this checkout is not skill or hook placement, so it no longer
4860
+ // waits on a scope question nobody can answer here.
4861
+ expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain('project = "')
4559
4862
  const result = JSON.parse(io.outLines[0] ?? '{}') as {
4560
- states: Array<{ id: string; status: string }>
4863
+ states: Array<{ id: string; status: string; remedy: { command?: string } | null }>
4561
4864
  }
4562
- expect(result.states.find((state) => state.id === 'project')).toMatchObject({
4563
- status: 'optional-gap',
4564
- })
4865
+ expect(result.states.find((state) => state.id === 'project')).toMatchObject({ status: 'ready' })
4866
+ // The run stops where a human genuinely is required, and never hands back
4867
+ // the command that produced this output as the thing to do about it.
4868
+ const project = result.states.find((state) => state.id === 'project')
4869
+ expect(project?.remedy).toBeNull()
4565
4870
  expect(io.errLines).toEqual([])
4566
4871
  })
4567
4872
 
@@ -5019,7 +5324,11 @@ describe('init', () => {
5019
5324
  expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(true)
5020
5325
  expect(io.outLines.join('\n')).not.toContain('Installed claude-code hooks')
5021
5326
  expect(io.outLines.join('\n')).not.toContain('Installed codex hooks')
5022
- expect(io.outLines.join('\n')).toContain('Next: Codex hook trust')
5327
+ // A hook diagnostic is a report line. The run it appears in still reaches
5328
+ // the delivery proof, which is the thing `init` is there to produce.
5329
+ expect(io.outLines.join('\n')).toContain('Companion Receipt')
5330
+ expect(io.outLines.join('\n')).toContain('All set.')
5331
+ expect(io.outLines.join('\n')).not.toContain('Next: Codex hook trust')
5023
5332
  })
5024
5333
 
5025
5334
  it('lets a human keep a subset of the detected harnesses', async () => {
@@ -5071,7 +5380,8 @@ describe('init', () => {
5071
5380
  }
5072
5381
 
5073
5382
  expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
5074
- expect(io.outLines.join('\n')).toContain('notifai login')
5383
+ expect(io.outLines.join('\n')).toContain('notifai init')
5384
+ expect(io.outLines.join('\n')).not.toContain('notifai login')
5075
5385
  })
5076
5386
 
5077
5387
  it('announces sign-in for a present human without re-confirming', async () => {
@@ -5104,7 +5414,8 @@ describe('init', () => {
5104
5414
  const out = io.outLines.join('\n')
5105
5415
  expect(out).toContain('Opening your browser to approve this machine — Ctrl-C to stop.')
5106
5416
  expect(out).toContain('Next: This machine')
5107
- expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
5417
+ expect(out).toContain('notifai init')
5418
+ expect(out).not.toContain('notifai login')
5108
5419
  })
5109
5420
 
5110
5421
  it('never prompts or opens a browser when an agent runs it unattended', async () => {
@@ -5152,11 +5463,11 @@ describe('init', () => {
5152
5463
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
5153
5464
  const out = io.outLines.join('\n')
5154
5465
  expect(out).toContain('Next: Your devices')
5155
- expect(out).toContain('https://test.notifai.invalid/support')
5466
+ // One focused destination for this errand, never the omnibus help page.
5467
+ expect(out).toContain('https://test.notifai.invalid/setup/companion')
5468
+ expect(out).not.toContain('/support')
5156
5469
  expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
5157
- expect(out).toContain(
5158
- 'no active Companion device registered yet; install Notifai via https://test.notifai.invalid/support',
5159
- )
5470
+ expect(out).toContain('no active Companion App registered yet')
5160
5471
  expect(out.match(/^Next:/gm)).toHaveLength(1)
5161
5472
  })
5162
5473
 
@@ -5271,6 +5582,9 @@ describe('init', () => {
5271
5582
  },
5272
5583
  }
5273
5584
 
5585
+ // The terminal asks which phone, in those words, and the destination it
5586
+ // opens is already about that answer — nobody chooses twice.
5587
+ io.selectAnswer = 'iphone'
5274
5588
  expect(
5275
5589
  await initCommand(deps, {
5276
5590
  hooks: false,
@@ -5279,10 +5593,11 @@ describe('init', () => {
5279
5593
  }),
5280
5594
  ).toBe(EXIT.ok)
5281
5595
  expect(io.prompts).toEqual([
5282
- 'Open install instructions in your browser?',
5283
- 'Wait here while you finish that on your device?',
5596
+ 'Where do you want to receive notifications?',
5597
+ 'Open those steps in your browser?',
5598
+ 'Wait here while you finish that on your phone?',
5284
5599
  ])
5285
- expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
5600
+ expect(io.openedUrls).toEqual(['https://test.notifai.invalid/setup/companion?platform=iphone'])
5286
5601
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
5287
5602
  expect(io.spinnerEvents).toContain(
5288
5603
  'message:Waiting for a Companion App to sign in and register…',
@@ -5296,6 +5611,11 @@ describe('init', () => {
5296
5611
  expect(submitCalls).toBe(1)
5297
5612
  expect(submittedDraft?.draft).not.toHaveProperty('event')
5298
5613
  expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
5614
+ // The proof is a real send that has to be seen: a normal banner and sound,
5615
+ // not a silent arrival in Notification Center a first-time User never
5616
+ // looks at. Its sound is left to the server's kind/Project/Account layers.
5617
+ expect(submittedDraft?.draft.platform).toEqual({ ios: { interruption_level: 'active' } })
5618
+ expect(submittedDraft?.draft.kind).toBe('done')
5299
5619
 
5300
5620
  io.outLines = []
5301
5621
  io.prompts = []
@@ -5402,7 +5722,8 @@ describe('init', () => {
5402
5722
  ).toBe(EXIT.failed)
5403
5723
  const out = io.outLines.join('\n')
5404
5724
  expect(out).toContain('Next: Your devices')
5405
- expect(out).toContain('https://test.notifai.invalid/support')
5725
+ expect(out).toContain('https://test.notifai.invalid/setup/companion')
5726
+ expect(out).not.toContain('/support')
5406
5727
  expect(out).toContain('sign in with the same email as this account (agent@example.com)')
5407
5728
  expect(io.openedUrls).toEqual([])
5408
5729
  })
@@ -5553,6 +5874,57 @@ describe('init', () => {
5553
5874
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
5554
5875
  })
5555
5876
 
5877
+ it('writes the first notification about the User, not about the check', async () => {
5878
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-copy-'))
5879
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
5880
+ writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "orders-api"\n')
5881
+ const io = new CapturedIo()
5882
+ let submitted: SubmitNotificationRequestT | null = null
5883
+ const client = {
5884
+ health: async () => true,
5885
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5886
+ listDevices: async () => ({ devices: [readyIphone] }),
5887
+ submit: async (body: SubmitNotificationRequestT) => {
5888
+ submitted = body
5889
+ return setupReceipt()
5890
+ },
5891
+ evidence: async (requestId: string) =>
5892
+ setupEvidence(requestId, {
5893
+ state: 'observed',
5894
+ observed_at: '2026-08-05T18:00:02.000Z',
5895
+ latency_ms: 1_000,
5896
+ }),
5897
+ } as unknown as ApiClient
5898
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
5899
+
5900
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
5901
+ const presentation = submitted!.draft.presentation
5902
+
5903
+ // A title says what is now true for the reader, alone on a lock screen,
5904
+ // and carries neither the kind nor the Project — both travel as their own
5905
+ // fields.
5906
+ expect(presentation.title).toBe('Your agents can reach you')
5907
+ expect(presentation.title.length).toBeLessThanOrEqual(40)
5908
+ expect(presentation.title).not.toMatch(/orders-api|done/i)
5909
+
5910
+ // None of the vocabulary of the check that produced it: the reader has not
5911
+ // met "verification", a "proof", or the distinction between a real
5912
+ // notification and any other kind.
5913
+ const copy = `${presentation.title} ${presentation.body}`
5914
+ expect(copy).not.toMatch(/verification|verify|proof|receipt|real notification|test/i)
5915
+ // And nothing about where it landed or what to do to it.
5916
+ expect(copy).not.toMatch(/iPhone|Android|phone|tap|swipe|banner|lock screen/i)
5917
+
5918
+ expect(presentation.body).toContain('orders-api')
5919
+ expect(presentation.body).toMatch(/^Notifai setup is finished/)
5920
+
5921
+ // Still the same bar: a real send whose Companion Receipt was observed.
5922
+ expect(io.outLines.join('\n')).toContain(
5923
+ "Companion Receipt (the app's delivery confirmation) observed from iPhone.",
5924
+ )
5925
+ expect(io.outLines.join('\n')).toContain('All set.')
5926
+ })
5927
+
5556
5928
  it('treats Android as active Companion readiness and sends a platform-correct proof', async () => {
5557
5929
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-android-proof-'))
5558
5930
  mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
@@ -5591,7 +5963,12 @@ describe('init', () => {
5591
5963
  mode: 'selected',
5592
5964
  device_ids: [readyAndroid.device_id],
5593
5965
  })
5594
- expect(submitted?.draft.platform).toEqual({ android: { sound: null } })
5966
+ // Android has no caller-selected interruption level, so a configured Apple
5967
+ // preference must not reach it — and nothing else is stamped either, so the
5968
+ // server's kind sound applies rather than the old hard silence.
5969
+ expect(submitted?.draft.platform).toBeUndefined()
5970
+ expect(JSON.stringify(submitted?.draft)).not.toContain('interruption_level')
5971
+ expect(submitted?.draft.kind).toBe('done')
5595
5972
  expect(io.outLines.join('\n')).toContain(
5596
5973
  "Companion Receipt (the app's delivery confirmation) observed from Pixel.",
5597
5974
  )
@@ -5623,7 +6000,7 @@ describe('init', () => {
5623
6000
  expect(submitCalls).toBe(0)
5624
6001
  const out = io.outLines.join('\n')
5625
6002
  expect(out).toContain('Next: Your devices')
5626
- expect(out).toContain('no active Companion device registered yet')
6003
+ expect(out).toContain('no active Companion App registered yet')
5627
6004
  expect(out).not.toContain('All set.')
5628
6005
  expect(out).not.toMatch(/Companion Receipt observed/i)
5629
6006
 
@@ -5640,13 +6017,159 @@ describe('init', () => {
5640
6017
  ),
5641
6018
  ).toBe(EXIT.failed)
5642
6019
  const doctorOut = doctorIo.outLines.join('\n')
5643
- expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
5644
- expect(doctorOut).toContain('no active Companion device registered yet')
6020
+ expect(doctorOut).toMatch(/FAIL\s+Your devices: no active Companion App registered yet/)
5645
6021
  expect(doctorOut).toContain(
5646
6022
  'Delivery proof: not checked — no iPhone or Android Companion App is ready',
5647
6023
  )
5648
6024
  })
5649
6025
 
6026
+ it('says access is already requested rather than asking for it again', async () => {
6027
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-access-requested-'))
6028
+ const io = new CapturedIo()
6029
+ const nextAction = 'Open https://app.notifai.sh/setup/access to set up access, then retry.'
6030
+ const client = {
6031
+ health: async () => true,
6032
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
6033
+ listDevices: async () => {
6034
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
6035
+ },
6036
+ accessStatus: async () => ({
6037
+ status: 'no_active_plan',
6038
+ reason: 'no_active_grant',
6039
+ expires_at: null,
6040
+ email: 'waiting@example.test',
6041
+ public_v1_cutover: false,
6042
+ }),
6043
+ accessRequest: async () => ({
6044
+ request: {
6045
+ status: 'requested',
6046
+ requested_at: '2026-08-21T09:15:00.000Z',
6047
+ updated_at: '2026-08-21T09:15:00.000Z',
6048
+ },
6049
+ }),
6050
+ } as unknown as ApiClient
6051
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
6052
+
6053
+ const readiness = await assessReadiness(deps)
6054
+ const auth = readiness.states.find((state) => state.id === 'auth')
6055
+ // The middle value. Without it, someone who asked in August is told to ask
6056
+ // again on every run, forever, for a wait no command of theirs can shorten.
6057
+ expect(auth?.detail).toContain('access requested on 2026-08-21')
6058
+ expect(auth?.detail).toContain('waiting@example.test')
6059
+ expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere' })
6060
+ expect(JSON.stringify(auth)).not.toContain('/setup/access')
6061
+
6062
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
6063
+ const out = io.outLines.join('\n')
6064
+ expect(out).toContain('nothing is needed from you')
6065
+ expect(out.match(/^Next:/gm)).toHaveLength(1)
6066
+ })
6067
+
6068
+ it('asks for access only when this account has never asked', async () => {
6069
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-access-never-asked-'))
6070
+ const io = new CapturedIo()
6071
+ const nextAction = 'Open https://app.notifai.sh/setup/access to set up access, then retry.'
6072
+ const client = {
6073
+ health: async () => true,
6074
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
6075
+ listDevices: async () => {
6076
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
6077
+ },
6078
+ accessStatus: async () => ({
6079
+ status: 'no_active_plan',
6080
+ reason: 'no_active_grant',
6081
+ expires_at: null,
6082
+ email: 'fresh@example.test',
6083
+ public_v1_cutover: false,
6084
+ }),
6085
+ accessRequest: async () => ({ request: null }),
6086
+ } as unknown as ApiClient
6087
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
6088
+
6089
+ const auth = (await assessReadiness(deps)).states.find((state) => state.id === 'auth')
6090
+ expect(auth?.detail).toContain('does not have access to Notifai yet')
6091
+ expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
6092
+ })
6093
+
6094
+ it('does not let a duplicate agent-skill install stand in front of the proof', async () => {
6095
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-'))
6096
+ const io = new CapturedIo()
6097
+ const calls = { submit: 0 }
6098
+ const nativeSkills: NativeSkills = {
6099
+ add: async () => 0,
6100
+ remove: async () => 0,
6101
+ // Both scopes installed: the harness lists two, which is a real problem
6102
+ // and a human decision — and still not a reason to withhold the proof.
6103
+ list: async (scope) => ({ skills: [managedSkill(scope ?? 'project', cwd)] }),
6104
+ }
6105
+ const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
6106
+
6107
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
6108
+ const readiness = await assessReadiness(deps)
6109
+ expect(readiness.states.find((state) => state.id === 'skill')?.status).toBe('gap')
6110
+ expect(firstRequiredBlocker(readiness)).toBeNull()
6111
+ expect(calls.submit).toBe(1)
6112
+ expect(io.outLines.join('\n')).toContain('All set.')
6113
+ })
6114
+
6115
+ it('closes on the blocker that stopped the run, not the state before it', async () => {
6116
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-login-access-'))
6117
+ const io = new InteractiveIo()
6118
+ let now = 0
6119
+ const client = {
6120
+ health: async () => true,
6121
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
6122
+ beginPairing: async () => ({
6123
+ pairing_id: 'pair_test',
6124
+ code: 'ABCD-EFGH',
6125
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
6126
+ expires_at: new Date(10_000).toISOString(),
6127
+ poll_interval_seconds: 1,
6128
+ }),
6129
+ pollPairing: async () => ({
6130
+ status: 'no_active_plan',
6131
+ next_action: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
6132
+ }),
6133
+ } as unknown as ApiClient
6134
+ const deps: CommandDeps = {
6135
+ ...makeDeps(io, client),
6136
+ cwd,
6137
+ env: isolatedEnv(cwd),
6138
+ now: () => now,
6139
+ sleep: async (milliseconds: number) => {
6140
+ now += milliseconds
6141
+ },
6142
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
6143
+ }
6144
+
6145
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
6146
+ const out = io.outLines.join('\n')
6147
+ // One next step, and it is the one that actually stopped the run. The
6148
+ // pairing state that preceded it would send the reader straight back into
6149
+ // the command that just failed, for a reason it never names.
6150
+ expect(out.match(/^Next:/gm)).toHaveLength(1)
6151
+ expect(out).toContain('Next: Access')
6152
+ expect(out).toContain('https://app.notifai.sh/setup/access')
6153
+ expect(out).not.toContain('not paired with your account')
6154
+
6155
+ // …and the wall is stated once across the whole visit. It used to arrive
6156
+ // three ways — a lowercase `next:` from the pairing failure, an "after
6157
+ // access … run `notifai init`" remedy, and this close — so a stopped User
6158
+ // had to work out which of three destinations-and-commands was theirs.
6159
+ const said = [
6160
+ ...io.outLines,
6161
+ ...io.errLines,
6162
+ ...io.notes.map((note) => note.message),
6163
+ ...io.spinnerEvents,
6164
+ ].join('\n')
6165
+ expect(said.match(/setup\/access/g)).toHaveLength(1)
6166
+ expect(said.match(/does not have access/g)).toHaveLength(1)
6167
+ expect(said).not.toContain('After access is granted')
6168
+ expect(said).not.toContain('next: ')
6169
+ // Rerunning is how they resume, not how they get access.
6170
+ expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
6171
+ })
6172
+
5650
6173
  it('treats a revoked credential as the one blocker and points back to pairing', async () => {
5651
6174
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-revoked-'))
5652
6175
  const io = new CapturedIo()
@@ -5667,8 +6190,86 @@ describe('init', () => {
5667
6190
  const out = io.outLines.join('\n')
5668
6191
  expect(out).toContain('Next: Account')
5669
6192
  expect(out).toContain('pair it again')
5670
- expect(out).toContain('notifai login')
6193
+ expect(out).toContain('notifai init')
6194
+ expect(out).not.toContain('notifai login')
6195
+ expect(out.match(/^Next:/gm)).toHaveLength(1)
6196
+ })
6197
+
6198
+ it('names the support next action when a paired account has no active plan', async () => {
6199
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-no-plan-'))
6200
+ const io = new CapturedIo()
6201
+ const nextAction = 'Open https://app.notifai.sh/support to request Alpha access, then retry.'
6202
+ const client = {
6203
+ health: async () => true,
6204
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
6205
+ listDevices: async () => {
6206
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
6207
+ },
6208
+ accessStatus: async () => ({
6209
+ status: 'no_active_plan',
6210
+ reason: 'no_active_grant',
6211
+ expires_at: null,
6212
+ email: 'rafael@example.test',
6213
+ }),
6214
+ } as unknown as ApiClient
6215
+ const deps = {
6216
+ ...makeDeps(io, client),
6217
+ cwd,
6218
+ env: isolatedEnv(cwd),
6219
+ }
6220
+
6221
+ const readiness = await assessReadiness(deps)
6222
+ const auth = readiness.states.find((state) => state.id === 'auth')
6223
+ expect(auth).toMatchObject({
6224
+ status: 'gap',
6225
+ detail: 'this account does not have access to Notifai yet (rafael@example.test)',
6226
+ remedy: { by: 'user-elsewhere', summary: nextAction },
6227
+ })
6228
+ expect(auth?.remedy && 'command' in auth.remedy ? auth.remedy.command : undefined).toBeUndefined()
6229
+
6230
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
6231
+ const out = io.outLines.join('\n')
6232
+ expect(out).toContain('Next: Account')
6233
+ expect(out).toContain(nextAction)
6234
+ expect(out).not.toContain('pair it again')
6235
+ expect(out).not.toContain('notifai login')
5671
6236
  expect(out.match(/^Next:/gm)).toHaveLength(1)
6237
+
6238
+ const doctorIo = new CapturedIo()
6239
+ expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.failed)
6240
+ const payload = JSON.parse(doctorIo.outLines[0] ?? '{}') as {
6241
+ states: Array<{ id: string; remedy?: { summary?: string; command?: string } }>
6242
+ }
6243
+ const doctorAuth = payload.states.find((state) => state.id === 'auth')
6244
+ expect(doctorAuth?.remedy?.summary).toBe(nextAction)
6245
+ expect(doctorAuth?.remedy?.command).toBeUndefined()
6246
+ })
6247
+
6248
+ it('does not treat a listDevices no-plan 403 as a revoked machine when accessStatus fails', async () => {
6249
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-no-plan-devices-only-'))
6250
+ const io = new CapturedIo()
6251
+ const nextAction = 'Open https://app.notifai.sh/support to request Alpha access, then retry.'
6252
+ const client = {
6253
+ health: async () => true,
6254
+ listDevices: async () => {
6255
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
6256
+ },
6257
+ accessStatus: async () => {
6258
+ throw new ApiCallError(500, 'internal_error', 'access status unavailable')
6259
+ },
6260
+ } as unknown as ApiClient
6261
+ const deps = {
6262
+ ...makeDeps(io, client),
6263
+ cwd,
6264
+ env: isolatedEnv(cwd),
6265
+ }
6266
+
6267
+ const readiness = await assessReadiness(deps)
6268
+ const auth = readiness.states.find((state) => state.id === 'auth')
6269
+ expect(auth?.status).toBe('gap')
6270
+ expect(auth?.detail).toContain('does not have access to Notifai yet')
6271
+ expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
6272
+ expect(JSON.stringify(auth)).not.toContain('pair it again')
5672
6273
  })
5673
6274
 
5674
6275
  it('reuses proof across worktrees of one Project on the same Approved Machine', async () => {
@@ -6134,7 +6735,7 @@ describe('asking before the hooks have ever run', () => {
6134
6735
  io.outLines = []
6135
6736
 
6136
6737
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
6137
- expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
6738
+ expect(io.outLines.some((line) => line.startsWith('Question registered locally (q_'))).toBe(true)
6138
6739
  expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
6139
6740
  session_id: 'codex-current-thread',
6140
6741
  harness: 'codex',
@@ -6142,6 +6743,28 @@ describe('asking before the hooks have ever run', () => {
6142
6743
  expect(
6143
6744
  readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
6144
6745
  ).toBe('Gentle Salmon')
6746
+
6747
+ io.outLines = []
6748
+ expect(askCommand(deps, 'Wait?', { json: true })).toBe(EXIT.ok)
6749
+ const registered = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
6750
+ expect(registered).toMatchObject({
6751
+ registered: true,
6752
+ state: 'local',
6753
+ submitted: false,
6754
+ request_id: null,
6755
+ provider_acceptance: 'not_available',
6756
+ status: `notifai status ${registered.question_id as string}`,
6757
+ close: `notifai close ${registered.question_id as string}`,
6758
+ })
6759
+ expect(inspectQuestionState(registered.question_id as string, env)).toMatchObject({
6760
+ found: true,
6761
+ question: {
6762
+ question_id: registered.question_id,
6763
+ state: 'local',
6764
+ submitted: false,
6765
+ request_id: null,
6766
+ },
6767
+ })
6145
6768
  })
6146
6769
 
6147
6770
  it('uses exact lifecycle state across checkouts while Project stays invocation-owned', () => {
@@ -6678,8 +7301,11 @@ describe('asking before the hooks have ever run', () => {
6678
7301
 
6679
7302
  const readiness = await assessReadiness(deps)
6680
7303
  const fired = readiness.states.find((state) => state.id === 'hooks-fired')
6681
- expect(fired?.status).toBe('gap')
7304
+ // Reported, never a blocker: the only thing that closes this is a turn
7305
+ // ending, which the agent standing inside that turn cannot supply.
7306
+ expect(fired?.status).toBe('optional-gap')
6682
7307
  expect(fired?.detail).toMatch(/UserPromptSubmit.*Stop has not been observed/)
7308
+ expect(firstRequiredBlocker(readiness)?.id).not.toBe('hooks-fired')
6683
7309
  })
6684
7310
 
6685
7311
  it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
@@ -6723,6 +7349,28 @@ describe('asking before the hooks have ever run', () => {
6723
7349
  expect(readSessionState('opencode-current', env).pending).toBeUndefined()
6724
7350
  })
6725
7351
 
7352
+ it('rejects OpenClaw before registration even with an exact matching pointer', () => {
7353
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-openclaw-unsupported-'))
7354
+ const io = new CapturedIo()
7355
+ const env = {
7356
+ HOME: path.join(cwd, 'home'),
7357
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
7358
+ XDG_STATE_HOME: path.join(cwd, 'state'),
7359
+ OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
7360
+ NOTIFAI_ACTIVE_HARNESS: 'openclaw',
7361
+ NOTIFAI_ACTIVE_SESSION_ID: 'agent:main:main',
7362
+ }
7363
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
7364
+ expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.ok)
7365
+ writeSessionState('agent:main:main', env, { harness: 'openclaw', last_prompt_at: 42, last_stop_at: 41 })
7366
+ writeProjectSession(cwd, env, 'agent:main:main', 42, 'openclaw')
7367
+ io.outLines = []
7368
+
7369
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
7370
+ expect(io.errLines.join('\n')).toMatch(/no proven answer continuation/i)
7371
+ expect(readSessionState('agent:main:main', env).pending).toBeUndefined()
7372
+ })
7373
+
6726
7374
  it('refuses Hermes ask as unsupported rather than as missing hooks', () => {
6727
7375
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-ask-unsupported-'))
6728
7376
  const io = new CapturedIo()
@@ -7231,7 +7879,7 @@ describe('a server behind this CLI', () => {
7231
7879
 
7232
7880
  const said = io.outLines.concat(io.errLines).join(' ')
7233
7881
  expect(said).toMatch(/service is being updated/)
7234
- expect(said).not.toContain('npm install -g @raidiant/notifai')
7882
+ expect(said).not.toContain('notifai update')
7235
7883
  expect(said).not.toMatch(/Protocol version|schema v/i)
7236
7884
 
7237
7885
  const sendIo = new CapturedIo()