@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.
package/src/commands.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  REPLY_MAX_WINDOW_SECONDS,
10
10
  validateDraft,
11
11
  type EvidenceSnapshot,
12
+ type AccountAccessResponse,
12
13
  type ListRepliesResponse,
13
14
  type Platform,
14
15
  type ReplyView,
@@ -46,9 +47,12 @@ import {
46
47
  parseHookInput,
47
48
  pruneAbandonedSessions,
48
49
  readProjectSession,
50
+ readProjectSessionPointer,
51
+ readSessionState,
49
52
  registerQuestion,
50
53
  type HookContext,
51
54
  type HookEnvelope,
55
+ type HookHarness,
52
56
  } from './hooks.js'
53
57
  import { readIdleSeconds } from './idle.js'
54
58
  import {
@@ -85,6 +89,7 @@ import {
85
89
  receiptExitCode,
86
90
  type SendFlags,
87
91
  } from './send.js'
92
+ import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
88
93
 
89
94
  export interface CommandIo {
90
95
  out(line: string): void
@@ -127,6 +132,8 @@ export interface CommandDeps {
127
132
  sleep?: (milliseconds: number) => Promise<void>
128
133
  /** Test seam for the OS idle probe; production shells out to the platform. */
129
134
  idleSeconds?: () => number | null
135
+ /** Test seam and production adapter for the external native skills installer. */
136
+ nativeSkills?: NativeSkills
130
137
  }
131
138
 
132
139
  export const EXIT = {
@@ -207,7 +214,7 @@ export async function loginCommand(
207
214
 
208
215
  const interactive = deps.io.interactive === true
209
216
  if (interactive) {
210
- await deps.io.intro?.('NotifAI sign in')
217
+ await deps.io.intro?.('Notifai sign in')
211
218
  await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine')
212
219
  } else {
213
220
  deps.io.out(`Pairing code: ${begin.code}`)
@@ -220,7 +227,15 @@ export async function loginCommand(
220
227
  const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000
221
228
  const now = deps.now ?? Date.now
222
229
  const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
223
- const spinner = interactive ? await deps.io.spinner?.('Waiting for approval…') : null
230
+ const approvalWaitMessage = (): string => {
231
+ const remainingSec = Math.max(0, Math.ceil((expiresAt - now()) / 1000))
232
+ const minutes = Math.floor(remainingSec / 60)
233
+ const seconds = remainingSec % 60
234
+ const remaining =
235
+ minutes > 0 ? `${minutes}m ${seconds.toString().padStart(2, '0')}s` : `${seconds}s`
236
+ return `Waiting for approval… code ${begin.code} · ${remaining} left`
237
+ }
238
+ const spinner = interactive ? await deps.io.spinner?.(approvalWaitMessage()) : null
224
239
  while (now() < expiresAt) {
225
240
  await sleep(intervalMs)
226
241
  let poll
@@ -228,7 +243,7 @@ export async function loginCommand(
228
243
  poll = await client.pollPairing(begin.pairing_id, pollVerifier)
229
244
  } catch (err) {
230
245
  if (err instanceof NetworkError) {
231
- spinner?.message('Connection lost — retrying…')
246
+ spinner?.message(`Connection lost — retrying… code ${begin.code}`)
232
247
  continue
233
248
  }
234
249
  spinner?.error('Pairing failed')
@@ -249,8 +264,18 @@ export async function loginCommand(
249
264
  deps.io.err('Pairing was denied from the dashboard.')
250
265
  return EXIT.auth
251
266
  }
267
+ if (poll.status === 'no_active_plan') {
268
+ const next =
269
+ poll.next_action ??
270
+ `Open ${baseUrl.replace(/\/$/, '')}/support to request Alpha access, then retry.`
271
+ spinner?.error('Account has no Alpha access')
272
+ deps.io.err('This account has no active plan or temporary Alpha access.')
273
+ deps.io.err(`next: ${next}`)
274
+ deps.io.err('After access is granted, run `notifai login` again.')
275
+ return EXIT.auth
276
+ }
252
277
  if (poll.status === 'expired') break
253
- spinner?.message('Waiting for approval…')
278
+ spinner?.message(approvalWaitMessage())
254
279
  }
255
280
  spinner?.error('Pairing expired')
256
281
  deps.io.err('Pairing expired before it was approved. Run `notifai login` again.')
@@ -293,6 +318,34 @@ export function authStatusCommand(deps: CommandDeps, flags: { json?: boolean }):
293
318
  return EXIT.ok
294
319
  }
295
320
 
321
+ /** Show the server's account access decision without attempting a product mutation. */
322
+ export async function accessStatusCommand(
323
+ deps: CommandDeps,
324
+ flags: { json?: boolean },
325
+ ): Promise<number> {
326
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
327
+ const authed = authedClient(deps, config)
328
+ if (!authed) return EXIT.auth
329
+ try {
330
+ const access: AccountAccessResponse = await authed.client.accessStatus()
331
+ if (flags.json) {
332
+ deps.io.out(JSON.stringify(access, null, 2))
333
+ return access.status === 'active' ? EXIT.ok : EXIT.failed
334
+ }
335
+ if (access.status === 'no_active_plan') {
336
+ const supportUrl = `${authed.baseUrl.replace(/\/$/, '')}/support`
337
+ deps.io.out('No active plan or temporary Alpha access for this account.')
338
+ deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`)
339
+ return EXIT.failed
340
+ }
341
+ const expiry = access.expires_at ? ` until ${access.expires_at}` : ''
342
+ deps.io.out(`Access active (${access.reason})${expiry}`)
343
+ return EXIT.ok
344
+ } catch (err) {
345
+ return reportError(deps, err)
346
+ }
347
+ }
348
+
296
349
  // ---------------------------------------------------------------------------
297
350
  // devices / capabilities
298
351
  // ---------------------------------------------------------------------------
@@ -308,7 +361,16 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
308
361
  return EXIT.ok
309
362
  }
310
363
  if (result.devices.length === 0) {
311
- deps.io.out('No devices. Install a NotifAI companion app on a device and sign in.')
364
+ const supportUrl = supportPageUrl(authed.baseUrl)
365
+ let email: string | null = null
366
+ try {
367
+ email = (await authed.client.accessStatus()).email
368
+ } catch {
369
+ // Best-effort: the empty-state copy still points at /support without it.
370
+ }
371
+ deps.io.out(
372
+ `No devices yet. Install Notifai from ${supportUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`,
373
+ )
312
374
  return EXIT.ok
313
375
  }
314
376
  for (const d of result.devices) {
@@ -365,6 +427,10 @@ export async function sendCommand(
365
427
  const hasReplyChoice = Array.isArray(flags.replyChoice)
366
428
  ? flags.replyChoice.length > 0
367
429
  : flags.replyChoice !== undefined
430
+ if (flags.reply && flags.kind === 'done') {
431
+ deps.io.err('--kind done cannot be combined with --reply; a reply request is a question.')
432
+ return EXIT.usage
433
+ }
368
434
  if (
369
435
  !flags.reply &&
370
436
  (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)
@@ -435,6 +501,7 @@ export async function sendCommand(
435
501
  for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
436
502
  return EXIT.usage
437
503
  }
504
+ emitSendWarnings(deps, flags, config)
438
505
  if (
439
506
  !flags.reply &&
440
507
  (flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
@@ -452,11 +519,21 @@ export async function sendCommand(
452
519
  )
453
520
  const receiptExit = receiptExitCode(receipt)
454
521
  if (!flags.json) deps.io.out(formatReceipt(receipt))
522
+ else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
455
523
 
456
524
  // A zero wait can no longer reach here: --reply guarantees a positive one.
457
525
  if (!flags.reply || receiptExit !== EXIT.ok) {
458
526
  if (flags.json) {
459
- deps.io.out(JSON.stringify(flags.reply ? { receipt, replies: [] } : receipt, null, 2))
527
+ deps.io.out(
528
+ flags.reply
529
+ ? JSON.stringify({
530
+ type: 'reply_result',
531
+ request_id: receipt.request_id,
532
+ replies: [],
533
+ degraded: false,
534
+ })
535
+ : JSON.stringify(receipt, null, 2),
536
+ )
460
537
  }
461
538
  return receiptExit
462
539
  }
@@ -469,11 +546,12 @@ export async function sendCommand(
469
546
  })
470
547
  if (flags.json) {
471
548
  deps.io.out(
472
- JSON.stringify(
473
- { receipt, replies: result.response.replies, degraded: result.degraded },
474
- null,
475
- 2,
476
- ),
549
+ JSON.stringify({
550
+ type: 'reply_result',
551
+ request_id: receipt.request_id,
552
+ replies: result.response.replies,
553
+ degraded: result.degraded,
554
+ }),
477
555
  )
478
556
  } else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
479
557
  else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
@@ -481,16 +559,65 @@ export async function sendCommand(
481
559
  deps.io.err(DEGRADED_WAIT_WARNING)
482
560
  return EXIT.network
483
561
  }
562
+ if (result.timedOut) {
563
+ deps.io.err(
564
+ `No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
565
+ `\`notifai close ${receipt.request_id}\`.`,
566
+ )
567
+ }
484
568
  return result.timedOut ? EXIT.noReply : EXIT.ok
485
569
  } catch (err) {
486
570
  return reportError(deps, err)
487
571
  }
488
572
  }
489
573
 
574
+ function emitSendWarnings(
575
+ deps: CommandDeps,
576
+ flags: SendFlags,
577
+ config: ReturnType<typeof loadConfig>,
578
+ ): void {
579
+ if (flags.title.length > 40) {
580
+ deps.io.err(
581
+ `Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`,
582
+ )
583
+ }
584
+ if (looksLikeMarkdown(flags.body)) {
585
+ deps.io.err(
586
+ 'Heads up: --body looks like Markdown, but banners show plain text. Put long-form Markdown in --detail or --detail-file.',
587
+ )
588
+ }
589
+ const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
590
+ if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
591
+ deps.io.err(
592
+ `Heads up: this title announces completion but the notification kind is update. Use --kind done for finished work.`,
593
+ )
594
+ }
595
+ if (
596
+ flags.collapseKey === undefined &&
597
+ config.collapse_key.value !== null &&
598
+ config.collapse_key.source.startsWith('global:')
599
+ ) {
600
+ deps.io.err(
601
+ 'Heads up: collapse_key comes from machine-global config, so unrelated notifications may replace each other. Prefer a project or command-specific --collapse-key.',
602
+ )
603
+ }
604
+ if (flags.ttl !== undefined && flags.ttl > 259_200) {
605
+ deps.io.err(
606
+ 'Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.',
607
+ )
608
+ }
609
+ }
610
+
611
+ function looksLikeMarkdown(value: string): boolean {
612
+ return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(
613
+ value,
614
+ )
615
+ }
616
+
490
617
  export async function repliesCommand(
491
618
  deps: CommandDeps,
492
- requestId: string,
493
- flags: { wait?: number; after?: number; json?: boolean },
619
+ requestedId: string | undefined,
620
+ flags: { wait?: number; after?: number; json?: boolean; pending?: boolean },
494
621
  ): Promise<number> {
495
622
  const waitSeconds = flags.wait ?? 0
496
623
  const afterSeq = flags.after ?? 0
@@ -503,6 +630,28 @@ export async function repliesCommand(
503
630
  return EXIT.usage
504
631
  }
505
632
 
633
+ if (flags.pending === true && requestedId !== undefined) {
634
+ deps.io.err('Pass a request id or --pending, not both.')
635
+ return EXIT.usage
636
+ }
637
+ let requestId = requestedId
638
+ if (flags.pending === true) {
639
+ const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
640
+ if (sessionId === null) {
641
+ deps.io.err('No active session pointer is available in this directory.')
642
+ return EXIT.noReply
643
+ }
644
+ requestId = readSessionState(sessionId, deps.env).pending?.request_id
645
+ if (requestId === undefined) {
646
+ deps.io.err(`Session ${sessionId} has no pushed question pending.`)
647
+ return EXIT.noReply
648
+ }
649
+ }
650
+ if (requestId === undefined) {
651
+ deps.io.err('Pass a request id or --pending.')
652
+ return EXIT.usage
653
+ }
654
+
506
655
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
507
656
  const authed = authedClient(deps, config)
508
657
  if (!authed) return EXIT.auth
@@ -515,7 +664,10 @@ export async function repliesCommand(
515
664
  })
516
665
  if (flags.json) {
517
666
  deps.io.out(JSON.stringify({ ...result.response, degraded: result.degraded }, null, 2))
518
- } else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
667
+ } else if (result.response.replies.length > 0) {
668
+ if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
669
+ printReplies(deps, result.response.replies)
670
+ }
519
671
  else printNoReply(deps, requestId, result.response.reply_expires_at)
520
672
  if (result.degraded) {
521
673
  deps.io.err(DEGRADED_WAIT_WARNING)
@@ -679,12 +831,12 @@ export async function statusCommand(
679
831
  if (d.companion_receipt.state === 'observed') {
680
832
  const latency = d.companion_receipt.latency_ms
681
833
  deps.io.out(
682
- ` Companion Receipt: observed at ${d.companion_receipt.observed_at}` +
834
+ ` Companion Receipt (the app's delivery confirmation): observed at ${d.companion_receipt.observed_at}` +
683
835
  (latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`),
684
836
  )
685
837
  } else {
686
838
  deps.io.out(
687
- ' Companion Receipt: unknown — not observed; this is not a failure or proof of non-receipt',
839
+ " Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt",
688
840
  )
689
841
  }
690
842
  for (const e of d.events) {
@@ -772,7 +924,7 @@ export async function hookRunCommand(
772
924
  deps: CommandDeps,
773
925
  event: string,
774
926
  readStdin: () => Promise<string>,
775
- harness?: 'cursor',
927
+ harness?: HookHarness,
776
928
  ): Promise<number> {
777
929
  if (!(HOOK_EVENTS as readonly string[]).includes(event)) {
778
930
  deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
@@ -780,7 +932,17 @@ export async function hookRunCommand(
780
932
  }
781
933
  let envelope: HookEnvelope
782
934
  try {
783
- envelope = parseHookInput(await readStdin())
935
+ const raw = await readStdin()
936
+ if (raw.trim() !== '') {
937
+ try {
938
+ const parsed: unknown = JSON.parse(raw)
939
+ if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
940
+ } catch {
941
+ deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
942
+ return EXIT.ok
943
+ }
944
+ }
945
+ envelope = parseHookInput(raw)
784
946
  if (harness === 'cursor') {
785
947
  const sessionId = envelope.session_id ?? envelope.conversation_id
786
948
  const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
@@ -814,7 +976,10 @@ export async function hookRunCommand(
814
976
  const cwd = envelope.cwd ?? deps.cwd
815
977
  const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
816
978
  const credential = deps.store.load()
817
- if (!credential) return EXIT.ok
979
+ if (!credential) {
980
+ deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`')
981
+ return EXIT.ok
982
+ }
818
983
  // Pin authenticated traffic to the origin the credential was issued for. A
819
984
  // repository can commit `.notifai/config.toml`, and honouring a base_url
820
985
  // from it would hand this machine's bearer token to whatever host it names.
@@ -854,6 +1019,7 @@ export async function hookRunCommand(
854
1019
  degraded: result.degraded,
855
1020
  }
856
1021
  },
1022
+ ...(harness === undefined ? {} : { harness }),
857
1023
  }
858
1024
 
859
1025
  // Real clock, deliberately, not `deps.now`. This compares against file
@@ -941,18 +1107,50 @@ export interface AskFlags {
941
1107
  * `require_idle = false` intentionally permits a push while the user is active.
942
1108
  */
943
1109
  export function askCommand(deps: CommandDeps, question: string, flags: AskFlags): number {
944
- // An agent calling this gets no hook payload and no harness exports its
945
- // session id, so the UserPromptSubmit hook leaves a pointer keyed on the
946
- // project directory and we read it back here. The pointer outranks the
1110
+ // An agent calling this gets no hook payload. Harness-native environment
1111
+ // markers identify the active owner, while the UserPromptSubmit hook leaves
1112
+ // a pointer keyed on the project directory with the hook's canonical id.
1113
+ // The pointer outranks the
947
1114
  // NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
948
1115
  // label rather than the harness's own id, and the hooks key state by the
949
1116
  // latter — the env var is only trusted when no hook has spoken.
950
1117
  const now = (deps.now ?? Date.now)()
951
- const sessionId =
952
- flags.session ??
953
- readProjectSession(deps.cwd, deps.env, now) ??
954
- deps.env['NOTIFAI_SESSION'] ??
955
- undefined
1118
+ const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now)
1119
+ const pointer = projectPointer?.sessionId ?? null
1120
+ const active = activeHarnessSession(deps.env)
1121
+ let sessionId: string | undefined
1122
+ if (flags.session !== undefined) {
1123
+ sessionId = flags.session
1124
+ } else if (active !== null) {
1125
+ const installations = findInstallations(deps.cwd, deps.env)
1126
+ const activeInstalled = installations.some(
1127
+ (installation) => installation.harness === active.harness,
1128
+ )
1129
+ if (!activeInstalled) {
1130
+ for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
1131
+ deps.io.err(line)
1132
+ }
1133
+ return EXIT.usage
1134
+ }
1135
+ if (projectPointer === null) {
1136
+ for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
1137
+ deps.io.err(line)
1138
+ }
1139
+ return EXIT.usage
1140
+ }
1141
+ if (
1142
+ projectPointer.harness !== active.harness ||
1143
+ (active.sessionId !== undefined && projectPointer.sessionId !== active.sessionId)
1144
+ ) {
1145
+ for (const line of diagnoseActiveHarnessSession(active, 'different-session', installations)) {
1146
+ deps.io.err(line)
1147
+ }
1148
+ return EXIT.usage
1149
+ }
1150
+ sessionId = projectPointer.sessionId
1151
+ } else {
1152
+ sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined
1153
+ }
956
1154
  if (!sessionId) {
957
1155
  for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
958
1156
  return EXIT.usage
@@ -994,6 +1192,79 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
994
1192
  return EXIT.ok
995
1193
  }
996
1194
 
1195
+ /**
1196
+ * Exact evidence that this shell command is running inside one supported
1197
+ * harness. Configuration-directory variables are deliberately absent: they
1198
+ * describe where a tool stores files, not which tool owns the current shell.
1199
+ * OpenCode's generated plugin supplies the Notifai-owned marker because its
1200
+ * plugin API exposes session identity but the ordinary environment does not.
1201
+ */
1202
+ interface ActiveHarnessSession {
1203
+ harness: Harness
1204
+ label: string
1205
+ sessionId?: string
1206
+ }
1207
+
1208
+ function activeHarnessSession(env: NodeJS.ProcessEnv): ActiveHarnessSession | null {
1209
+ const explicit = env['NOTIFAI_ACTIVE_HARNESS']
1210
+ if (explicit === 'opencode') {
1211
+ const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID']
1212
+ return {
1213
+ harness: 'opencode',
1214
+ label: 'OpenCode',
1215
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1216
+ }
1217
+ }
1218
+ if (env['CLAUDECODE'] === '1') {
1219
+ const sessionId = env['CLAUDE_CODE_SESSION_ID']
1220
+ return {
1221
+ harness: 'claude-code',
1222
+ label: 'Claude Code',
1223
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1224
+ }
1225
+ }
1226
+ const codexSession = env['CODEX_THREAD_ID']
1227
+ if (codexSession !== undefined && codexSession !== '') {
1228
+ return { harness: 'codex', label: 'Codex', sessionId: codexSession }
1229
+ }
1230
+ if ((env['CURSOR_AGENT'] ?? '') !== '') return { harness: 'cursor', label: 'Cursor' }
1231
+ return null
1232
+ }
1233
+
1234
+ type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
1235
+
1236
+ function diagnoseActiveHarnessSession(
1237
+ active: ActiveHarnessSession,
1238
+ problem: ActiveHarnessProblem,
1239
+ installations: Installation[],
1240
+ ): string[] {
1241
+ if (problem === 'not-installed') {
1242
+ const others = installations.map((installation) => installation.harness)
1243
+ return [
1244
+ `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
1245
+ ...(others.length === 0
1246
+ ? []
1247
+ : [
1248
+ `Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
1249
+ ]),
1250
+ `Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
1251
+ `Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
1252
+ ]
1253
+ }
1254
+ if (problem === 'different-session') {
1255
+ return [
1256
+ `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
1257
+ `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
1258
+ `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
1259
+ ]
1260
+ }
1261
+ return [
1262
+ `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
1263
+ `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
1264
+ `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
1265
+ ]
1266
+ }
1267
+
997
1268
  /**
998
1269
  * Why `ask` cannot see a session, in terms of what to do about it.
999
1270
  *
@@ -1007,13 +1278,13 @@ function diagnoseMissingSession(deps: CommandDeps): string[] {
1007
1278
  const installations = findInstallations(deps.cwd, deps.env)
1008
1279
  if (installations.length === 0) {
1009
1280
  return [
1010
- 'Could not tell which harness session this is: no NotifAI hooks are installed for this project.',
1281
+ 'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
1011
1282
  'Run `notifai hooks install`, then follow the activation instruction it prints.',
1012
1283
  ]
1013
1284
  }
1014
1285
  const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ')
1015
1286
  return [
1016
- `Could not tell which harness session this is. NotifAI hooks are installed (${where}),`,
1287
+ `Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
1017
1288
  'but no usable session pointer from the last 24 hours exists here.',
1018
1289
  hookActivationAdvice(installations),
1019
1290
  'To ask from this session anyway, pass --session <id>.',
@@ -1060,7 +1331,9 @@ function hookActivationAdvice(installations: Installation[]): string {
1060
1331
  )
1061
1332
  }
1062
1333
  if (harnesses.has('opencode')) {
1063
- advice.push('OpenCode: restart it, then send one prompt; plugins load at startup')
1334
+ advice.push(
1335
+ 'OpenCode: restart it, then send one prompt; plugins load at startup, and a device answer is delivered on the next prompt',
1336
+ )
1064
1337
  }
1065
1338
  return `${advice.join('. ')}.`
1066
1339
  }
@@ -1123,6 +1396,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1123
1396
  scriptPath,
1124
1397
  replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1125
1398
  graceSeconds: config.ask_grace_seconds.value,
1399
+ harness: 'cursor',
1126
1400
  }),
1127
1401
  scriptPath,
1128
1402
  )
@@ -1142,8 +1416,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1142
1416
  deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
1143
1417
  deps.io.out('start a new Cursor session and try one prompt again.')
1144
1418
  }
1145
- deps.io.out('A companion-device answer is submitted as one')
1146
- deps.io.out('follow-up user message; loop_limit = 1 prevents repeated answer turns.')
1419
+ deps.io.out('A companion-device answer is submitted as one follow-up user message.')
1420
+ deps.io.out('loop_limit = 3 permits bounded chained questions without an unbounded loop.')
1147
1421
  return EXIT.ok
1148
1422
  }
1149
1423
 
@@ -1161,6 +1435,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1161
1435
  scriptPath,
1162
1436
  replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1163
1437
  graceSeconds: config.ask_grace_seconds.value,
1438
+ harness,
1164
1439
  }),
1165
1440
  scriptPath,
1166
1441
  )
@@ -1184,11 +1459,11 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1184
1459
  `nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
1185
1460
  `until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
1186
1461
  `has elapsed; it goes to your devices only while the machine also meets the idle threshold. ` +
1187
- `Set \`require_idle = false\` to be notified even while you are working.`
1462
+ `Run \`notifai config set require_idle false\` to be notified even while you are working.`
1188
1463
  : `A question registered with \`notifai ask\` stays in the terminal for ` +
1189
1464
  `${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
1190
1465
  `whether or not you are at this machine ` +
1191
- `(\`require_idle = false\`).`,
1466
+ `(\`notifai config set require_idle false\` is already in effect).`,
1192
1467
  )
1193
1468
  if (config.require_idle.value) {
1194
1469
  deps.io.out(
@@ -1226,7 +1501,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1226
1501
  }
1227
1502
 
1228
1503
  /**
1229
- * Writes the OpenCode plugin, replacing any NotifAI plugin already there —
1504
+ * Writes the OpenCode plugin, replacing any Notifai plugin already there —
1230
1505
  * including one a different checkout wrote, matched on the managed marker for
1231
1506
  * the same reason command hooks are.
1232
1507
  */
@@ -1238,7 +1513,7 @@ function installOpencodePlugin(
1238
1513
  if (existsSync(file)) {
1239
1514
  const existing = readFileSync(file, 'utf8')
1240
1515
  if (!isOurOpencodePlugin(existing)) {
1241
- deps.io.err(`${file} exists and was not written by NotifAI; move it aside first.`)
1516
+ deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`)
1242
1517
  return EXIT.failed
1243
1518
  }
1244
1519
  }
@@ -1254,8 +1529,9 @@ function installOpencodePlugin(
1254
1529
  deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
1255
1530
  deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
1256
1531
  deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.')
1257
- deps.io.out('OpenCode cannot reliably resume an idle agent turn with a device answer; use')
1258
- deps.io.out('`notifai send --reply` when the answer must return to the agent.')
1532
+ deps.io.out('OpenCode pushes without holding its idle event open; the next user prompt')
1533
+ deps.io.out('collects a device answer and adds it to the agent context. Use `notifai')
1534
+ deps.io.out('send --reply` only when the answer must return before another prompt.')
1259
1535
  deps.io.out('')
1260
1536
  deps.io.out('Restart OpenCode: it loads plugins once at start.')
1261
1537
  return EXIT.ok
@@ -1273,11 +1549,11 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1273
1549
  if (harness === 'opencode') {
1274
1550
  // We own the whole file, but only if we wrote it.
1275
1551
  if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
1276
- deps.io.out(`Left ${file} alone: NotifAI did not write it.`)
1552
+ deps.io.out(`Left ${file} alone: Notifai did not write it.`)
1277
1553
  return EXIT.ok
1278
1554
  }
1279
1555
  rmSync(file, { force: true })
1280
- deps.io.out(`Removed the NotifAI OpenCode plugin at ${file}`)
1556
+ deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
1281
1557
  return EXIT.ok
1282
1558
  }
1283
1559
  if (harness === 'cursor') {
@@ -1297,8 +1573,8 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1297
1573
  }
1298
1574
  deps.io.out(
1299
1575
  stripped.replaced.length > 0
1300
- ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1301
- : `No NotifAI hooks found in ${file}`,
1576
+ ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1577
+ : `No Notifai hooks found in ${file}`,
1302
1578
  )
1303
1579
  return EXIT.ok
1304
1580
  }
@@ -1318,8 +1594,8 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1318
1594
  }
1319
1595
  deps.io.out(
1320
1596
  stripped.replaced.length > 0
1321
- ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1322
- : `No NotifAI hooks found in ${file}`,
1597
+ ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1598
+ : `No Notifai hooks found in ${file}`,
1323
1599
  )
1324
1600
  return EXIT.ok
1325
1601
  }
@@ -1453,27 +1729,69 @@ export async function configSetCommand(
1453
1729
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1454
1730
  * Keep this immutable and public because the command is printed to users.
1455
1731
  */
1456
- export const SKILLS_SOURCE = 'RafaelVidaurre/notifai#v0.1.7'
1732
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.1.8'
1457
1733
 
1458
- function pinnedSkillInstalled(cwd: string): boolean {
1734
+ function skillSourceParts(): { source: string; ref: string } | null {
1459
1735
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
1460
- if (!match) return false
1461
- const lockPath = path.join(cwd, 'skills-lock.json')
1462
- const installedPath = path.join(cwd, '.agents', 'skills', 'notifai', 'SKILL.md')
1463
- if (!existsSync(lockPath) || !existsSync(installedPath)) return false
1464
- try {
1465
- const parsed = JSON.parse(readFileSync(lockPath, 'utf8')) as {
1466
- skills?: Record<string, { source?: unknown; ref?: unknown; skillPath?: unknown }>
1736
+ return match === null ? null : { source: match[1]!, ref: match[2]! }
1737
+ }
1738
+
1739
+ function expectedSkill(skill: NativeSkill): boolean {
1740
+ const expected = skillSourceParts()
1741
+ return (
1742
+ expected !== null &&
1743
+ skill.name === 'notifai' &&
1744
+ skill.source === expected.source &&
1745
+ skill.sourceType === 'github' &&
1746
+ skill.ref === expected.ref
1747
+ )
1748
+ }
1749
+
1750
+ async function skillReadiness(
1751
+ deps: CommandDeps,
1752
+ selectedScope?: SkillScope,
1753
+ ): Promise<ReadinessState> {
1754
+ const scopes: SkillScope[] = selectedScope === undefined ? ['project', 'global'] : [selectedScope]
1755
+ const results = await Promise.all(
1756
+ scopes.map(async (scope) => {
1757
+ if (deps.nativeSkills === undefined) return { scope, skills: [] as NativeSkill[] }
1758
+ try {
1759
+ return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) }
1760
+ } catch (err) {
1761
+ return { scope, skills: [] as NativeSkill[], error: String(err) }
1762
+ }
1763
+ }),
1764
+ )
1765
+ const installed = results.flatMap(({ skills }) => skills).find(expectedSkill)
1766
+ if (installed !== undefined) {
1767
+ return {
1768
+ id: 'skill',
1769
+ title: 'Agent guidance skill',
1770
+ status: 'ready',
1771
+ detail: `installed from ${SKILLS_SOURCE} in the ${installed.scope} scope`,
1467
1772
  }
1468
- const skill = parsed.skills?.notifai
1469
- if (!skill) return false
1470
- return (
1471
- skill.source === match[1] &&
1472
- skill.ref === match[2] &&
1473
- skill.skillPath === 'skills/notifai/SKILL.md'
1474
- )
1475
- } catch {
1476
- return false
1773
+ }
1774
+
1775
+ const errors = results
1776
+ .filter((result) => result.error !== undefined)
1777
+ .map((result) => `${result.scope}: ${result.error}`)
1778
+ const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`
1779
+ return {
1780
+ id: 'skill',
1781
+ title: 'Agent guidance skill',
1782
+ status: 'optional-gap',
1783
+ detail:
1784
+ errors.length > 0
1785
+ ? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
1786
+ : `not installed from ${SKILLS_SOURCE} in ${scopeText}`,
1787
+ remedy: {
1788
+ by: 'cli',
1789
+ summary: 'install the skill agents follow when deciding to notify',
1790
+ command:
1791
+ selectedScope === undefined
1792
+ ? 'notifai init --skills'
1793
+ : `notifai init --skills --skills-scope ${selectedScope}`,
1794
+ },
1477
1795
  }
1478
1796
  }
1479
1797
 
@@ -1496,6 +1814,8 @@ export interface InitFlags {
1496
1814
  * never spawn npx against the network by default.
1497
1815
  */
1498
1816
  skills?: boolean
1817
+ /** Scope selected by an unattended caller; humans choose inside npx skills. */
1818
+ skillsScope?: SkillScope
1499
1819
  /** Same tri-state, for the harness hooks. */
1500
1820
  hooks?: boolean
1501
1821
  }
@@ -1507,11 +1827,47 @@ interface SetupProofRecord {
1507
1827
  started_at: string
1508
1828
  }
1509
1829
 
1510
- const DEVICE_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000
1830
+ /** Long enough for a first TestFlight install; keep-waiting extends another budget. */
1831
+ const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000
1511
1832
  const DEVICE_BRIDGE_POLL_MS = 2_000
1512
1833
  const PROOF_TIMEOUT_MS = 30_000
1513
1834
  const PROOF_POLL_MS = 1_000
1514
1835
 
1836
+ function supportPageUrl(baseUrl: string): string {
1837
+ return `${baseUrl.replace(/\/$/, '')}/support`
1838
+ }
1839
+
1840
+ function formatWaitBudget(milliseconds: number): string {
1841
+ const minutes = Math.round(milliseconds / 60_000)
1842
+ return minutes === 1 ? '1 minute' : `${minutes} minutes`
1843
+ }
1844
+
1845
+ /** Same-account line for the device hop; prefers the real email when known. */
1846
+ function sameAccountSignInLine(email: string | null | undefined): string {
1847
+ return email
1848
+ ? `sign in with the same email as this account (${email})`
1849
+ : 'sign in with the same email as this account'
1850
+ }
1851
+
1852
+ function deviceInstallRemedy(options: {
1853
+ baseUrl: string
1854
+ email: string | null
1855
+ devices: readonly RoutableDevice[]
1856
+ }): string {
1857
+ const support = supportPageUrl(options.baseUrl)
1858
+ const sameEmail = sameAccountSignInLine(options.email)
1859
+ if (options.devices.length === 0) {
1860
+ return (
1861
+ `open the install steps at ${support} on that device (or your phone’s browser), ` +
1862
+ `install Notifai, ${sameEmail}, and allow notifications`
1863
+ )
1864
+ }
1865
+ if (options.devices.some((d) => d.permission_status === 'denied')) {
1866
+ return 'allow notifications for Notifai in that device’s system settings'
1867
+ }
1868
+ return 'open Notifai on that device and allow its notification prompt'
1869
+ }
1870
+
1515
1871
  function setupProofPath(deps: CommandDeps): string {
1516
1872
  let projectDir = path.resolve(deps.cwd)
1517
1873
  try {
@@ -1624,18 +1980,27 @@ async function closeGap(
1624
1980
  }
1625
1981
 
1626
1982
  if (state.id === 'skill') {
1627
- deps.io.out(`Installing the notifai agent skill (npx skills add ${SKILLS_SOURCE})...`)
1628
- const code = await new Promise<number>((resolve) => {
1629
- const child = spawn('npx', ['-y', 'skills', 'add', SKILLS_SOURCE, '--skill', 'notifai'], {
1630
- cwd: deps.cwd,
1631
- stdio: 'inherit',
1632
- })
1633
- child.on('error', () => resolve(1))
1634
- child.on('exit', (exitCode) => resolve(exitCode ?? 1))
1635
- })
1983
+ if (deps.nativeSkills === undefined) {
1984
+ deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
1985
+ return 'failed'
1986
+ }
1987
+ const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`
1988
+ deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`)
1989
+ const addOptions = {
1990
+ source: SKILLS_SOURCE,
1991
+ skill: 'notifai',
1992
+ cwd: deps.cwd,
1993
+ env: deps.env,
1994
+ ...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
1995
+ }
1996
+ const code = await deps.nativeSkills.add(addOptions)
1636
1997
  if (code !== 0) {
1637
1998
  deps.io.err('Skill installation failed — run it manually with:')
1638
- deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`)
1999
+ deps.io.err(
2000
+ ` npx skills add ${SKILLS_SOURCE} --skill notifai${
2001
+ flags.skillsScope === 'global' ? ' --global' : ''
2002
+ }${flags.skillsScope === undefined ? '' : ' --yes'}`,
2003
+ )
1639
2004
  }
1640
2005
  return code === 0 ? 'closed' : 'failed'
1641
2006
  }
@@ -1669,55 +2034,104 @@ function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
1669
2034
 
1670
2035
  /**
1671
2036
  * Observe the supported Device Installation path while the user finishes the
1672
- * app-side work. There is deliberately no invented QR or download URL here:
1673
- * this release exposes neither an App Store/TestFlight URL nor a device-pairing
1674
- * endpoint, and a placeholder bridge is worse than an explicit boundary.
2037
+ * app-side work. The live bridge is the dashboard `/support` page (TestFlight
2038
+ * steps). Interactive runs offer to open it so the user never types the URL;
2039
+ * non-interactive/agent paths only print plain text and never wait on a prompt.
2040
+ *
2041
+ * The wait budget is stated up front. On expiry we say the *timer* expired —
2042
+ * not the setup — and offer another budget when a human is present. Agents
2043
+ * never hang: this whole path is gated on `io.interactive`, and keep-waiting
2044
+ * uses `confirm` which resolves the safe default when not interactive.
1675
2045
  */
1676
2046
  async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Promise<GapCloseResult> {
1677
2047
  const remedy = state.remedy
1678
2048
  if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
1679
2049
 
2050
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
2051
+ const authed = authedClient(deps, config)
2052
+ if (!authed) {
2053
+ deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
2054
+ return 'failed'
2055
+ }
2056
+
2057
+ const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS)
2058
+ const supportUrl = supportPageUrl(authed.baseUrl)
1680
2059
  await deps.io.note?.(
1681
- `${state.detail}\n${remedy.summary}`,
2060
+ [
2061
+ state.detail,
2062
+ remedy.summary,
2063
+ `Install steps (no typing): ${supportUrl}`,
2064
+ `I will wait up to ${budgetLabel} for a companion device to become ready.`,
2065
+ ].join('\n'),
1682
2066
  'Finish setup on your companion device',
1683
2067
  )
2068
+
2069
+ // Open the real support page so the user never has to type the URL. Decline
2070
+ // is fine — the URL remains in the note and in the Next: line if they leave.
2071
+ if (await deps.io.confirm('Open install instructions in your browser?', true)) {
2072
+ deps.io.openUrl(supportUrl)
2073
+ }
2074
+
1684
2075
  if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
2076
+ deps.io.out(
2077
+ `OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`,
2078
+ )
1685
2079
  return 'pending'
1686
2080
  }
1687
2081
 
1688
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1689
- const authed = authedClient(deps, config)
1690
- if (!authed) return 'failed'
1691
2082
  const now = deps.now ?? Date.now
1692
- const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
1693
- const deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
1694
- const spinner = await deps.io.spinner?.('Waiting for a companion device…')
2083
+ const sleep =
2084
+ deps.sleep ??
2085
+ ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
2086
+ const spinner = await deps.io.spinner?.(`Waiting up to ${budgetLabel} for a companion device…`)
1695
2087
  let lastDevices: RoutableDevice[] = []
2088
+ let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
1696
2089
 
1697
- while (now() < deadline) {
1698
- try {
1699
- const response = await authed.client.listDevices()
1700
- lastDevices = response.devices
1701
- const ready = response.devices.find(deviceCanReceive)
1702
- if (ready) {
1703
- spinner?.stop(`${ready.display_name} is ready to receive`)
1704
- return 'closed'
1705
- }
1706
- spinner?.message(deviceBridgeMessage(response.devices))
1707
- } catch (err) {
1708
- if (!(err instanceof NetworkError)) {
1709
- spinner?.error('Could not check companion readiness')
1710
- reportError(deps, err)
1711
- return 'failed'
2090
+ for (;;) {
2091
+ while (now() < deadline) {
2092
+ try {
2093
+ const response = await authed.client.listDevices()
2094
+ lastDevices = response.devices
2095
+ const ready = response.devices.find(deviceCanReceive)
2096
+ if (ready) {
2097
+ spinner?.stop(`${ready.display_name} is ready to receive`)
2098
+ return 'closed'
2099
+ }
2100
+ spinner?.message(deviceBridgeMessage(response.devices))
2101
+ } catch (err) {
2102
+ if (!(err instanceof NetworkError)) {
2103
+ spinner?.error('Could not check companion readiness')
2104
+ reportError(deps, err)
2105
+ return 'failed'
2106
+ }
2107
+ spinner?.message('Connection lost — still watching…')
1712
2108
  }
1713
- spinner?.message('Connection lost — still watching…')
2109
+ await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
1714
2110
  }
1715
- await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
1716
- }
1717
2111
 
1718
- spinner?.error('No companion device became ready')
1719
- deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
1720
- return 'pending'
2112
+ // Timer expired — setup did not. Offer another budget only when a human
2113
+ // can answer; confirm() already returns the fallback for agents, so this
2114
+ // never hangs unattended even if interactive were mis-set.
2115
+ spinner?.error(`The ${budgetLabel} wait timer expired`)
2116
+ deps.io.err(
2117
+ `The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`,
2118
+ )
2119
+ deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
2120
+ deps.io.err(
2121
+ `Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`,
2122
+ )
2123
+
2124
+ const keepWaiting = await deps.io.confirm(
2125
+ `Keep waiting for another ${budgetLabel}?`,
2126
+ false,
2127
+ )
2128
+ if (!keepWaiting) {
2129
+ deps.io.out('Stopping the wait. Device setup can continue; re-run `notifai init` when ready.')
2130
+ return 'pending'
2131
+ }
2132
+ spinner?.message(`Waiting another ${budgetLabel} for a companion device…`)
2133
+ deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
2134
+ }
1721
2135
  }
1722
2136
 
1723
2137
  function setupProofDraft(
@@ -1726,7 +2140,7 @@ function setupProofDraft(
1726
2140
  ): ReturnType<typeof buildDraft> {
1727
2141
  const project = config.project.value
1728
2142
  return buildDraft(config, {
1729
- title: 'NotifAI is ready',
2143
+ title: 'Notifai is ready',
1730
2144
  body:
1731
2145
  project === null
1732
2146
  ? 'This real notification completed setup verification.'
@@ -1804,7 +2218,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
1804
2218
  ) ?? candidates[0]
1805
2219
  if (!target) {
1806
2220
  deps.io.err(
1807
- 'Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts.',
2221
+ "Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts (the app's delivery confirmation).",
1808
2222
  )
1809
2223
  return 'pending'
1810
2224
  }
@@ -1836,7 +2250,9 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
1836
2250
  const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
1837
2251
  const deadline = now() + PROOF_TIMEOUT_MS
1838
2252
  const spinner = deps.io.interactive === true
1839
- ? await deps.io.spinner?.('Waiting for a Companion Receipt…')
2253
+ ? await deps.io.spinner?.(
2254
+ "Waiting for a Companion Receipt (the app's delivery confirmation)…",
2255
+ )
1840
2256
  : null
1841
2257
  let lastError: unknown = null
1842
2258
  let replacedMissingProof = false
@@ -1887,11 +2303,11 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
1887
2303
  await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())))
1888
2304
  }
1889
2305
 
1890
- spinner?.error('Companion Receipt not observed yet')
2306
+ spinner?.stop('Delivery confirmation not observed yet')
1891
2307
  if (lastError instanceof NetworkError) deps.io.err(lastError.message)
1892
- deps.io.err(
1893
- `No Companion Receipt was observed for ${proof.request_id} within ${PROOF_TIMEOUT_MS / 1000}s. ` +
1894
- 'That is not proof of non-receipt; re-run `notifai init` to check this same notification again.',
2308
+ deps.io.out(
2309
+ `Provider accepted the notification; Companion Receipt (the app's delivery confirmation) for ${proof.request_id} was not observed within ${PROOF_TIMEOUT_MS / 1000}s. ` +
2310
+ 'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.',
1895
2311
  )
1896
2312
  return 'pending'
1897
2313
  }
@@ -1910,7 +2326,7 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
1910
2326
  const question =
1911
2327
  state.id === 'hooks'
1912
2328
  ? 'Install harness hooks, so questions reach your devices when you are away?'
1913
- : 'Install/update the agent guidance skill in this repo?'
2329
+ : 'Install/update the agent guidance skill through the native npx skills flow?'
1914
2330
  return deps.io.confirm(question, true)
1915
2331
  }
1916
2332
 
@@ -1931,9 +2347,36 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
1931
2347
  * states rather than four branches to enumerate.
1932
2348
  */
1933
2349
  export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
1934
- await deps.io.intro?.('NotifAI setup')
2350
+ if (
2351
+ flags.skillsScope !== undefined &&
2352
+ flags.skillsScope !== 'project' &&
2353
+ flags.skillsScope !== 'global'
2354
+ ) {
2355
+ deps.io.err('Invalid skill scope. Choose `project` or `global`.')
2356
+ return EXIT.usage
2357
+ }
2358
+ if (flags.skillsScope !== undefined && flags.skills !== true) {
2359
+ deps.io.err('`--skills-scope` requires `--skills`. Choose project or global in the native installer.')
2360
+ return EXIT.usage
2361
+ }
2362
+ if (
2363
+ flags.skills === true &&
2364
+ deps.io.interactive !== true &&
2365
+ flags.skillsScope === undefined
2366
+ ) {
2367
+ deps.io.err(
2368
+ 'Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.',
2369
+ )
2370
+ return EXIT.usage
2371
+ }
2372
+ await deps.io.intro?.('Notifai setup')
1935
2373
 
1936
- let readiness = await assessReadiness(deps)
2374
+ const reassess = () =>
2375
+ assessReadiness(
2376
+ deps,
2377
+ flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope },
2378
+ )
2379
+ let readiness = await reassess()
1937
2380
  let failed = false
