@raidiant/notifai 0.1.7 → 0.1.8

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.
@@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'
13
13
  import { ApiCallError, NetworkError, type ApiClient } from './client.js'
14
14
  import {
15
15
  askCommand,
16
+ accessStatusCommand,
16
17
  assessReadiness,
17
18
  capabilitiesCommand,
18
19
  configSetCommand,
@@ -34,10 +35,13 @@ import {
34
35
  type CommandSpinner,
35
36
  } from './commands.js'
36
37
  import { applyPlan, buildHookConfig } from './install-hooks.js'
38
+ import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
39
+ import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
37
40
 
38
41
  class CapturedIo implements CommandIo {
39
42
  outLines: string[] = []
40
43
  errLines: string[] = []
44
+ openedUrls: string[] = []
41
45
 
42
46
  out(line: string) {
43
47
  this.outLines.push(line)
@@ -51,12 +55,16 @@ class CapturedIo implements CommandIo {
51
55
  return false
52
56
  }
53
57
 
54
- openUrl() {}
58
+ openUrl(url: string) {
59
+ this.openedUrls.push(url)
60
+ }
55
61
  }
56
62
 
57
63
  class InteractiveIo extends CapturedIo {
58
64
  interactive = true
59
65
  selectAnswer: string | null = 'global'
66
+ /** When set, answers are consumed in order; falls back to confirmAnswer. */
67
+ confirmAnswers: boolean[] | null = null
60
68
  confirmAnswer = true
61
69
  prompts: string[] = []
62
70
  notes: { message: string; title?: string }[] = []
@@ -67,6 +75,9 @@ class InteractiveIo extends CapturedIo {
67
75
 
68
76
  override async confirm(question: string) {
69
77
  this.prompts.push(question)
78
+ if (this.confirmAnswers !== null && this.confirmAnswers.length > 0) {
79
+ return this.confirmAnswers.shift()!
80
+ }
70
81
  return this.confirmAnswer
71
82
  }
72
83
 
@@ -163,6 +174,23 @@ function replyResponse(replies: ReplyView[] = []): ListRepliesResponse {
163
174
  }
164
175
 
165
176
  describe('command contracts', () => {
177
+ it('shows an actionable no-plan access state', async () => {
178
+ const io = new CapturedIo()
179
+ const client = {
180
+ accessStatus: async () => ({
181
+ status: 'no_active_plan',
182
+ reason: 'no_active_grant',
183
+ expires_at: null,
184
+ }),
185
+ } as unknown as ApiClient
186
+
187
+ expect(await accessStatusCommand(makeDeps(io, client), {})).toBe(EXIT.failed)
188
+ expect(io.outLines).toEqual([
189
+ 'No active plan or temporary Alpha access for this account.',
190
+ 'next: Open https://test.notifai.invalid/support to request Alpha access, then retry.',
191
+ ])
192
+ })
193
+
166
194
  it('renders capability field paths instead of array indexes', async () => {
167
195
  const io = new CapturedIo()
168
196
  const document: CapabilityDocument = {
@@ -217,9 +245,91 @@ describe('command contracts', () => {
217
245
  },
218
246
  } as unknown as ApiClient
219
247
 
220
- expect(await sendCommand(makeDeps(io, client), { title: 'T', body: 'B', badge: -1 })).toBe(EXIT.usage)
248
+ expect(
249
+ await sendCommand(makeDeps(io, client), {
250
+ title: 'T',
251
+ body: 'B',
252
+ project: 'Invalid Project!',
253
+ }),
254
+ ).toBe(EXIT.usage)
221
255
  expect(submitCalls).toBe(0)
222
- expect(io.errLines.join('\n')).toContain('platform.ios.badge')
256
+ expect(io.errLines.join('\n')).toContain('project')
257
+ })
258
+
259
+ it('rejects done plus reply before authentication or submission', async () => {
260
+ const io = new CapturedIo()
261
+ let submitCalls = 0
262
+ const client = {
263
+ submit: async () => {
264
+ submitCalls += 1
265
+ return receipt
266
+ },
267
+ } as unknown as ApiClient
268
+
269
+ expect(
270
+ await sendCommand(makeDeps(io, client), {
271
+ title: 'Done',
272
+ body: 'Answer?',
273
+ kind: 'done',
274
+ reply: true,
275
+ }),
276
+ ).toBe(EXIT.usage)
277
+ expect(submitCalls).toBe(0)
278
+ expect(io.errLines.join('\n')).toContain('--kind done cannot be combined with --reply')
279
+ })
280
+
281
+ it.each([
282
+ {
283
+ flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
284
+ warning: /titles work best around 40/i,
285
+ },
286
+ {
287
+ flags: { title: 'Details', body: '**bold Markdown**' },
288
+ warning: /--body looks like Markdown.*--detail/i,
289
+ },
290
+ {
291
+ flags: { title: 'Done · build', body: 'All green.' },
292
+ warning: /Use --kind done/i,
293
+ },
294
+ {
295
+ flags: { title: 'Failed · build', body: 'One integration test failed.' },
296
+ warning: /Use --kind done/i,
297
+ },
298
+ {
299
+ flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
300
+ warning: /longer than 72 hours/i,
301
+ },
302
+ ])('warns without rejecting a send: $warning', async ({ flags, warning }) => {
303
+ const io = new CapturedIo()
304
+ const client = { submit: async () => receipt } as unknown as ApiClient
305
+
306
+ expect(await sendCommand(makeDeps(io, client), flags)).toBe(EXIT.ok)
307
+ expect(io.errLines.join('\n')).toMatch(warning)
308
+ })
309
+
310
+ it('warns when a collapse key comes from machine-global config', async () => {
311
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-global-collapse-'))
312
+ const configDir = path.join(root, 'notifai')
313
+ mkdirSync(configDir, { recursive: true })
314
+ writeFileSync(path.join(configDir, 'config.toml'), 'collapse_key = "global-key"\n')
315
+ const io = new CapturedIo()
316
+ const client = { submit: async () => receipt } as unknown as ApiClient
317
+ const deps = {
318
+ ...makeDeps(io, client),
319
+ env: { XDG_CONFIG_HOME: root },
320
+ cwd: root,
321
+ }
322
+
323
+ expect(await sendCommand(deps, { title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
324
+ expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
325
+ })
326
+
327
+ it('removes unmanaged provider fields from the public send flags', () => {
328
+ const source = readFileSync(new URL('./main.ts', import.meta.url), 'utf8')
329
+ expect(source).not.toContain(".option('--badge")
330
+ expect(source).not.toContain(".option('--relevance")
331
+ expect(source).not.toContain(".option('--target-content-id")
332
+ expect(source).toContain('Kind profiles')
223
333
  })
224
334
 
225
335
  it('rejects a question nobody will wait for', async () => {
@@ -431,12 +541,13 @@ describe('command contracts', () => {
431
541
  expect(sleeps).toEqual([250])
432
542
  })
433
543
 
434
- it('returns exit 3 with one JSON object when no reply arrives before the timeout', async () => {
544
+ it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
435
545
  const io = new CapturedIo()
436
546
  let now = 0
437
547
  const client = {
438
548
  submit: async () => receipt,
439
549
  replies: async (_requestId: string, options: { waitSeconds: number }) => {
550
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
440
551
  now += options.waitSeconds * 1_000
441
552
  return replyResponse()
442
553
  },
@@ -452,13 +563,20 @@ describe('command contracts', () => {
452
563
  json: true,
453
564
  }),
454
565
  ).toBe(EXIT.noReply)
455
- expect(io.outLines).toHaveLength(1)
566
+ expect(io.outLines).toHaveLength(2)
456
567
  // `degraded` is part of the shape on every reply wait, not only when it is
457
568
  // true: an agent must be able to read it without knowing it might be absent.
458
- expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ receipt, replies: [], degraded: false })
569
+ expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
570
+ type: 'reply_result',
571
+ request_id: receipt.request_id,
572
+ replies: [],
573
+ degraded: false,
574
+ })
575
+ expect(io.errLines.join('\n')).toContain(`notifai replies ${receipt.request_id}`)
576
+ expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
459
577
  })
460
578
 
461
- it('prints the stable send JSON shape when a reply is received', async () => {
579
+ it('prints the receipt and reply result as two NDJSON records when answered', async () => {
462
580
  const io = new CapturedIo()
463
581
  const client = {
464
582
  submit: async () => receipt,
@@ -474,9 +592,11 @@ describe('command contracts', () => {
474
592
  json: true,
475
593
  }),
476
594
  ).toBe(EXIT.ok)
477
- expect(io.outLines).toHaveLength(1)
478
- expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
479
- receipt,
595
+ expect(io.outLines).toHaveLength(2)
596
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
597
+ expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
598
+ type: 'reply_result',
599
+ request_id: receipt.request_id,
480
600
  replies: [reply],
481
601
  degraded: false,
482
602
  })
@@ -496,6 +616,30 @@ describe('command contracts', () => {
496
616
  expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
497
617
  expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
498
618
  })
619
+
620
+ it('resolves replies --pending through the project session pointer and prints its id', async () => {
621
+ const io = new CapturedIo()
622
+ const client = {
623
+ replies: async () => replyResponse([reply]),
624
+ } as unknown as ApiClient
625
+ const deps = makeDeps(io, client)
626
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-replies-'))
627
+ deps.cwd = root
628
+ deps.env = {
629
+ XDG_CONFIG_HOME: path.join(root, 'config'),
630
+ XDG_STATE_HOME: path.join(root, 'state'),
631
+ }
632
+ writeSessionState('pending-session', deps.env, {
633
+ pending: { question: 'Deploy?', request_id: receipt.request_id },
634
+ })
635
+ writeProjectSession(root, deps.env, 'pending-session', Date.now(), 'codex')
636
+
637
+ expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
638
+ expect(io.outLines).toEqual([
639
+ `pending request ${receipt.request_id}`,
640
+ 'reply from iPhone: yes, after the migration',
641
+ ])
642
+ })
499
643
  })
500
644
 
501
645
  describe('delivery evidence status', () => {
@@ -549,7 +693,7 @@ describe('delivery evidence status', () => {
549
693
  expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
550
694
  const said = io.outLines.join('\n')
551
695
  expect(said).toContain('Provider Acceptance: accepted')
552
- expect(said).toContain('Companion Receipt: unknown')
696
+ expect(said).toContain("Companion Receipt (the app's delivery confirmation): unknown")
553
697
  expect(said).toContain('not a failure')
554
698
  expect(said).toContain('attempt_started')
555
699
  })
@@ -567,7 +711,7 @@ describe('delivery evidence status', () => {
567
711
 
568
712
  expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
569
713
  const said = io.outLines.join('\n')
570
- expect(said).toContain('Companion Receipt: observed')
714
+ expect(said).toContain("Companion Receipt (the app's delivery confirmation): observed")
571
715
  expect(said).toContain('11m 27s after Provider Acceptance')
572
716
  })
573
717
  })
@@ -576,7 +720,7 @@ describe('Cursor hook commands', () => {
576
720
  const execPath = '/usr/local/bin/node'
577
721
  const scriptPath = '/opt/notifai/dist/main.js'
578
722
 
579
- it('installs native Cursor hooks with a single bounded answer continuation', () => {
723
+ it('installs native Cursor hooks with bounded chained answer continuations', () => {
580
724
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
581
725
  const io = new CapturedIo()
582
726
  const deps = { ...makeDeps(io, {} as ApiClient), cwd }
@@ -602,7 +746,7 @@ describe('Cursor hook commands', () => {
602
746
  )
603
747
  expect(installed.hooks['stop']?.[0]).toMatchObject({
604
748
  command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
605
- loop_limit: 1,
749
+ loop_limit: 3,
606
750
  })
607
751
  })
608
752
 
@@ -638,7 +782,7 @@ describe('Cursor hook commands', () => {
638
782
  expect(io.outLines.some((line) => line.includes('Cursor: send one prompt'))).toBe(true)
639
783
  })
640
784
 
641
- it('uninstalls only NotifAI Cursor hooks and preserves foreign hooks', () => {
785
+ it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
642
786
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-uninstall-'))
643
787
  const io = new CapturedIo()
644
788
  const deps = { ...makeDeps(io, {} as ApiClient), cwd }
@@ -714,7 +858,7 @@ describe('harness activation guidance', () => {
714
858
  )
715
859
 
716
860
  expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
717
- expect(io.outLines.join('\n')).toContain('cannot reliably resume an idle agent turn')
861
+ expect(io.outLines.join('\n')).toContain('next user prompt')
718
862
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
719
863
  const plugin = readFileSync(pluginFile, 'utf8')
720
864
  expect(plugin).toContain('const TIMEOUT_MS = 540000')
@@ -735,7 +879,7 @@ describe('harness activation guidance', () => {
735
879
  describe('projectSlugFrom', () => {
736
880
  it('canonicalizes directory names into contract-valid slugs', () => {
737
881
  expect(projectSlugFrom('My App')).toBe('my-app')
738
- expect(projectSlugFrom('NotifAI')).toBe('notifai')
882
+ expect(projectSlugFrom('Notifai')).toBe('notifai')
739
883
  expect(projectSlugFrom('--weird__Name.2')).toBe('weird__name.2')
740
884
  expect(projectSlugFrom('!!!')).toBe('project')
741
885
  })
@@ -777,7 +921,7 @@ describe('interactive command UX', () => {
777
921
  }
778
922
 
779
923
  expect(await loginCommand(deps, { name: 'workstation', open: false })).toBe(EXIT.ok)
780
- expect(io.intros).toEqual(['NotifAI sign in'])
924
+ expect(io.intros).toEqual(['Notifai sign in'])
781
925
  expect(io.notes).toEqual([
782
926
  {
783
927
  title: 'Approve this machine',
@@ -785,8 +929,8 @@ describe('interactive command UX', () => {
785
929
  },
786
930
  ])
787
931
  expect(io.spinnerEvents).toEqual([
788
- 'start:Waiting for approval…',
789
- 'message:Waiting for approval…',
932
+ 'start:Waiting for approval… code ABCD-EFGH · 10s left',
933
+ 'message:Waiting for approval… code ABCD-EFGH · 9s left',
790
934
  'stop:Machine "workstation" approved',
791
935
  ])
792
936
  expect(io.outLines).toEqual([])
@@ -899,7 +1043,7 @@ describe('interactive command UX', () => {
899
1043
  }
900
1044
 
901
1045
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
902
- expect(io.intros).toEqual(['NotifAI doctor'])
1046
+ expect(io.intros).toEqual(['Notifai doctor'])
903
1047
  expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
904
1048
  expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
905
1049
  expect(io.outLines).toEqual([])
@@ -995,6 +1139,47 @@ describe('init', () => {
995
1139
  }
996
1140
  }
997
1141
 
1142
+ function managedSkill(scope: SkillScope, cwd: string): NativeSkill {
1143
+ return {
1144
+ name: 'notifai',
1145
+ scope,
1146
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
1147
+ source: 'Raidiant-io/notifai',
1148
+ sourceType: 'github',
1149
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1150
+ ref: 'v0.1.8',
1151
+ }
1152
+ }
1153
+
1154
+ function setupReadyDeps(
1155
+ io: CapturedIo,
1156
+ cwd: string,
1157
+ nativeSkills: NativeSkills,
1158
+ calls: { submit: number },
1159
+ ): CommandDeps {
1160
+ const client = {
1161
+ health: async () => true,
1162
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1163
+ listDevices: async () => ({ devices: [readyIphone] }),
1164
+ submit: async () => {
1165
+ calls.submit += 1
1166
+ return setupReceipt()
1167
+ },
1168
+ evidence: async () =>
1169
+ setupEvidence('req_setup', {
1170
+ state: 'observed',
1171
+ observed_at: '2026-08-05T18:00:02.000Z',
1172
+ latency_ms: 1_000,
1173
+ }),
1174
+ } as unknown as ApiClient
1175
+ return {
1176
+ ...makeDeps(io, client),
1177
+ cwd,
1178
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1179
+ nativeSkills,
1180
+ }
1181
+ }
1182
+
998
1183
  it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
999
1184
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
1000
1185
  const io = new CapturedIo()
@@ -1033,6 +1218,7 @@ describe('init', () => {
1033
1218
  const out = io.outLines.join('\n')
1034
1219
  expect(out).toContain('Next: This machine')
1035
1220
  expect(out).toContain('notifai login')
1221
+ expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
1036
1222
  // The device gap is real and downstream; it must stay hidden until the
1037
1223
  // sign-in that would let anyone actually check it has happened.
1038
1224
  expect(out).not.toContain('companion app')
@@ -1064,45 +1250,201 @@ describe('init', () => {
1064
1250
  })
1065
1251
 
1066
1252
  it('pins the skill installer to the tagged public release syntax', () => {
1067
- expect(SKILLS_SOURCE).toBe('RafaelVidaurre/notifai#v0.1.7')
1253
+ expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.1.8')
1068
1254
  expect(SKILLS_SOURCE).not.toContain('@v')
1069
1255
  })
1070
1256
 
1071
1257
  it('recognizes a skill installed from the exact immutable release', async () => {
1072
1258
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-pinned-skill-'))
1073
- mkdirSync(path.join(cwd, '.agents', 'skills', 'notifai'), { recursive: true })
1074
- writeFileSync(path.join(cwd, '.agents', 'skills', 'notifai', 'SKILL.md'), '# notifai\n')
1075
- writeFileSync(
1076
- path.join(cwd, 'skills-lock.json'),
1077
- `${JSON.stringify(
1078
- {
1079
- version: 1,
1080
- skills: {
1081
- notifai: {
1082
- source: 'RafaelVidaurre/notifai',
1083
- ref: 'v0.1.7',
1084
- sourceType: 'github',
1085
- skillPath: 'skills/notifai/SKILL.md',
1086
- },
1087
- },
1088
- },
1089
- null,
1090
- 2,
1091
- )}\n`,
1092
- )
1093
1259
  const io = new CapturedIo()
1094
1260
  const client = {
1095
1261
  health: async () => true,
1096
1262
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1097
1263
  listDevices: async () => ({ devices: [] }),
1098
1264
  } as unknown as ApiClient
1099
- const readiness = await assessReadiness({ ...makeDeps(io, client), cwd })
1265
+ const nativeSkills: NativeSkills = {
1266
+ add: async () => 0,
1267
+ list: async (scope) => ({
1268
+ skills: [
1269
+ {
1270
+ name: 'notifai',
1271
+ scope,
1272
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
1273
+ source: 'Raidiant-io/notifai',
1274
+ sourceType: 'github',
1275
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1276
+ ref: 'v0.1.8',
1277
+ },
1278
+ ],
1279
+ }),
1280
+ }
1281
+ const readiness = await assessReadiness(
1282
+ { ...makeDeps(io, client), cwd, nativeSkills },
1283
+ { skillScope: 'project' },
1284
+ )
1100
1285
  expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
1101
1286
  status: 'ready',
1102
- detail: `installed from ${SKILLS_SOURCE} in this project`,
1287
+ detail: `installed from ${SKILLS_SOURCE} in the project scope`,
1103
1288
  })
1104
1289
  })
1105
1290
 
1291
+ it.each(['project', 'global'] as const)(
1292
+ 'recognizes native installer provenance in the selected %s scope',
1293
+ async (scope) => {
1294
+ const cwd = mkdtempSync(path.join(os.tmpdir(), `init-managed-${scope}-`))
1295
+ const io = new CapturedIo()
1296
+ const client = {
1297
+ health: async () => true,
1298
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1299
+ listDevices: async () => ({ devices: [] }),
1300
+ } as unknown as ApiClient
1301
+ const calls: SkillScope[] = []
1302
+ const nativeSkills: NativeSkills = {
1303
+ add: async () => 0,
1304
+ list: async (selected) => {
1305
+ calls.push(selected)
1306
+ return { skills: [managedSkill(selected, cwd)] }
1307
+ },
1308
+ }
1309
+
1310
+ const readiness = await assessReadiness(
1311
+ { ...makeDeps(io, client), cwd, nativeSkills },
1312
+ { skillScope: scope },
1313
+ )
1314
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
1315
+ status: 'ready',
1316
+ detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
1317
+ })
1318
+ expect(calls).toEqual([scope])
1319
+ },
1320
+ )
1321
+
1322
+ it.each(['project', 'global'] as const)(
1323
+ 'does not trust unmanaged same-path content in the %s scope',
1324
+ async (scope) => {
1325
+ const cwd = mkdtempSync(path.join(os.tmpdir(), `init-unmanaged-${scope}-`))
1326
+ const io = new CapturedIo()
1327
+ const client = {
1328
+ health: async () => true,
1329
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1330
+ listDevices: async () => ({ devices: [] }),
1331
+ } as unknown as ApiClient
1332
+ const nativeSkills: NativeSkills = {
1333
+ add: async () => 0,
1334
+ list: async (selected) => ({
1335
+ skills: [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }],
1336
+ }),
1337
+ }
1338
+
1339
+ const readiness = await assessReadiness(
1340
+ { ...makeDeps(io, client), cwd, nativeSkills },
1341
+ { skillScope: scope },
1342
+ )
1343
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
1344
+ status: 'optional-gap',
1345
+ detail: `not installed from ${SKILLS_SOURCE} in ${scope} scope`,
1346
+ })
1347
+ },
1348
+ )
1349
+
1350
+ it('requires an explicit skill scope before unattended installation', async () => {
1351
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-scope-required-'))
1352
+ const io = new CapturedIo()
1353
+ let addCalls = 0
1354
+ const nativeSkills: NativeSkills = {
1355
+ add: async () => {
1356
+ addCalls += 1
1357
+ return 0
1358
+ },
1359
+ list: async () => ({ skills: [] }),
1360
+ }
1361
+
1362
+ expect(await initCommand({ ...makeDeps(io, {} as ApiClient), cwd, nativeSkills }, { skills: true })).toBe(
1363
+ EXIT.usage,
1364
+ )
1365
+ expect(addCalls).toBe(0)
1366
+ expect(io.errLines.join('\n')).toContain('--skills-scope project')
1367
+ })
1368
+
1369
+ it('rejects an invalid unattended skill scope instead of guessing', async () => {
1370
+ const io = new CapturedIo()
1371
+ expect(
1372
+ await initCommand(
1373
+ { ...makeDeps(io, {} as ApiClient), cwd: mkdtempSync(path.join(os.tmpdir(), 'init-skill-invalid-')) },
1374
+ { skills: true, skillsScope: 'machine' as SkillScope },
1375
+ ),
1376
+ ).toBe(EXIT.usage)
1377
+ expect(io.errLines.join('\n')).toContain('Choose `project` or `global`')
1378
+ })
1379
+
1380
+ it.each(['project', 'global'] as const)(
1381
+ 'passes an unattended %s choice to the native installer and continues setup',
1382
+ async (scope) => {
1383
+ const cwd = mkdtempSync(path.join(os.tmpdir(), `init-skill-${scope}-`))
1384
+ const io = new CapturedIo()
1385
+ const calls: { submit: number } = { submit: 0 }
1386
+ let installed = false
1387
+ let receivedScope: SkillScope | undefined
1388
+ const nativeSkills: NativeSkills = {
1389
+ add: async (options) => {
1390
+ receivedScope = options.scope
1391
+ installed = true
1392
+ return 0
1393
+ },
1394
+ list: async (selected) => ({
1395
+ skills: installed && selected === scope ? [managedSkill(scope, cwd)] : [],
1396
+ }),
1397
+ }
1398
+
1399
+ const result = await initCommand(
1400
+ setupReadyDeps(io, cwd, nativeSkills, calls),
1401
+ { skills: true, skillsScope: scope, hooks: false },
1402
+ )
1403
+ expect(result).toBe(EXIT.ok)
1404
+ expect(receivedScope).toBe(scope)
1405
+ expect(calls.submit).toBe(1)
1406
+ expect(io.outLines.join('\n')).toContain('All set.')
1407
+ },
1408
+ )
1409
+
1410
+ it('lets the native interactive flow choose scope and resumes after cancellation', async () => {
1411
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-cancelled-'))
1412
+ const io = new InteractiveIo()
1413
+ const calls: { submit: number } = { submit: 0 }
1414
+ let receivedScope: SkillScope | undefined = 'global'
1415
+ const nativeSkills: NativeSkills = {
1416
+ add: async (options) => {
1417
+ receivedScope = options.scope
1418
+ return 0
1419
+ },
1420
+ list: async () => ({ skills: [] }),
1421
+ }
1422
+
1423
+ expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
1424
+ EXIT.ok,
1425
+ )
1426
+ expect(receivedScope).toBeUndefined()
1427
+ expect(calls.submit).toBe(1)
1428
+ expect(io.outLines.join('\n')).toContain('All set.')
1429
+ })
1430
+
1431
+ it('reports an optional native installer failure without blocking remaining setup', async () => {
1432
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-failed-'))
1433
+ const io = new InteractiveIo()
1434
+ const calls: { submit: number } = { submit: 0 }
1435
+ const nativeSkills: NativeSkills = {
1436
+ add: async () => 1,
1437
+ list: async () => ({ skills: [] }),
1438
+ }
1439
+
1440
+ expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
1441
+ EXIT.failed,
1442
+ )
1443
+ expect(calls.submit).toBe(1)
1444
+ expect(io.errLines.join('\n')).toContain('Skill installation failed')
1445
+ expect(io.outLines.join('\n')).toContain('All set.')
1446
+ })
1447
+
1106
1448
  it('tells the user what only they can do when nothing is signed in', async () => {
1107
1449
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-nocred-'))
1108
1450
  const io = new CapturedIo()
@@ -1116,7 +1458,10 @@ describe('init', () => {
1116
1458
  expect(io.outLines.join('\n')).toContain('notifai login')
1117
1459
  })
1118
1460
 
1119
- it('offers a present human the sign-in and respects a refusal', async () => {
1461
+ it('announces sign-in for a present human without re-confirming', async () => {
1462
+ // Running `init` is the consent; re-asking "Sign in now?" is noise.
1463
+ // Ctrl-C is the escape hatch (announced). Here beginPairing fails so we
1464
+ // still end on the credential Next line with a re-run hint.
1120
1465
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-human-'))
1121
1466
  const asked: string[] = []
1122
1467
  const io = new (class extends CapturedIo {
@@ -1126,16 +1471,24 @@ describe('init', () => {
1126
1471
  return false
1127
1472
  }
1128
1473
  })()
1474
+ const client = {
1475
+ health: async () => true,
1476
+ beginPairing: async () => {
1477
+ throw new NetworkError('offline for test')
1478
+ },
1479
+ } as unknown as ApiClient
1129
1480
  const deps: CommandDeps = {
1130
- ...makeDeps(io, {} as ApiClient),
1481
+ ...makeDeps(io, client),
1131
1482
  cwd,
1132
1483
  store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
1133
1484
  }
1134
1485
 
1135
- expect(await initCommand(deps, {})).toBe(EXIT.ok)
1136
- expect(asked.some((q) => q.includes('Sign in'))).toBe(true)
1137
- // Refused, so it stays the next step rather than being treated as done.
1138
- expect(io.outLines.join('\n')).toContain('notifai login')
1486
+ expect(await initCommand(deps, {})).toBe(EXIT.failed)
1487
+ expect(asked.some((q) => q.includes('Sign in'))).toBe(false)
1488
+ const out = io.outLines.join('\n')
1489
+ expect(out).toContain('Opening your browser to approve this machine — Ctrl-C to stop.')
1490
+ expect(out).toContain('Next: This machine')
1491
+ expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
1139
1492
  })
1140
1493
 
1141
1494
  it('never prompts or opens a browser when an agent runs it unattended', async () => {
@@ -1167,6 +1520,12 @@ describe('init', () => {
1167
1520
  health: async () => true,
1168
1521
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1169
1522
  listDevices: async () => ({ devices: [] }),
1523
+ accessStatus: async () => ({
1524
+ status: 'active',
1525
+ reason: 'alpha_grant',
1526
+ expires_at: null,
1527
+ email: 'alpha@example.com',
1528
+ }),
1170
1529
  } as unknown as ApiClient
1171
1530
  const deps = {
1172
1531
  ...makeDeps(io, client),
@@ -1177,11 +1536,57 @@ describe('init', () => {
1177
1536
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
1178
1537
  const out = io.outLines.join('\n')
1179
1538
  expect(out).toContain('Next: Your devices')
1180
- expect(out).toContain('no supported App Store/TestFlight URL')
1181
- expect(out).toContain('open it, sign in with the same account, and allow notifications')
1539
+ expect(out).toContain('https://test.notifai.invalid/support')
1540
+ expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
1541
+ expect(out).toContain('install Notifai on iPhone or Mac via https://test.notifai.invalid/support')
1182
1542
  expect(out.match(/^Next:/gm)).toHaveLength(1)
1183
1543
  })
1184
1544
 
1545
+ it('offers hooks and skill before stopping at a phone-less device gap', async () => {
1546
+ // P2.1: optionals that work without a phone must not be trapped behind the
1547
+ // user-elsewhere device blocker.
1548
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-optionals-first-'))
1549
+ const asked: string[] = []
1550
+ const io = new (class extends CapturedIo {
1551
+ interactive = true
1552
+ override async confirm(question: string) {
1553
+ asked.push(question)
1554
+ return false
1555
+ }
1556
+ })()
1557
+ const client = {
1558
+ health: async () => true,
1559
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1560
+ listDevices: async () => ({ devices: [] }),
1561
+ accessStatus: async () => ({
1562
+ status: 'active',
1563
+ reason: 'alpha_grant',
1564
+ expires_at: null,
1565
+ email: 'alpha@example.com',
1566
+ }),
1567
+ } as unknown as ApiClient
1568
+ const deps = {
1569
+ ...makeDeps(io, client),
1570
+ cwd,
1571
+ env: {
1572
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
1573
+ XDG_STATE_HOME: path.join(cwd, 'state'),
1574
+ CODEX_HOME: path.join(cwd, 'codex'),
1575
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
1576
+ },
1577
+ }
1578
+
1579
+ expect(await initCommand(deps, {})).toBe(EXIT.ok)
1580
+ expect(asked.some((q) => q.includes('hooks'))).toBe(true)
1581
+ expect(asked.some((q) => q.includes('skill'))).toBe(true)
1582
+ const out = io.outLines.join('\n')
1583
+ expect(out).toContain('Next: Your devices')
1584
+ // Device wait prompts only after optionals have been considered.
1585
+ expect(asked.indexOf(asked.find((q) => q.includes('hooks'))!)).toBeLessThan(
1586
+ asked.findIndex((q) => q.includes('Wait here') || q.includes('Open install')),
1587
+ )
1588
+ })
1589
+
1185
1590
  it.each([
1186
1591
  ['denied', 'system settings'],
1187
1592
  ['not_determined', 'allow its notification prompt'],
@@ -1219,6 +1624,12 @@ describe('init', () => {
1219
1624
  health: async () => true,
1220
1625
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1221
1626
  listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
1627
+ accessStatus: async () => ({
1628
+ status: 'active',
1629
+ reason: 'alpha_grant',
1630
+ expires_at: null,
1631
+ email: 'alpha@example.com',
1632
+ }),
1222
1633
  submit: async (draft: SubmitNotificationRequestT) => {
1223
1634
  submitCalls += 1
1224
1635
  submittedDraft = draft
@@ -1243,10 +1654,17 @@ describe('init', () => {
1243
1654
  }
1244
1655
 
1245
1656
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1246
- expect(io.prompts).toEqual(['Wait here while you finish that on your device?'])
1657
+ expect(io.prompts).toEqual([
1658
+ 'Open install instructions in your browser?',
1659
+ 'Wait here while you finish that on your device?',
1660
+ ])
1661
+ expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
1662
+ expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
1247
1663
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
1248
1664
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
1249
- expect(io.outLines.join('\n')).toContain('Companion Receipt observed from iPhone')
1665
+ expect(io.outLines.join('\n')).toContain(
1666
+ "Companion Receipt (the app's delivery confirmation) observed from iPhone",
1667
+ )
1250
1668
  expect(io.outLines.join('\n')).toContain('All set.')
1251
1669
  expect(submitCalls).toBe(1)
1252
1670
  expect(submittedDraft?.draft.event).toBe('setup_verified')
@@ -1254,12 +1672,96 @@ describe('init', () => {
1254
1672
 
1255
1673
  io.outLines = []
1256
1674
  io.prompts = []
1675
+ io.openedUrls = []
1257
1676
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1258
1677
  expect(submitCalls).toBe(1)
1259
1678
  expect(io.prompts).toEqual([])
1260
1679
  expect(io.outLines.join('\n')).toContain('All set.')
1261
1680
  })
1262
1681
 
1682
+ it('says the wait timer expired and can keep waiting for a late device', async () => {
1683
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-keep-waiting-'))
1684
+ const io = new InteractiveIo()
1685
+ // Open browser, wait yes, keep-waiting yes on first expiry.
1686
+ io.confirmAnswers = [true, true, true]
1687
+ let now = 0
1688
+ // Match DEVICE_BRIDGE_TIMEOUT_MS (10 minutes): device appears only after
1689
+ // the first budget has fully elapsed and keep-waiting has restarted.
1690
+ const firstBudgetMs = 10 * 60 * 1000
1691
+ const client = {
1692
+ health: async () => true,
1693
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1694
+ listDevices: async () => ({
1695
+ devices: now >= firstBudgetMs ? [readyIphone] : [],
1696
+ }),
1697
+ accessStatus: async () => ({
1698
+ status: 'active',
1699
+ reason: 'alpha_grant',
1700
+ expires_at: null,
1701
+ email: 'late@example.com',
1702
+ }),
1703
+ submit: async () => setupReceipt(),
1704
+ evidence: async (requestId: string) =>
1705
+ setupEvidence(requestId, {
1706
+ state: 'observed',
1707
+ observed_at: '2026-08-05T18:00:02.000Z',
1708
+ latency_ms: 1_000,
1709
+ }),
1710
+ } as unknown as ApiClient
1711
+ const deps: CommandDeps = {
1712
+ ...makeDeps(io, client),
1713
+ cwd,
1714
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1715
+ now: () => now,
1716
+ sleep: async (milliseconds) => {
1717
+ now += milliseconds
1718
+ },
1719
+ }
1720
+
1721
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1722
+ expect(io.errLines.join('\n')).toContain('wait timer expired')
1723
+ expect(io.errLines.join('\n')).toMatch(/setup is not finished, only this wait/)
1724
+ expect(io.prompts.some((q) => q.includes('Keep waiting'))).toBe(true)
1725
+ expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
1726
+ expect(io.outLines.join('\n')).toContain('All set.')
1727
+ })
1728
+
1729
+ it('never hangs an agent on the device wait or keep-waiting prompt', async () => {
1730
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-agent-'))
1731
+ const io = new (class extends CapturedIo {
1732
+ override async confirm(): Promise<boolean> {
1733
+ throw new Error('an unattended init reached a device-wait prompt')
1734
+ }
1735
+
1736
+ override openUrl(): void {
1737
+ throw new Error('an unattended init opened a browser')
1738
+ }
1739
+ })()
1740
+ const client = {
1741
+ health: async () => true,
1742
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1743
+ listDevices: async () => ({ devices: [] }),
1744
+ accessStatus: async () => ({
1745
+ status: 'active',
1746
+ reason: 'alpha_grant',
1747
+ expires_at: null,
1748
+ email: 'agent@example.com',
1749
+ }),
1750
+ } as unknown as ApiClient
1751
+ const deps = {
1752
+ ...makeDeps(io, client),
1753
+ cwd,
1754
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1755
+ }
1756
+
1757
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
1758
+ const out = io.outLines.join('\n')
1759
+ expect(out).toContain('Next: Your devices')
1760
+ expect(out).toContain('https://test.notifai.invalid/support')
1761
+ expect(out).toContain('sign in with the same email as this account (agent@example.com)')
1762
+ expect(io.openedUrls).toEqual([])
1763
+ })
1764
+
1263
1765
  it('persists a partial proof and checks the same request instead of sending again', async () => {
1264
1766
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-partial-'))
1265
1767
  const io = new CapturedIo()
@@ -1303,7 +1805,8 @@ describe('init', () => {
1303
1805
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
1304
1806
  expect(submitCalls).toBe(1)
1305
1807
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
1306
- expect(io.errLines.join('\n')).toContain('not proof of non-receipt')
1808
+ expect(io.outLines.join('\n')).toContain('Provider accepted the notification')
1809
+ expect(io.outLines.join('\n')).toContain('Proof may still arrive')
1307
1810
 
1308
1811
  io.outLines = []
1309
1812
  io.errLines = []
@@ -1317,7 +1820,9 @@ describe('init', () => {
1317
1820
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1318
1821
  expect(submitCalls).toBe(2)
1319
1822
  expect(io.outLines.join('\n')).toContain('saved proof had expired; sent replacement req_replacement')
1320
- expect(io.outLines.join('\n')).toContain('Companion Receipt observed from iPhone')
1823
+ expect(io.outLines.join('\n')).toContain(
1824
+ "Companion Receipt (the app's delivery confirmation) observed from iPhone",
1825
+ )
1321
1826
  })
1322
1827
 
1323
1828
  it('reports a proof-state write failure instead of crashing or sending twice', async () => {
@@ -1345,7 +1850,7 @@ describe('init', () => {
1345
1850
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
1346
1851
  })
1347
1852
 
1348
- it('does not claim proof for a macOS-only setup whose receipt bridge is unavailable', async () => {
1853
+ it('treats macOS-only delivery proof as an honest non-blocking caveat', async () => {
1349
1854
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
1350
1855
  const io = new CapturedIo()
1351
1856
  let submitCalls = 0
@@ -1366,10 +1871,66 @@ describe('init', () => {
1366
1871
  env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1367
1872
  }
1368
1873
 
1369
- expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
1874
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1370
1875
  expect(submitCalls).toBe(0)
1371
- expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
1372
- expect(io.outLines.join('\n')).toContain('no supported macOS receipt bridge')
1876
+ const out = io.outLines.join('\n')
1877
+ expect(out).toContain('All set.')
1878
+ expect(out).toContain('receipt proof needs an iPhone in this release')
1879
+ expect(out).not.toContain('Next: Delivery proof')
1880
+ expect(out).not.toMatch(/Companion Receipt observed/i)
1881
+
1882
+ // doctor must render the same non-blocking state (not FAIL / not a Next:).
1883
+ const doctorIo = new CapturedIo()
1884
+ expect(
1885
+ await doctorCommand(
1886
+ {
1887
+ ...makeDeps(doctorIo, client),
1888
+ cwd,
1889
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1890
+ },
1891
+ {},
1892
+ ),
1893
+ ).toBe(EXIT.ok)
1894
+ const doctorOut = doctorIo.outLines.join('\n')
1895
+ expect(doctorOut).toMatch(/--\s+Delivery proof:/)
1896
+ expect(doctorOut).toContain('receipt proof needs an iPhone')
1897
+ expect(doctorOut).not.toMatch(/FAIL\s+Delivery proof:/)
1898
+ })
1899
+
1900
+ it('stops login when the approval page reports no Alpha access', async () => {
1901
+ const io = new CapturedIo()
1902
+ let now = 0
1903
+ let polls = 0
1904
+ const client = {
1905
+ beginPairing: async () => ({
1906
+ pairing_id: 'pair_no_plan',
1907
+ code: 'NOPE-PLAN',
1908
+ approve_url: 'https://test.notifai.invalid/approve?code=NOPE-PLAN',
1909
+ expires_at: new Date(60_000).toISOString(),
1910
+ poll_interval_seconds: 1,
1911
+ }),
1912
+ pollPairing: async () => {
1913
+ polls += 1
1914
+ return {
1915
+ status: 'no_active_plan' as const,
1916
+ next_action: 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
1917
+ }
1918
+ },
1919
+ } as unknown as ApiClient
1920
+ const deps = {
1921
+ ...makeDeps(io, client),
1922
+ now: () => now,
1923
+ sleep: async (milliseconds: number) => {
1924
+ now += milliseconds
1925
+ },
1926
+ }
1927
+
1928
+ expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
1929
+ expect(polls).toBe(1)
1930
+ expect(io.errLines.join('\n')).toContain('no active plan or temporary Alpha access')
1931
+ expect(io.errLines.join('\n')).toContain(
1932
+ 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
1933
+ )
1373
1934
  })
1374
1935
 
1375
1936
  it('treats a revoked credential as the one blocker and points back to pairing', async () => {
@@ -1486,7 +2047,7 @@ describe('an outage is not an answer', () => {
1486
2047
  json: true,
1487
2048
  })
1488
2049
 
1489
- const payload = JSON.parse(io.outLines[0] ?? '{}') as { degraded: boolean }
2050
+ const payload = JSON.parse(io.outLines[1] ?? '{}') as { degraded: boolean }
1490
2051
  expect(payload.degraded).toBe(true)
1491
2052
  })
1492
2053
 
@@ -1512,6 +2073,276 @@ describe('an outage is not an answer', () => {
1512
2073
  })
1513
2074
 
1514
2075
  describe('asking before the hooks have ever run', () => {
2076
+ const execPath = '/usr/local/bin/node'
2077
+ const scriptPath = '/opt/notifai/dist/main.js'
2078
+
2079
+ it('names the active Codex harness instead of unrelated installed adapters', () => {
2080
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
2081
+ const io = new CapturedIo()
2082
+ const env = {
2083
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2084
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2085
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2086
+ CODEX_THREAD_ID: 'codex-current-thread',
2087
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2088
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
2089
+ }
2090
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
2091
+
2092
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(EXIT.ok)
2093
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
2094
+ io.outLines = []
2095
+
2096
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2097
+ const said = io.errLines.join(' ')
2098
+ expect(said).toMatch(/active Codex session/i)
2099
+ expect(said).toContain('notifai hooks install --harness codex')
2100
+ expect(said).not.toMatch(/Claude Code: send one new prompt|OpenCode: restart/)
2101
+ })
2102
+
2103
+ it('refuses a recent pointer owned by another Codex thread', () => {
2104
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-mismatch-'))
2105
+ const io = new CapturedIo()
2106
+ const env = {
2107
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2108
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2109
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2110
+ CODEX_THREAD_ID: 'codex-current-thread',
2111
+ }
2112
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2113
+
2114
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2115
+ writeSessionState('codex-other-thread', env, { last_prompt_at: 42 })
2116
+ writeProjectSession(cwd, env, 'codex-other-thread', 42, 'codex')
2117
+ io.outLines = []
2118
+
2119
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2120
+ expect(io.errLines.join(' ')).toMatch(/belongs to another Codex session/i)
2121
+ expect(io.outLines).not.toContain(
2122
+ 'Question registered. Ask it in the conversation as usual and end your turn.',
2123
+ )
2124
+ })
2125
+
2126
+ it('registers only when the active Codex thread owns the project pointer', () => {
2127
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-matching-'))
2128
+ const io = new CapturedIo()
2129
+ const env = {
2130
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2131
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2132
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2133
+ CODEX_THREAD_ID: 'codex-current-thread',
2134
+ }
2135
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2136
+
2137
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2138
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42 })
2139
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
2140
+ io.outLines = []
2141
+
2142
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2143
+ expect(io.outLines).toContain(
2144
+ 'Question registered. Ask it in the conversation as usual and end your turn.',
2145
+ )
2146
+ })
2147
+
2148
+ it('gives doctor the same active-Codex diagnosis as ask', async () => {
2149
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-doctor-'))
2150
+ const io = new CapturedIo()
2151
+ const env = {
2152
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2153
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2154
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2155
+ CODEX_THREAD_ID: 'codex-current-thread',
2156
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2157
+ }
2158
+ const client = {
2159
+ health: async () => true,
2160
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
2161
+ listDevices: async () => ({ devices: [] }),
2162
+ } as unknown as ApiClient
2163
+ const nativeSkills: NativeSkills = {
2164
+ add: async () => 0,
2165
+ list: async (scope) => ({
2166
+ skills:
2167
+ scope === 'global'
2168
+ ? [
2169
+ {
2170
+ name: 'notifai',
2171
+ scope,
2172
+ path: path.join(cwd, 'global-skills', 'notifai'),
2173
+ source: 'Raidiant-io/notifai',
2174
+ sourceType: 'github',
2175
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2176
+ ref: 'v0.1.8',
2177
+ },
2178
+ ]
2179
+ : [],
2180
+ }),
2181
+ }
2182
+ const deps = { ...makeDeps(io, client), cwd, env, nativeSkills }
2183
+
2184
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(EXIT.ok)
2185
+ io.outLines = []
2186
+
2187
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2188
+ const said = io.outLines.join(' ')
2189
+ expect(said).toContain(`installed from ${SKILLS_SOURCE} in the global scope`)
2190
+ expect(said).toMatch(/active Codex/i)
2191
+ expect(said).toContain('notifai hooks install --harness codex')
2192
+ })
2193
+
2194
+ it('refuses a Claude Code pointer owned by another live session', () => {
2195
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-mismatch-'))
2196
+ const io = new CapturedIo()
2197
+ const env = {
2198
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2199
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2200
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2201
+ CLAUDECODE: '1',
2202
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
2203
+ }
2204
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2205
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2206
+ EXIT.ok,
2207
+ )
2208
+ writeSessionState('claude-other', env, { last_prompt_at: 42 })
2209
+ writeProjectSession(cwd, env, 'claude-other', 42, 'claude-code')
2210
+ io.outLines = []
2211
+
2212
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2213
+ expect(io.errLines.join(' ')).toMatch(/another Claude Code session/i)
2214
+ })
2215
+
2216
+ it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
2217
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-child-'))
2218
+ const io = new CapturedIo()
2219
+ const env = {
2220
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2221
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2222
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2223
+ CLAUDECODE: '1',
2224
+ CLAUDE_CODE_CHILD_SESSION: '1',
2225
+ CLAUDE_CODE_SESSION_ID: 'claude-parent-loop',
2226
+ }
2227
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2228
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2229
+ EXIT.ok,
2230
+ )
2231
+ writeSessionState('claude-parent-loop', env, { last_prompt_at: 42 })
2232
+ writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
2233
+ io.outLines = []
2234
+
2235
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2236
+ expect(readSessionState('claude-parent-loop', env).pending?.question).toBe('Ship it?')
2237
+ })
2238
+
2239
+ it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
2240
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-'))
2241
+ const io = new CapturedIo()
2242
+ const env = {
2243
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2244
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2245
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
2246
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
2247
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-current',
2248
+ }
2249
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2250
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
2251
+ writeSessionState('opencode-other', env, { last_prompt_at: 42 })
2252
+ writeProjectSession(cwd, env, 'opencode-other', 42, 'opencode')
2253
+ io.outLines = []
2254
+
2255
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2256
+ expect(io.errLines.join(' ')).toMatch(/another OpenCode session/i)
2257
+ })
2258
+
2259
+ it('recognizes Cursor only from its active-agent marker', () => {
2260
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
2261
+ const io = new CapturedIo()
2262
+ const env = {
2263
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2264
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2265
+ CURSOR_AGENT: '1',
2266
+ }
2267
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2268
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
2269
+ writeSessionState('cursor-live', env, { last_prompt_at: 42 })
2270
+ writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
2271
+ io.outLines = []
2272
+
2273
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2274
+ expect(readSessionState('cursor-live', env).pending?.question).toBe('Ship it?')
2275
+ })
2276
+
2277
+ it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
2278
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-doctor-'))
2279
+ const io = new CapturedIo()
2280
+ const env = {
2281
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2282
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2283
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2284
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2285
+ CLAUDECODE: '1',
2286
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
2287
+ }
2288
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2289
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2290
+ writeSessionState('codex-live', env, { last_prompt_at: 42 })
2291
+ writeProjectSession(cwd, env, 'codex-live', 42, 'codex')
2292
+ io.outLines = []
2293
+
2294
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2295
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+Question routing:.*active Claude Code/is)
2296
+ })
2297
+
2298
+ it('shows resolved question-routing values with their winning config sources', async () => {
2299
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
2300
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
2301
+ writeFileSync(
2302
+ path.join(cwd, '.notifai', 'config.local.toml'),
2303
+ 'ask_notifications = false\nrequire_idle = false\naway_after_seconds = 45\nask_grace_seconds = 90\nhook_reply_timeout_seconds = 120\n',
2304
+ )
2305
+ const io = new CapturedIo()
2306
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
2307
+
2308
+ await doctorCommand(deps, {})
2309
+
2310
+ const said = io.outLines.join('\n')
2311
+ expect(said).toContain('Question routing settings:')
2312
+ expect(said).toContain('ask_notifications=false (project-local:')
2313
+ expect(said).toContain('require_idle=false (project-local:')
2314
+ expect(said).toContain('away_after_seconds=45 (project-local:')
2315
+ expect(said).toContain('ask_grace_seconds=90 (project-local:')
2316
+ expect(said).toContain('hook_reply_timeout_seconds=120 (project-local:')
2317
+ })
2318
+
2319
+ it('fails doctor when runtime waits outgrow an installed Stop timeout', async () => {
2320
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
2321
+ const io = new CapturedIo()
2322
+ const env = { XDG_STATE_HOME: path.join(cwd, 'state'), CLAUDE_CONFIG_DIR: path.join(cwd, 'claude') }
2323
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
2324
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2325
+ EXIT.ok,
2326
+ )
2327
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
2328
+ writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 400\n')
2329
+ io.outLines = []
2330
+
2331
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2332
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(timeout\)/)
2333
+ expect(io.outLines.join('\n')).toContain('notifai hooks install --harness claude-code')
2334
+ })
2335
+
2336
+ it('documents the failing exit contract in doctor JSON', async () => {
2337
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-exit-json-'))
2338
+ const io = new CapturedIo()
2339
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
2340
+
2341
+ expect(await doctorCommand(deps, { json: true })).toBe(EXIT.failed)
2342
+ const payload = JSON.parse(io.outLines[0] ?? '{}') as { ok?: boolean; exit_code?: number }
2343
+ expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
2344
+ })
2345
+
1515
2346
  it('tells Claude Code to send a prompt without falsely requiring a restart', () => {
1516
2347
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
1517
2348
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
@@ -1533,6 +2364,43 @@ describe('asking before the hooks have ever run', () => {
1533
2364
  expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
1534
2365
  })
1535
2366
 
2367
+ it('gives Cursor its prompt-and-doctor activation path', () => {
2368
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
2369
+ const io = new CapturedIo()
2370
+ const deps = {
2371
+ ...makeDeps(io, {} as ApiClient),
2372
+ cwd,
2373
+ env: { XDG_STATE_HOME: path.join(cwd, 'state') },
2374
+ }
2375
+
2376
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
2377
+ io.outLines = []
2378
+
2379
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2380
+ expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
2381
+ })
2382
+
2383
+ it('keeps OpenCode activation separate from answer continuation', () => {
2384
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
2385
+ const io = new CapturedIo()
2386
+ const deps = {
2387
+ ...makeDeps(io, {} as ApiClient),
2388
+ cwd,
2389
+ env: {
2390
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2391
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
2392
+ },
2393
+ }
2394
+
2395
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
2396
+ io.outLines = []
2397
+
2398
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2399
+ const said = io.errLines.join(' ')
2400
+ expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
2401
+ expect(said).toContain('device answer is delivered on the next prompt')
2402
+ })
2403
+
1536
2404
  it('says to install when nothing is installed at all', () => {
1537
2405
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-noinstall-'))
1538
2406
  const io = new CapturedIo()
@@ -1572,7 +2440,7 @@ describe('a server behind this CLI', () => {
1572
2440
  expect(said).not.toMatch(/older than this CLI/)
1573
2441
  })
1574
2442
 
1575
- it('doctor says plainly that the server needs deploying', async () => {
2443
+ it('doctor uses alpha-user wording when the server is behind the CLI', async () => {
1576
2444
  const io = new CapturedIo()
1577
2445
  const client = {
1578
2446
  health: async () => true,
@@ -1593,7 +2461,8 @@ describe('a server behind this CLI', () => {
1593
2461
  const said = io.outLines.concat(io.errLines).join(' ')
1594
2462
  // The label is the user's word for it; the detail is what must survive.
1595
2463
  expect(said).toMatch(/Protocol version/)
1596
- expect(said).toMatch(/needs deploying/)
2464
+ expect(said).toMatch(/service is being updated/)
2465
+ expect(said).not.toMatch(/needs deploying/)
1597
2466
  })
1598
2467
 
1599
2468
  it('doctor is quiet when both sides agree', async () => {