1938
2381
  const attempted = new Set<string>()
1939
2382
 
@@ -1963,11 +2406,16 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
1963
2406
  attempted.add(state.id)
1964
2407
  const result = await closeGap(deps, state, flags)
1965
2408
  if (result === 'failed') failed = true
2409
+ if (result === 'failed' && state.status === 'optional-gap') {
2410
+ readiness = await reassess()
2411
+ advanced = true
2412
+ break
2413
+ }
1966
2414
  if (result !== 'closed') {
1967
2415
  stop = true
1968
2416
  break
1969
2417
  }
1970
- readiness = await assessReadiness(deps)
2418
+ readiness = await reassess()
1971
2419
  advanced = true
1972
2420
  break
1973
2421
  }
@@ -1980,30 +2428,26 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
1980
2428
  stop = true
1981
2429
  break
1982
2430
  }
1983
- readiness = await assessReadiness(deps)
2431
+ readiness = await reassess()
1984
2432
  advanced = true
1985
2433
  break
1986
2434
  }
1987
2435
 
1988
- // Its to launch, theirs to complete. Offering to start the sign-in is
1989
- // useful only when someone is demonstrably watching; an agent never
1990
- // reaches this prompt or opens a browser.
2436
+ // Its to launch, theirs to complete. Running `init` is the consent; announce
2437
+ // and open rather than re-asking. An agent never reaches this path.
1991
2438
  if (
1992
2439
  remedy.by === 'user-here' &&
1993
2440
  remedy.interactive === true &&
1994
2441
  deps.io.interactive === true
1995
2442
  ) {
1996
2443
  attempted.add(state.id)
1997
- if (!(await deps.io.confirm('Sign in now? (opens your browser)', true))) {
1998
- stop = true
1999
- break
2000
- }
2444
+ deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
2001
2445
  if ((await loginCommand(deps, {})) !== EXIT.ok) {
2002
2446
  failed = true
2003
2447
  stop = true
2004
2448
  break
2005
2449
  }
2006
- readiness = await assessReadiness(deps)
2450
+ readiness = await reassess()
2007
2451
  advanced = true
2008
2452
  break
2009
2453
  }
@@ -2020,7 +2464,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
2020
2464
  stop = true
2021
2465
  break
2022
2466
  }
2023
- readiness = await assessReadiness(deps)
2467
+ readiness = await reassess()
2024
2468
  advanced = true
2025
2469
  break
2026
2470
  }
@@ -2033,14 +2477,16 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
2033
2477
  if (stop || !advanced) break
2034
2478
  }
2035
2479
 
2036
- readiness = await assessReadiness(deps)
2480
+ readiness = await reassess()
2037
2481
  for (const state of readiness.states.filter((s) => s.status === 'ready')) {
2038
2482
  deps.io.out(`${state.title}: ${state.detail}`)
2039
2483
  }
2040
2484
 
2041
2485
  const blocker = firstBlocker(readiness)
2042
2486
  if (blocker === null) {
2043
- const skipped = openItems(readiness)
2487
+ const skipped = openItems(readiness).filter(
2488
+ (state) => !(state.id === 'skill' && flags.skills === false),
2489
+ )
2044
2490
  for (const state of skipped) deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
2045
2491
  deps.io.out('All set. Agents in this project can notify you and ask questions.')
2046
2492
  await deps.io.outro?.('All set ✨')
@@ -2053,7 +2499,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
2053
2499
  deps.io.out('')
2054
2500
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
2055
2501
  deps.io.out(` ${remedyLine(blocker)}`)
2056
- if (blocker.remedy?.by === 'user-elsewhere') {
2502
+ // Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
2503
+ // how the rest is recovered either way.
2504
+ if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
2057
2505
  deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
2058
2506
  }
2059
2507
  await deps.io.outro?.('One step remains (above)')
@@ -2093,9 +2541,8 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
2093
2541
  ok: false,
2094
2542
  detail:
2095
2543
  local !== undefined && remote < local
2096
- ? `server speaks schema v${remote}, this CLI speaks v${local} — the server needs deploying, ` +
2097
- 'until then sends carrying newer fields are rejected'
2098
- : `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI`,
2544
+ ? `server speaks schema v${remote}, this CLI speaks v${local} — the service is being updated; try again later`
2545
+ : `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI: npm install -g @raidiant/notifai`,
2099
2546
  }
2100
2547
  } catch (err) {
2101
2548
  return {
@@ -2115,7 +2562,10 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
2115
2562
  * `unknown`, which is the honest answer and keeps a network outage from
2116
2563
  * looking like a broken install.
2117
2564
  */
2118
- export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
2565
+ export async function assessReadiness(
2566
+ deps: CommandDeps,
2567
+ options: { skillScope?: SkillScope } = {},
2568
+ ): Promise<Readiness> {
2119
2569
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
2120
2570
  const states: ReadinessState[] = []
2121
2571
  let accountClient: ApiClient | null = null
@@ -2223,58 +2673,35 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
2223
2673
  )
2224
2674
  }
2225
2675
 
2676
+ let accountEmail: string | null = null
2677
+ let accountLookupFailed = false
2226
2678
  if (!credential || !reachable) {
2227
2679
  const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
2228
2680
  states.push({ id: 'auth', title: 'Account', status: 'unknown', detail: `not checked — ${why}` })
2229
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` })
2230
2681
  } else {
2231
2682
  const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`)
2232
2683
  accountClient = client
2233
2684
  try {
2234
- const { devices } = await client.listDevices()
2685
+ const [{ devices }, email] = await Promise.all([
2686
+ client.listDevices(),
2687
+ Promise.resolve()
2688
+ .then(async () => (await client.accessStatus()).email)
2689
+ .catch(() => null as string | null),
2690
+ ])
2235
2691
  accountDevices = devices
2236
- const ready = devices.filter(deviceCanReceive)
2692
+ accountEmail = email
2237
2693
  states.push({
2238
2694
  id: 'auth',
2239
2695
  title: 'Account',
2240
2696
  status: 'ready',
2241
- detail: `machine ${credential.machineId} accepted`,
2697
+ detail: accountEmail
2698
+ ? `machine ${credential.machineId} accepted (${accountEmail})`
2699
+ : `machine ${credential.machineId} accepted`,
2242
2700
  })
2243
- states.push(
2244
- ready.length > 0
2245
- ? {
2246
- id: 'devices',
2247
- title: 'Your devices',
2248
- status: 'ready',
2249
- detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
2250
- }
2251
- : {
2252
- id: 'devices',
2253
- title: 'Your devices',
2254
- status: 'gap',
2255
- // The one gap that cannot be closed from this terminal, and the
2256
- // likeliest place a first setup is abandoned. Naming which of
2257
- // the three sub-states it is matters: "install the app" is
2258
- // useless advice to someone who installed it and denied the
2259
- // permission prompt.
2260
- detail:
2261
- devices.length === 0
2262
- ? 'nothing registered yet; this release has no supported App Store/TestFlight URL for the CLI to open'
2263
- : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
2264
- remedy: {
2265
- by: 'user-elsewhere',
2266
- summary:
2267
- devices.length === 0
2268
- ? 'install NotifAI through a supported distribution you already have, open it, sign in with the same account, and allow notifications'
2269
- : devices.some((d) => d.permission_status === 'denied')
2270
- ? 'allow notifications for NotifAI in that device’s system settings'
2271
- : 'open NotifAI on that device and allow its notification prompt',
2272
- },
2273
- },
2274
- )
2275
2701
  } catch (err) {
2276
2702
  // A credential the server rejects is revocation, not absence, and the
2277
2703
  // remedy is the same sign-in either way.
2704
+ accountLookupFailed = true
2278
2705
  states.push({
2279
2706
  id: 'auth',
2280
2707
  title: 'Account',
@@ -2286,32 +2713,56 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
2286
2713
  command: 'notifai login',
2287
2714
  },
2288
2715
  })
2289
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
2290
2716
  }
2291
2717
  }
2292
2718
 
2719
+ // Optional setup that works without a companion device must appear before the
2720
+ // device gap: init stops at the first user-elsewhere blocker, and hooks/skill
2721
+ // are reachable without a phone.
2293
2722
  states.push(...hookStates(deps))
2723
+ states.push(await skillReadiness(deps, options.skillScope))
2294
2724
 
2295
- states.push(
2296
- pinnedSkillInstalled(deps.cwd)
2297
- ? {
2298
- id: 'skill',
2299
- title: 'Agent guidance skill',
2300
- status: 'ready',
2301
- detail: `installed from ${SKILLS_SOURCE} in this project`,
2302
- }
2303
- : {
2304
- id: 'skill',
2305
- title: 'Agent guidance skill',
2306
- status: 'optional-gap',
2307
- detail: `not installed from ${SKILLS_SOURCE} in this project`,
2308
- remedy: {
2309
- by: 'cli',
2310
- summary: 'install the skill agents follow when deciding to notify',
2311
- command: 'notifai init --skills',
2725
+ if (!credential || !reachable) {
2726
+ const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
2727
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` })
2728
+ } else if (accountLookupFailed || accountDevices === null) {
2729
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
2730
+ } else {
2731
+ const devices = accountDevices
2732
+ const ready = devices.filter(deviceCanReceive)
2733
+ states.push(
2734
+ ready.length > 0
2735
+ ? {
2736
+ id: 'devices',
2737
+ title: 'Your devices',
2738
+ status: 'ready',
2739
+ detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
2740
+ }
2741
+ : {
2742
+ id: 'devices',
2743
+ title: 'Your devices',
2744
+ status: 'gap',
2745
+ // The one gap that cannot be closed from this terminal, and the
2746
+ // likeliest place a first setup is abandoned. Naming which of
2747
+ // the three sub-states it is matters: "install the app" is
2748
+ // useless advice to someone who installed it and denied the
2749
+ // permission prompt. The live bridge is /support on the
2750
+ // dashboard origin — not a placeholder, and not typed by hand.
2751
+ detail:
2752
+ devices.length === 0
2753
+ ? `nothing registered yet; install Notifai on iPhone or Mac via ${supportPageUrl(baseUrl)}`
2754
+ : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
2755
+ remedy: {
2756
+ by: 'user-elsewhere',
2757
+ summary: deviceInstallRemedy({
2758
+ baseUrl,
2759
+ email: accountEmail,
2760
+ devices,
2761
+ }),
2762
+ },
2312
2763
  },
2313
- },
2314
- )
2764
+ )
2765
+ }
2315
2766
 
2316
2767
  states.push(await setupProofState(deps, config, accountClient, accountDevices))
2317
2768
 
@@ -2345,16 +2796,18 @@ async function setupProofState(
2345
2796
 
2346
2797
  const ios = readyIosDevices(devices)
2347
2798
  if (ios.length === 0) {
2799
+ // Honest non-blocking caveat: notifications can reach the Mac; only the
2800
+ // receipt proof path is unavailable in this release. Never emit a Next:
2801
+ // step the user cannot satisfy, and never claim unobserved proof.
2348
2802
  return {
2349
2803
  id: 'proof',
2350
2804
  title: 'Delivery proof',
2351
- status: 'gap',
2805
+ status: 'optional-gap',
2352
2806
  detail:
2353
- 'blocked — the current macOS notification path does not emit Companion Receipts, so this CLI cannot prove receipt on a macOS-only setup',
2807
+ "unprovable in this release — notifications can reach your Mac, but the macOS path does not emit Companion Receipts (the app's delivery confirmation); receipt proof needs an iPhone",
2354
2808
  remedy: {
2355
2809
  by: 'user-elsewhere',
2356
- summary:
2357
- 'pair a receipt-capable iPhone build; no supported macOS receipt bridge is available in this release',
2810
+ summary: 'receipt proof needs an iPhone in this release (notifications still reach this Mac)',
2358
2811
  },
2359
2812
  }
2360
2813
  }
@@ -2366,10 +2819,12 @@ async function setupProofState(
2366
2819
  id: 'proof',
2367
2820
  title: 'Delivery proof',
2368
2821
  status: 'gap',
2369
- detail: 'no Companion Receipt has proven this project on this machine yet',
2822
+ detail:
2823
+ "no Companion Receipt (the app's delivery confirmation) has proven this project on this machine yet",
2370
2824
  remedy: {
2371
2825
  by: 'cli',
2372
- summary: 'send one real verification notification and wait for its Companion Receipt',
2826
+ summary:
2827
+ "send one real verification notification and wait for its Companion Receipt (the app's delivery confirmation)",
2373
2828
  command: 'notifai init',
2374
2829
  },
2375
2830
  }
@@ -2383,14 +2838,14 @@ async function setupProofState(
2383
2838
  id: 'proof',
2384
2839
  title: 'Delivery proof',
2385
2840
  status: 'ready',
2386
- detail: `Companion Receipt observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
2841
+ detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
2387
2842
  }
2388
2843
  }
2389
2844
  return {
2390
2845
  id: 'proof',
2391
2846
  title: 'Delivery proof',
2392
2847
  status: 'gap',
2393
- detail: `${proof.request_id} was sent, but its Companion Receipt is still unknown`,
2848
+ detail: `${proof.request_id} was sent, but its Companion Receipt (the app's delivery confirmation) is still unknown`,
2394
2849
  remedy: {
2395
2850
  by: 'cli',
2396
2851
  summary: 'check the same verification notification again',
@@ -2418,7 +2873,7 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
2418
2873
  const ok = blocker === null
2419
2874
 
2420
2875
  if (flags.json) {
2421
- deps.io.out(JSON.stringify({ ok, states: readiness.states }, null, 2))
2876
+ deps.io.out(JSON.stringify({ ok, exit_code: ok ? EXIT.ok : EXIT.failed, states: readiness.states }, null, 2))
2422
2877
  return ok ? EXIT.ok : EXIT.failed
2423
2878
  }
2424
2879
 
@@ -2428,12 +2883,19 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
2428
2883
  // problems in dependency order has an obvious first move and saying so
2429
2884
  // costs nothing.
2430
2885
  if (deps.io.interactive === true && deps.io.check) {
2431
- await deps.io.intro?.('NotifAI doctor')
2886
+ await deps.io.intro?.('Notifai doctor')
2432
2887
  for (const s of readiness.states) await deps.io.check(s.status !== 'gap', line(s))
2433
2888
  await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
2434
2889
  } else {
2435
2890
  for (const s of readiness.states) {
2436
- const mark = s.status === 'gap' ? 'FAIL' : s.status === 'unknown' ? ' ? ' : 'ok '
2891
+ const mark =
2892
+ s.status === 'gap'
2893
+ ? 'FAIL'
2894
+ : s.status === 'unknown'
2895
+ ? ' ? '
2896
+ : s.status === 'optional-gap'
2897
+ ? ' --'
2898
+ : 'ok '
2437
2899
  deps.io.out(`${mark} ${line(s)}`)
2438
2900
  }
2439
2901
  if (!ok) deps.io.out(`\nStart with: ${remedyLine(blocker)}`)
@@ -2476,39 +2938,67 @@ function remedyLine(state: ReadinessState): string {
2476
2938
  */
2477
2939
  function hookStates(deps: CommandDeps): ReadinessState[] {
2478
2940
  const installations = findInstallations(deps.cwd, deps.env)
2941
+ const active = activeHarnessSession(deps.env)
2942
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
2943
+ const settings: ReadinessState = {
2944
+ id: 'question-routing-settings',
2945
+ title: 'Question routing settings',
2946
+ status: 'ready',
2947
+ detail: [
2948
+ `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
2949
+ `require_idle=${config.require_idle.value} (${config.require_idle.source})`,
2950
+ `away_after_seconds=${config.away_after_seconds.value} (${config.away_after_seconds.source})`,
2951
+ `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
2952
+ `hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
2953
+ ].join(', '),
2954
+ }
2479
2955
  if (installations.length === 0) {
2480
2956
  return [
2481
2957
  {
2482
2958
  id: 'hooks',
2483
2959
  title: 'Question routing',
2484
- status: 'optional-gap',
2485
- detail: 'hooks not installed, so questions stay in the terminal',
2960
+ status: active === null ? 'optional-gap' : 'gap',
2961
+ detail:
2962
+ active === null
2963
+ ? 'hooks not installed, so questions stay in the terminal'
2964
+ : `active ${active.label} session detected, but ${active.label} hooks are not installed; \`notifai ask\` cannot route this session`,
2486
2965
  remedy: {
2487
2966
  by: 'cli',
2488
2967
  summary: 'install harness hooks so questions reach your devices when you are away',
2489
- command: 'notifai hooks install',
2968
+ command:
2969
+ active === null
2970
+ ? 'notifai hooks install'
2971
+ : `notifai hooks install --harness ${active.harness}`,
2490
2972
  },
2491
2973
  },
2974
+ settings,
2492
2975
  ]
2493
2976
  }
2494
2977
 
2495
2978
  /** Real but not in the way; see the note above. */
2496
- const informational = new Set(['hooks (fired)', 'hooks (opencode continuation)'])
2497
- return hookChecks(deps).map((check) => ({
2498
- id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
2499
- title: check.name === 'hooks' ? 'Question routing' : check.name,
2500
- status: check.ok ? 'ready' : informational.has(check.name) ? 'optional-gap' : 'gap',
2501
- detail: check.detail,
2502
- ...(check.ok
2503
- ? {}
2504
- : {
2505
- remedy: {
2506
- by: 'user-here' as const,
2507
- summary: 'the detail above names what to change',
2508
- command: 'notifai hooks install',
2509
- },
2510
- }),
2511
- }))
2979
+ const informational = new Set(['hooks (opencode continuation)'])
2980
+ if (active === null) informational.add('hooks (fired)')
2981
+ return [
2982
+ ...hookChecks(deps).map((check) => ({
2983
+ id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
2984
+ title:
2985
+ check.name === 'hooks' || check.name.startsWith('hooks (active')
2986
+ ? 'Question routing'
2987
+ : check.name,
2988
+ status: check.ok ? 'ready' as const : informational.has(check.name) ? 'optional-gap' as const : 'gap' as const,
2989
+ detail: check.detail,
2990
+ ...(check.ok
2991
+ ? {}
2992
+ : {
2993
+ remedy: {
2994
+ by: 'user-here' as const,
2995
+ summary: 'the detail above names what to change',
2996
+ command: 'notifai hooks install',
2997
+ },
2998
+ }),
2999
+ })),
3000
+ settings,
3001
+ ]
2512
3002
  }
2513
3003
 
2514
3004
  function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: string }[] {
@@ -2533,6 +3023,39 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
2533
3023
  .join(', '),
2534
3024
  })
2535
3025
 
3026
+ const active = activeHarnessSession(deps.env)
3027
+ const activeInstallations =
3028
+ active === null
3029
+ ? []
3030
+ : installations.filter((installation) => installation.harness === active.harness)
3031
+ if (active !== null) {
3032
+ checks.push({
3033
+ name: 'hooks (active harness)',
3034
+ ok: activeInstallations.length > 0,
3035
+ detail:
3036
+ activeInstallations.length > 0
3037
+ ? `active ${active.label} session has a matching hook installation`
3038
+ : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
3039
+ })
3040
+ if (activeInstallations.length > 0) {
3041
+ const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
3042
+ const matches =
3043
+ pointer !== null &&
3044
+ pointer.harness === active.harness &&
3045
+ (active.sessionId === undefined || pointer.sessionId === active.sessionId)
3046
+ checks.push({
3047
+ name: 'hooks (active session)',
3048
+ ok: matches,
3049
+ detail:
3050
+ pointer === null
3051
+ ? `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`
3052
+ : matches
3053
+ ? `the project pointer belongs to the active ${active.label} session`
3054
+ : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
3055
+ })
3056
+ }
3057
+ }
3058
+
2536
3059
  // A handler naming an event this build dropped exits 2 every time the harness
2537
3060
  // fires it, which the harness reports as a hook failure.
2538
3061
  const stale = installations.flatMap((i) =>
@@ -2563,6 +3086,29 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
2563
3086
  })
2564
3087
  }
2565
3088
 
3089
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
3090
+ const requiredTimeout = blockingHookTimeoutSeconds(
3091
+ config.ask_grace_seconds.value,
3092
+ config.hook_reply_timeout_seconds.value,
3093
+ )
3094
+ const shortTimeouts = installations.flatMap((installation) =>
3095
+ installation.handlers
3096
+ .filter((handler) => handlerEvent(handler.command) === 'stop')
3097
+ .filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
3098
+ .map(
3099
+ (handler) =>
3100
+ `${installation.harness} declares ${handler.timeout ?? 'no'}s, needs ${requiredTimeout}s — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
3101
+ ),
3102
+ )
3103
+ checks.push({
3104
+ name: 'hooks (timeout)',
3105
+ ok: shortTimeouts.length === 0,
3106
+ detail:
3107
+ shortTimeouts.length === 0
3108
+ ? `installed Stop timeouts cover the current ${requiredTimeout}s grace/reply budget`
3109
+ : shortTimeouts.join('; '),
3110
+ })
3111
+
2566
3112
  // Two checkouts each installing hooks means both fire for the same event, and
2567
3113
  // the user gets every question twice.
2568
3114
  //
@@ -2586,19 +3132,22 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
2586
3132
  detail: duplicated
2587
3133
  .map(
2588
3134
  (entry) =>
2589
- `${entry.harness}: ${entry.scripts.size} different NotifAI builds are installed, so each event will fire all of them. Uninstall the ones you do not want: ${[...entry.scripts].join(', ')}`,
3135
+ `${entry.harness}: ${entry.scripts.size} different Notifai builds are installed, so each event will fire all of them. Uninstall the ones you do not want: ${[...entry.scripts].join(', ')}`,
2590
3136
  )
2591
3137
  .join('; '),
2592
3138
  })
2593
3139
  }
2594
3140
 
2595
- const fired = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)()) !== null
3141
+ const firedPointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
3142
+ const fired = firedPointer !== null
3143
+ const activationInstallations =
3144
+ active !== null && activeInstallations.length > 0 ? activeInstallations : installations
2596
3145
  checks.push({
2597
3146
  name: 'hooks (fired)',
2598
3147
  ok: fired,
2599
3148
  detail: fired
2600
3149
  ? 'a session in this directory has run them'
2601
- : `no session pointer from the last 24 hours — ${hookActivationAdvice(installations)}`,
3150
+ : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
2602
3151
  })
2603
3152
 
2604
3153
  if (installations.some((installation) => installation.harness === 'opencode')) {
@@ -2606,7 +3155,7 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
2606
3155
  name: 'hooks (opencode continuation)',
2607
3156
  ok: false,
2608
3157
  detail:
2609
- 'question routing is installed, but OpenCode cannot reliably resume an idle agent turn with the answer; use `notifai send --reply` for decisions',
3158
+ 'question routing is installed; OpenCode cannot resume an idle turn automatically, but its next user prompt collects the answer',
2610
3159
  })
2611
3160
  }
2612
3161
 
@@ -2731,10 +3280,16 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
2731
3280
  else log.error(message)
2732
3281
  },
2733
3282
  openUrl: (url) => {
2734
- const command =
2735
- process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
2736
3283
  try {
2737
- spawn(command, [url], { stdio: 'ignore', detached: true }).unref()
3284
+ if (process.platform === 'darwin') {
3285
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
3286
+ } else if (process.platform === 'win32') {
3287
+ // `start` is a cmd builtin; spawn('start', …) cannot work on Windows.
3288
+ // Empty title argument keeps URLs with special characters intact.
3289
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref()
3290
+ } else {
3291
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
3292
+ }
2738
3293
  } catch {
2739
3294
  // Browser opening is best-effort; the URL is printed anyway.
2740
3295
  }