@raidiant/notifai 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commands.ts CHANGED
@@ -6,11 +6,15 @@ import path from 'node:path'
6
6
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
7
7
  import {
8
8
  CAPABILITIES_V1,
9
+ QUESTION_TEXT_MAX_LENGTH,
10
+ REPLY_MAX_QUESTIONS,
9
11
  REPLY_MAX_WINDOW_SECONDS,
10
12
  validateDraft,
11
13
  type EvidenceSnapshot,
14
+ type AccountAccessResponse,
12
15
  type ListRepliesResponse,
13
16
  type Platform,
17
+ type QuestionT,
14
18
  type ReplyView,
15
19
  type RoutableDevice,
16
20
  type SubmissionReceipt,
@@ -46,9 +50,12 @@ import {
46
50
  parseHookInput,
47
51
  pruneAbandonedSessions,
48
52
  readProjectSession,
53
+ readProjectSessionPointer,
54
+ readSessionState,
49
55
  registerQuestion,
50
56
  type HookContext,
51
57
  type HookEnvelope,
58
+ type HookHarness,
52
59
  } from './hooks.js'
53
60
  import { readIdleSeconds } from './idle.js'
54
61
  import {
@@ -78,13 +85,14 @@ import {
78
85
  } from './opencode-plugin.js'
79
86
  import {
80
87
  CHOICE_USAGE,
81
- ambiguousChoiceSplit,
82
88
  buildDraft,
83
89
  formatReceipt,
84
90
  parseChoices,
85
91
  receiptExitCode,
92
+ slugify,
86
93
  type SendFlags,
87
94
  } from './send.js'
95
+ import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
88
96
 
89
97
  export interface CommandIo {
90
98
  out(line: string): void
@@ -127,6 +135,8 @@ export interface CommandDeps {
127
135
  sleep?: (milliseconds: number) => Promise<void>
128
136
  /** Test seam for the OS idle probe; production shells out to the platform. */
129
137
  idleSeconds?: () => number | null
138
+ /** Test seam and production adapter for the external native skills installer. */
139
+ nativeSkills?: NativeSkills
130
140
  }
131
141
 
132
142
  export const EXIT = {
@@ -207,7 +217,7 @@ export async function loginCommand(
207
217
 
208
218
  const interactive = deps.io.interactive === true
209
219
  if (interactive) {
210
- await deps.io.intro?.('NotifAI sign in')
220
+ await deps.io.intro?.('Notifai sign in')
211
221
  await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine')
212
222
  } else {
213
223
  deps.io.out(`Pairing code: ${begin.code}`)
@@ -220,7 +230,15 @@ export async function loginCommand(
220
230
  const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000
221
231
  const now = deps.now ?? Date.now
222
232
  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
233
+ const approvalWaitMessage = (): string => {
234
+ const remainingSec = Math.max(0, Math.ceil((expiresAt - now()) / 1000))
235
+ const minutes = Math.floor(remainingSec / 60)
236
+ const seconds = remainingSec % 60
237
+ const remaining =
238
+ minutes > 0 ? `${minutes}m ${seconds.toString().padStart(2, '0')}s` : `${seconds}s`
239
+ return `Waiting for approval… code ${begin.code} · ${remaining} left`
240
+ }
241
+ const spinner = interactive ? await deps.io.spinner?.(approvalWaitMessage()) : null
224
242
  while (now() < expiresAt) {
225
243
  await sleep(intervalMs)
226
244
  let poll
@@ -228,7 +246,7 @@ export async function loginCommand(
228
246
  poll = await client.pollPairing(begin.pairing_id, pollVerifier)
229
247
  } catch (err) {
230
248
  if (err instanceof NetworkError) {
231
- spinner?.message('Connection lost — retrying…')
249
+ spinner?.message(`Connection lost — retrying… code ${begin.code}`)
232
250
  continue
233
251
  }
234
252
  spinner?.error('Pairing failed')
@@ -249,8 +267,18 @@ export async function loginCommand(
249
267
  deps.io.err('Pairing was denied from the dashboard.')
250
268
  return EXIT.auth
251
269
  }
270
+ if (poll.status === 'no_active_plan') {
271
+ const next =
272
+ poll.next_action ??
273
+ `Open ${baseUrl.replace(/\/$/, '')}/support to request Alpha access, then retry.`
274
+ spinner?.error('Account has no Alpha access')
275
+ deps.io.err('This account has no active plan or temporary Alpha access.')
276
+ deps.io.err(`next: ${next}`)
277
+ deps.io.err('After access is granted, run `notifai login` again.')
278
+ return EXIT.auth
279
+ }
252
280
  if (poll.status === 'expired') break
253
- spinner?.message('Waiting for approval…')
281
+ spinner?.message(approvalWaitMessage())
254
282
  }
255
283
  spinner?.error('Pairing expired')
256
284
  deps.io.err('Pairing expired before it was approved. Run `notifai login` again.')
@@ -293,6 +321,34 @@ export function authStatusCommand(deps: CommandDeps, flags: { json?: boolean }):
293
321
  return EXIT.ok
294
322
  }
295
323
 
324
+ /** Show the server's account access decision without attempting a product mutation. */
325
+ export async function accessStatusCommand(
326
+ deps: CommandDeps,
327
+ flags: { json?: boolean },
328
+ ): Promise<number> {
329
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
330
+ const authed = authedClient(deps, config)
331
+ if (!authed) return EXIT.auth
332
+ try {
333
+ const access: AccountAccessResponse = await authed.client.accessStatus()
334
+ if (flags.json) {
335
+ deps.io.out(JSON.stringify(access, null, 2))
336
+ return access.status === 'active' ? EXIT.ok : EXIT.failed
337
+ }
338
+ if (access.status === 'no_active_plan') {
339
+ const supportUrl = `${authed.baseUrl.replace(/\/$/, '')}/support`
340
+ deps.io.out('No active plan or temporary Alpha access for this account.')
341
+ deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`)
342
+ return EXIT.failed
343
+ }
344
+ const expiry = access.expires_at ? ` until ${access.expires_at}` : ''
345
+ deps.io.out(`Access active (${access.reason})${expiry}`)
346
+ return EXIT.ok
347
+ } catch (err) {
348
+ return reportError(deps, err)
349
+ }
350
+ }
351
+
296
352
  // ---------------------------------------------------------------------------
297
353
  // devices / capabilities
298
354
  // ---------------------------------------------------------------------------
@@ -308,7 +364,16 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
308
364
  return EXIT.ok
309
365
  }
310
366
  if (result.devices.length === 0) {
311
- deps.io.out('No devices. Install a NotifAI companion app on a device and sign in.')
367
+ const supportUrl = supportPageUrl(authed.baseUrl)
368
+ let email: string | null = null
369
+ try {
370
+ email = (await authed.client.accessStatus()).email
371
+ } catch {
372
+ // Best-effort: the empty-state copy still points at /support without it.
373
+ }
374
+ deps.io.out(
375
+ `No devices yet. Install Notifai from ${supportUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`,
376
+ )
312
377
  return EXIT.ok
313
378
  }
314
379
  for (const d of result.devices) {
@@ -365,6 +430,10 @@ export async function sendCommand(
365
430
  const hasReplyChoice = Array.isArray(flags.replyChoice)
366
431
  ? flags.replyChoice.length > 0
367
432
  : flags.replyChoice !== undefined
433
+ if (flags.reply && flags.kind === 'done') {
434
+ deps.io.err('--kind done cannot be combined with --reply; a reply request is a question.')
435
+ return EXIT.usage
436
+ }
368
437
  if (
369
438
  !flags.reply &&
370
439
  (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)
@@ -435,6 +504,7 @@ export async function sendCommand(
435
504
  for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
436
505
  return EXIT.usage
437
506
  }
507
+ emitSendWarnings(deps, flags, config)
438
508
  if (
439
509
  !flags.reply &&
440
510
  (flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
@@ -452,11 +522,21 @@ export async function sendCommand(
452
522
  )
453
523
  const receiptExit = receiptExitCode(receipt)
454
524
  if (!flags.json) deps.io.out(formatReceipt(receipt))
525
+ else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
455
526
 
456
527
  // A zero wait can no longer reach here: --reply guarantees a positive one.
457
528
  if (!flags.reply || receiptExit !== EXIT.ok) {
458
529
  if (flags.json) {
459
- deps.io.out(JSON.stringify(flags.reply ? { receipt, replies: [] } : receipt, null, 2))
530
+ deps.io.out(
531
+ flags.reply
532
+ ? JSON.stringify({
533
+ type: 'reply_result',
534
+ request_id: receipt.request_id,
535
+ replies: [],
536
+ degraded: false,
537
+ })
538
+ : JSON.stringify(receipt, null, 2),
539
+ )
460
540
  }
461
541
  return receiptExit
462
542
  }
@@ -469,11 +549,12 @@ export async function sendCommand(
469
549
  })
470
550
  if (flags.json) {
471
551
  deps.io.out(
472
- JSON.stringify(
473
- { receipt, replies: result.response.replies, degraded: result.degraded },
474
- null,
475
- 2,
476
- ),
552
+ JSON.stringify({
553
+ type: 'reply_result',
554
+ request_id: receipt.request_id,
555
+ replies: result.response.replies,
556
+ degraded: result.degraded,
557
+ }),
477
558
  )
478
559
  } else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
479
560
  else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
@@ -481,16 +562,65 @@ export async function sendCommand(
481
562
  deps.io.err(DEGRADED_WAIT_WARNING)
482
563
  return EXIT.network
483
564
  }
565
+ if (result.timedOut) {
566
+ deps.io.err(
567
+ `No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
568
+ `\`notifai close ${receipt.request_id}\`.`,
569
+ )
570
+ }
484
571
  return result.timedOut ? EXIT.noReply : EXIT.ok
485
572
  } catch (err) {
486
573
  return reportError(deps, err)
487
574
  }
488
575
  }
489
576
 
577
+ function emitSendWarnings(
578
+ deps: CommandDeps,
579
+ flags: SendFlags,
580
+ config: ReturnType<typeof loadConfig>,
581
+ ): void {
582
+ if (flags.title.length > 40) {
583
+ deps.io.err(
584
+ `Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`,
585
+ )
586
+ }
587
+ if (looksLikeMarkdown(flags.body)) {
588
+ deps.io.err(
589
+ 'Heads up: --body looks like Markdown, but banners show plain text. Put long-form Markdown in --detail or --detail-file.',
590
+ )
591
+ }
592
+ const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
593
+ if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
594
+ deps.io.err(
595
+ `Heads up: this title announces completion but the notification kind is update. Use --kind done for finished work.`,
596
+ )
597
+ }
598
+ if (
599
+ flags.collapseKey === undefined &&
600
+ config.collapse_key.value !== null &&
601
+ config.collapse_key.source.startsWith('global:')
602
+ ) {
603
+ deps.io.err(
604
+ 'Heads up: collapse_key comes from machine-global config, so unrelated notifications may replace each other. Prefer a project or command-specific --collapse-key.',
605
+ )
606
+ }
607
+ if (flags.ttl !== undefined && flags.ttl > 259_200) {
608
+ deps.io.err(
609
+ 'Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.',
610
+ )
611
+ }
612
+ }
613
+
614
+ function looksLikeMarkdown(value: string): boolean {
615
+ return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(
616
+ value,
617
+ )
618
+ }
619
+
490
620
  export async function repliesCommand(
491
621
  deps: CommandDeps,
492
- requestId: string,
493
- flags: { wait?: number; after?: number; json?: boolean },
622
+ requestedId: string | undefined,
623
+ flags: { wait?: number; after?: number; json?: boolean; pending?: boolean },
494
624
  ): Promise<number> {
495
625
  const waitSeconds = flags.wait ?? 0
496
626
  const afterSeq = flags.after ?? 0
@@ -503,6 +633,28 @@ export async function repliesCommand(
503
633
  return EXIT.usage
504
634
  }
505
635
 
636
+ if (flags.pending === true && requestedId !== undefined) {
637
+ deps.io.err('Pass a request id or --pending, not both.')
638
+ return EXIT.usage
639
+ }
640
+ let requestId = requestedId
641
+ if (flags.pending === true) {
642
+ const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
643
+ if (sessionId === null) {
644
+ deps.io.err('No active session pointer is available in this directory.')
645
+ return EXIT.noReply
646
+ }
647
+ requestId = readSessionState(sessionId, deps.env).pending?.request_id
648
+ if (requestId === undefined) {
649
+ deps.io.err(`Session ${sessionId} has no pushed question pending.`)
650
+ return EXIT.noReply
651
+ }
652
+ }
653
+ if (requestId === undefined) {
654
+ deps.io.err('Pass a request id or --pending.')
655
+ return EXIT.usage
656
+ }
657
+
506
658
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
507
659
  const authed = authedClient(deps, config)
508
660
  if (!authed) return EXIT.auth
@@ -515,7 +667,10 @@ export async function repliesCommand(
515
667
  })
516
668
  if (flags.json) {
517
669
  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)
670
+ } else if (result.response.replies.length > 0) {
671
+ if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
672
+ printReplies(deps, result.response.replies)
673
+ }
519
674
  else printNoReply(deps, requestId, result.response.reply_expires_at)
520
675
  if (result.degraded) {
521
676
  deps.io.err(DEGRADED_WAIT_WARNING)
@@ -618,28 +773,27 @@ function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
618
773
  }
619
774
 
620
775
  /**
621
- * The other half of first-reply-wins.
776
+ * The correction note for a wait that saw more than one answer.
622
777
  *
623
- * A blocking send returns the first non-empty batch and acts on it, so a later
624
- * answer that disagrees is silently ignored. That is the right default — an
625
- * agent wants one answer, not a quorum — but silently is the wrong way to do
626
- * it. The server now retires the question on the other devices the moment one
627
- * answers, which makes this rare; it is still reachable, because a device can
628
- * answer between the first reply landing and its retirement arriving.
778
+ * A later reply that conflicts with an earlier one is a correction, so the
779
+ * latest answer is the one that counts. The server retires the question on
780
+ * the other devices the moment one answers, which makes this rare; it is
781
+ * still reachable, because a device can answer between the first reply
782
+ * landing and its retirement arriving.
629
783
  *
630
- * Ordered by seq, so `replies[0]` is the one that won.
784
+ * Ordered by seq, so `replies.at(-1)` is the answer to act on.
631
785
  */
632
786
  export function contradictingAnswer(replies: ReplyView[]): string | null {
633
- const winner = replies[0]
787
+ const winner = replies.at(-1)
634
788
  if (winner === undefined || replies.length < 2) return null
635
- const disagreeing = replies
636
- .slice(1)
637
- .filter((reply) => (reply.choice_id ?? reply.text) !== (winner.choice_id ?? winner.text))
638
- if (disagreeing.length === 0) return null
639
- const names = [...new Set(disagreeing.map((reply) => reply.device_name))].join(', ')
789
+ const key = (reply: ReplyView) =>
790
+ reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text
791
+ const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner))
792
+ if (corrected.length === 0) return null
793
+ const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ')
640
794
  return (
641
- `note: "${winner.text}" from ${winner.device_name} is the answer that counts — it arrived first. ` +
642
- `${names} answered differently afterwards and that answer was not used.`
795
+ `note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
796
+ `answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`
643
797
  )
644
798
  }
645
799
 
@@ -679,12 +833,12 @@ export async function statusCommand(
679
833
  if (d.companion_receipt.state === 'observed') {
680
834
  const latency = d.companion_receipt.latency_ms
681
835
  deps.io.out(
682
- ` Companion Receipt: observed at ${d.companion_receipt.observed_at}` +
836
+ ` Companion Receipt (the app's delivery confirmation): observed at ${d.companion_receipt.observed_at}` +
683
837
  (latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`),
684
838
  )
685
839
  } else {
686
840
  deps.io.out(
687
- ' Companion Receipt: unknown — not observed; this is not a failure or proof of non-receipt',
841
+ " Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt",
688
842
  )
689
843
  }
690
844
  for (const e of d.events) {
@@ -772,7 +926,7 @@ export async function hookRunCommand(
772
926
  deps: CommandDeps,
773
927
  event: string,
774
928
  readStdin: () => Promise<string>,
775
- harness?: 'cursor',
929
+ harness?: HookHarness,
776
930
  ): Promise<number> {
777
931
  if (!(HOOK_EVENTS as readonly string[]).includes(event)) {
778
932
  deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
@@ -780,7 +934,17 @@ export async function hookRunCommand(
780
934
  }
781
935
  let envelope: HookEnvelope
782
936
  try {
783
- envelope = parseHookInput(await readStdin())
937
+ const raw = await readStdin()
938
+ if (raw.trim() !== '') {
939
+ try {
940
+ const parsed: unknown = JSON.parse(raw)
941
+ if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
942
+ } catch {
943
+ deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
944
+ return EXIT.ok
945
+ }
946
+ }
947
+ envelope = parseHookInput(raw)
784
948
  if (harness === 'cursor') {
785
949
  const sessionId = envelope.session_id ?? envelope.conversation_id
786
950
  const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
@@ -814,7 +978,10 @@ export async function hookRunCommand(
814
978
  const cwd = envelope.cwd ?? deps.cwd
815
979
  const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
816
980
  const credential = deps.store.load()
817
- if (!credential) return EXIT.ok
981
+ if (!credential) {
982
+ deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`')
983
+ return EXIT.ok
984
+ }
818
985
  // Pin authenticated traffic to the origin the credential was issued for. A
819
986
  // repository can commit `.notifai/config.toml`, and honouring a base_url
820
987
  // from it would hand this machine's bearer token to whatever host it names.
@@ -854,6 +1021,7 @@ export async function hookRunCommand(
854
1021
  degraded: result.degraded,
855
1022
  }
856
1023
  },
1024
+ ...(harness === undefined ? {} : { harness }),
857
1025
  }
858
1026
 
859
1027
  // Real clock, deliberately, not `deps.now`. This compares against file
@@ -930,43 +1098,173 @@ function rejectedPaths(details: unknown): string[] {
930
1098
  }
931
1099
 
932
1100
  export interface AskFlags {
933
- choice?: string | string[]
1101
+ choice?: string[]
1102
+ /** The single question is multi-select: several answers may be chosen. */
1103
+ multi?: boolean
1104
+ /** Long-form markdown context; shown in the app, never on the banner. */
1105
+ detail?: string
1106
+ /** Raw JSON for a multi-question form; replaces the positional question. */
1107
+ form?: string
934
1108
  session?: string
935
1109
  }
936
1110
 
1111
+ /** The `--form` document: what an agent writes to ask several things at once. */
1112
+ interface AskFormQuestion {
1113
+ text: string
1114
+ choices?: string[]
1115
+ multi?: boolean
1116
+ }
1117
+
1118
+ /**
1119
+ * Turn ask input into the question set that will ride the push. Everything is
1120
+ * validated here, at registration, because the push happens inside a hook
1121
+ * where a rejection becomes a stderr note the agent never reads.
1122
+ */
1123
+ export function buildQuestions(
1124
+ flags: AskFlags,
1125
+ question: string | undefined,
1126
+ ): { ok: true; questions: QuestionT[]; detail?: string } | { ok: false; error: string } {
1127
+ if (flags.form !== undefined) {
1128
+ if (question !== undefined || flags.choice?.length || flags.multi) {
1129
+ return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' }
1130
+ }
1131
+ let parsed: unknown
1132
+ try {
1133
+ parsed = JSON.parse(flags.form)
1134
+ } catch {
1135
+ return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' }
1136
+ }
1137
+ if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as { questions?: unknown }).questions)) {
1138
+ return { ok: false, error: '--form needs a "questions" array (1-4 entries).' }
1139
+ }
1140
+ const form = parsed as { questions: unknown[]; detail?: unknown }
1141
+ if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
1142
+ return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` }
1143
+ }
1144
+ if (form.detail !== undefined && typeof form.detail !== 'string') {
1145
+ return { ok: false, error: '"detail" must be a markdown string.' }
1146
+ }
1147
+ const questions: QuestionT[] = []
1148
+ const usedIds = new Set<string>()
1149
+ for (const [index, entry] of form.questions.entries()) {
1150
+ if (typeof entry !== 'object' || entry === null || typeof (entry as AskFormQuestion).text !== 'string') {
1151
+ return { ok: false, error: `Question ${index + 1} needs a "text" string.` }
1152
+ }
1153
+ const spec = entry as AskFormQuestion
1154
+ const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds)
1155
+ if ('error' in built) return { ok: false, error: `Question ${index + 1}: ${built.error}` }
1156
+ questions.push(built.question)
1157
+ }
1158
+ return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) }
1159
+ }
1160
+
1161
+ if (question === undefined || question.trim() === '') {
1162
+ return { ok: false, error: 'The question cannot be empty.' }
1163
+ }
1164
+ const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set())
1165
+ if ('error' in built) return { ok: false, error: built.error }
1166
+ return {
1167
+ ok: true,
1168
+ questions: [built.question],
1169
+ ...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
1170
+ }
1171
+ }
1172
+
1173
+ function buildOneQuestion(
1174
+ text: string,
1175
+ choiceLabels: string[] | undefined,
1176
+ multi: boolean,
1177
+ index: number,
1178
+ usedIds: Set<string>,
1179
+ ): { question: QuestionT } | { error: string } {
1180
+ const trimmed = text.trim()
1181
+ if (trimmed === '') return { error: 'the question text cannot be empty.' }
1182
+ if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
1183
+ return {
1184
+ error:
1185
+ `a question must be readable where it is answered: keep it within ` +
1186
+ `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
1187
+ }
1188
+ }
1189
+ const choices = parseChoices(choiceLabels)
1190
+ if (choices === 'invalid') return { error: CHOICE_USAGE }
1191
+ if (multi && choices === null) {
1192
+ return { error: '--multi needs answers to select between; add --choice.' }
1193
+ }
1194
+ let id = slugify(trimmed)
1195
+ if (id === '' || usedIds.has(id)) id = `q${index + 1}`
1196
+ usedIds.add(id)
1197
+ return {
1198
+ question: {
1199
+ id,
1200
+ text: trimmed,
1201
+ ...(choices !== null ? { choices } : {}),
1202
+ ...(multi ? { multi: true } : {}),
1203
+ },
1204
+ }
1205
+ }
1206
+
937
1207
  /**
938
1208
  * Registers a question for turn-end routing under the user's presence config.
939
1209
  * Returns immediately so the agent can ask in prose and end its turn. With the
940
1210
  * default presence gate, recent keyboard or mouse activity keeps it local;
941
1211
  * `require_idle = false` intentionally permits a push while the user is active.
942
1212
  */
943
- 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
1213
+ export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
1214
+ // An agent calling this gets no hook payload. Harness-native environment
1215
+ // markers identify the active owner, while the UserPromptSubmit hook leaves
1216
+ // a pointer keyed on the project directory with the hook's canonical id.
1217
+ // The pointer outranks the
947
1218
  // NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
948
1219
  // label rather than the harness's own id, and the hooks key state by the
949
1220
  // latter — the env var is only trusted when no hook has spoken.
950
1221
  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
1222
+ const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now)
1223
+ const pointer = projectPointer?.sessionId ?? null
1224
+ const active = activeHarnessSession(deps.env)
1225
+ let sessionId: string | undefined
1226
+ if (flags.session !== undefined) {
1227
+ sessionId = flags.session
1228
+ } else if (active !== null) {
1229
+ const installations = findInstallations(deps.cwd, deps.env)
1230
+ const activeInstalled = installations.some(
1231
+ (installation) => installation.harness === active.harness,
1232
+ )
1233
+ if (!activeInstalled) {
1234
+ for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
1235
+ deps.io.err(line)
1236
+ }
1237
+ return EXIT.usage
1238
+ }
1239
+ if (projectPointer === null) {
1240
+ for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
1241
+ deps.io.err(line)
1242
+ }
1243
+ return EXIT.usage
1244
+ }
1245
+ if (
1246
+ projectPointer.harness !== active.harness ||
1247
+ (active.sessionId !== undefined && projectPointer.sessionId !== active.sessionId)
1248
+ ) {
1249
+ for (const line of diagnoseActiveHarnessSession(active, 'different-session', installations)) {
1250
+ deps.io.err(line)
1251
+ }
1252
+ return EXIT.usage
1253
+ }
1254
+ sessionId = projectPointer.sessionId
1255
+ } else {
1256
+ sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined
1257
+ }
956
1258
  if (!sessionId) {
957
1259
  for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
958
1260
  return EXIT.usage
959
1261
  }
960
- if (question.trim() === '') {
961
- deps.io.err('The question cannot be empty.')
962
- return EXIT.usage
963
- }
964
1262
  // Validate here, not at push time. The push happens inside a hook, where a
965
1263
  // rejection becomes a stderr note the agent never reads — so a malformed
966
- // choice set would look like it registered fine and then silently never ask.
967
- const choices = parseChoices(flags.choice)
968
- if (choices === 'invalid') {
969
- deps.io.err(CHOICE_USAGE)
1264
+ // question set would look like it registered fine and then silently never ask.
1265
+ const built = buildQuestions(flags, question)
1266
+ if (!built.ok) {
1267
+ deps.io.err(built.error)
970
1268
  return EXIT.usage
971
1269
  }
972
1270
  try {
@@ -974,8 +1272,9 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
974
1272
  sessionId,
975
1273
  deps.env,
976
1274
  {
977
- question: question.trim(),
978
- ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
1275
+ question: built.questions[0]!.text,
1276
+ questions: built.questions,
1277
+ ...(built.detail !== undefined ? { detail: built.detail } : {}),
979
1278
  },
980
1279
  (deps.now ?? Date.now)(),
981
1280
  )
@@ -983,17 +1282,96 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
983
1282
  deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
984
1283
  return EXIT.failed
985
1284
  }
986
- if (choices !== null) {
987
- deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`)
1285
+ for (const [index, entry] of built.questions.entries()) {
1286
+ const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
1287
+ if (entry.choices !== undefined) {
1288
+ const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
1289
+ deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
1290
+ } else if (built.questions.length > 1) {
1291
+ deps.io.out(`${prefix}${entry.text} — free text`)
1292
+ }
988
1293
  }
989
- // On stdout, not stderr: the agent reads the former and this is only useful
990
- // if it is read while there is still time to fix the question.
991
- const ambiguous = ambiguousChoiceSplit(flags.choice)
992
- if (ambiguous !== null) deps.io.out(ambiguous)
993
- deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.')
1294
+ deps.io.out(
1295
+ built.questions.length > 1
1296
+ ? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
1297
+ : 'Question registered. Ask it in the conversation as usual and end your turn.',
1298
+ )
994
1299
  return EXIT.ok
995
1300
  }
996
1301
 
1302
+ /**
1303
+ * Exact evidence that this shell command is running inside one supported
1304
+ * harness. Configuration-directory variables are deliberately absent: they
1305
+ * describe where a tool stores files, not which tool owns the current shell.
1306
+ * OpenCode's generated plugin supplies the Notifai-owned marker because its
1307
+ * plugin API exposes session identity but the ordinary environment does not.
1308
+ */
1309
+ interface ActiveHarnessSession {
1310
+ harness: Harness
1311
+ label: string
1312
+ sessionId?: string
1313
+ }
1314
+
1315
+ function activeHarnessSession(env: NodeJS.ProcessEnv): ActiveHarnessSession | null {
1316
+ const explicit = env['NOTIFAI_ACTIVE_HARNESS']
1317
+ if (explicit === 'opencode') {
1318
+ const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID']
1319
+ return {
1320
+ harness: 'opencode',
1321
+ label: 'OpenCode',
1322
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1323
+ }
1324
+ }
1325
+ if (env['CLAUDECODE'] === '1') {
1326
+ const sessionId = env['CLAUDE_CODE_SESSION_ID']
1327
+ return {
1328
+ harness: 'claude-code',
1329
+ label: 'Claude Code',
1330
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1331
+ }
1332
+ }
1333
+ const codexSession = env['CODEX_THREAD_ID']
1334
+ if (codexSession !== undefined && codexSession !== '') {
1335
+ return { harness: 'codex', label: 'Codex', sessionId: codexSession }
1336
+ }
1337
+ if ((env['CURSOR_AGENT'] ?? '') !== '') return { harness: 'cursor', label: 'Cursor' }
1338
+ return null
1339
+ }
1340
+
1341
+ type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
1342
+
1343
+ function diagnoseActiveHarnessSession(
1344
+ active: ActiveHarnessSession,
1345
+ problem: ActiveHarnessProblem,
1346
+ installations: Installation[],
1347
+ ): string[] {
1348
+ if (problem === 'not-installed') {
1349
+ const others = installations.map((installation) => installation.harness)
1350
+ return [
1351
+ `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
1352
+ ...(others.length === 0
1353
+ ? []
1354
+ : [
1355
+ `Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
1356
+ ]),
1357
+ `Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
1358
+ `Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
1359
+ ]
1360
+ }
1361
+ if (problem === 'different-session') {
1362
+ return [
1363
+ `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
1364
+ `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
1365
+ `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
1366
+ ]
1367
+ }
1368
+ return [
1369
+ `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
1370
+ `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
1371
+ `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
1372
+ ]
1373
+ }
1374
+
997
1375
  /**
998
1376
  * Why `ask` cannot see a session, in terms of what to do about it.
999
1377
  *
@@ -1007,13 +1385,13 @@ function diagnoseMissingSession(deps: CommandDeps): string[] {
1007
1385
  const installations = findInstallations(deps.cwd, deps.env)
1008
1386
  if (installations.length === 0) {
1009
1387
  return [
1010
- 'Could not tell which harness session this is: no NotifAI hooks are installed for this project.',
1388
+ 'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
1011
1389
  'Run `notifai hooks install`, then follow the activation instruction it prints.',
1012
1390
  ]
1013
1391
  }
1014
1392
  const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ')
1015
1393
  return [
1016
- `Could not tell which harness session this is. NotifAI hooks are installed (${where}),`,
1394
+ `Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
1017
1395
  'but no usable session pointer from the last 24 hours exists here.',
1018
1396
  hookActivationAdvice(installations),
1019
1397
  'To ask from this session anyway, pass --session <id>.',
@@ -1060,7 +1438,9 @@ function hookActivationAdvice(installations: Installation[]): string {
1060
1438
  )
1061
1439
  }
1062
1440
  if (harnesses.has('opencode')) {
1063
- advice.push('OpenCode: restart it, then send one prompt; plugins load at startup')
1441
+ advice.push(
1442
+ 'OpenCode: restart it, then send one prompt; plugins load at startup, and a device answer is delivered on the next prompt',
1443
+ )
1064
1444
  }
1065
1445
  return `${advice.join('. ')}.`
1066
1446
  }
@@ -1123,6 +1503,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1123
1503
  scriptPath,
1124
1504
  replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1125
1505
  graceSeconds: config.ask_grace_seconds.value,
1506
+ harness: 'cursor',
1126
1507
  }),
1127
1508
  scriptPath,
1128
1509
  )
@@ -1142,8 +1523,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1142
1523
  deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
1143
1524
  deps.io.out('start a new Cursor session and try one prompt again.')
1144
1525
  }
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.')
1526
+ deps.io.out('A companion-device answer is submitted as one follow-up user message.')
1527
+ deps.io.out('loop_limit = 3 permits bounded chained questions without an unbounded loop.')
1147
1528
  return EXIT.ok
1148
1529
  }
1149
1530
 
@@ -1161,6 +1542,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1161
1542
  scriptPath,
1162
1543
  replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1163
1544
  graceSeconds: config.ask_grace_seconds.value,
1545
+ harness,
1164
1546
  }),
1165
1547
  scriptPath,
1166
1548
  )
@@ -1184,11 +1566,11 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1184
1566
  `nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
1185
1567
  `until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
1186
1568
  `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.`
1569
+ `Run \`notifai config set require_idle false\` to be notified even while you are working.`
1188
1570
  : `A question registered with \`notifai ask\` stays in the terminal for ` +
1189
1571
  `${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
1190
1572
  `whether or not you are at this machine ` +
1191
- `(\`require_idle = false\`).`,
1573
+ `(\`notifai config set require_idle false\` is already in effect).`,
1192
1574
  )
1193
1575
  if (config.require_idle.value) {
1194
1576
  deps.io.out(
@@ -1226,7 +1608,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1226
1608
  }
1227
1609
 
1228
1610
  /**
1229
- * Writes the OpenCode plugin, replacing any NotifAI plugin already there —
1611
+ * Writes the OpenCode plugin, replacing any Notifai plugin already there —
1230
1612
  * including one a different checkout wrote, matched on the managed marker for
1231
1613
  * the same reason command hooks are.
1232
1614
  */
@@ -1238,7 +1620,7 @@ function installOpencodePlugin(
1238
1620
  if (existsSync(file)) {
1239
1621
  const existing = readFileSync(file, 'utf8')
1240
1622
  if (!isOurOpencodePlugin(existing)) {
1241
- deps.io.err(`${file} exists and was not written by NotifAI; move it aside first.`)
1623
+ deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`)
1242
1624
  return EXIT.failed
1243
1625
  }
1244
1626
  }
@@ -1254,8 +1636,9 @@ function installOpencodePlugin(
1254
1636
  deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
1255
1637
  deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
1256
1638
  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.')
1639
+ deps.io.out('OpenCode pushes without holding its idle event open; the next user prompt')
1640
+ deps.io.out('collects a device answer and adds it to the agent context. Use `notifai')
1641
+ deps.io.out('send --reply` only when the answer must return before another prompt.')
1259
1642
  deps.io.out('')
1260
1643
  deps.io.out('Restart OpenCode: it loads plugins once at start.')
1261
1644
  return EXIT.ok
@@ -1273,11 +1656,11 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1273
1656
  if (harness === 'opencode') {
1274
1657
  // We own the whole file, but only if we wrote it.
1275
1658
  if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
1276
- deps.io.out(`Left ${file} alone: NotifAI did not write it.`)
1659
+ deps.io.out(`Left ${file} alone: Notifai did not write it.`)
1277
1660
  return EXIT.ok
1278
1661
  }
1279
1662
  rmSync(file, { force: true })
1280
- deps.io.out(`Removed the NotifAI OpenCode plugin at ${file}`)
1663
+ deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
1281
1664
  return EXIT.ok
1282
1665
  }
1283
1666
  if (harness === 'cursor') {
@@ -1297,8 +1680,8 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1297
1680
  }
1298
1681
  deps.io.out(
1299
1682
  stripped.replaced.length > 0
1300
- ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1301
- : `No NotifAI hooks found in ${file}`,
1683
+ ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1684
+ : `No Notifai hooks found in ${file}`,
1302
1685
  )
1303
1686
  return EXIT.ok
1304
1687
  }
@@ -1318,8 +1701,8 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1318
1701
  }
1319
1702
  deps.io.out(
1320
1703
  stripped.replaced.length > 0
1321
- ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1322
- : `No NotifAI hooks found in ${file}`,
1704
+ ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1705
+ : `No Notifai hooks found in ${file}`,
1323
1706
  )
1324
1707
  return EXIT.ok
1325
1708
  }
@@ -1453,27 +1836,69 @@ export async function configSetCommand(
1453
1836
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1454
1837
  * Keep this immutable and public because the command is printed to users.
1455
1838
  */
1456
- export const SKILLS_SOURCE = 'RafaelVidaurre/notifai#v0.1.7'
1839
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.0'
1457
1840
 
1458
- function pinnedSkillInstalled(cwd: string): boolean {
1841
+ function skillSourceParts(): { source: string; ref: string } | null {
1459
1842
  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 }>
1843
+ return match === null ? null : { source: match[1]!, ref: match[2]! }
1844
+ }
1845
+
1846
+ function expectedSkill(skill: NativeSkill): boolean {
1847
+ const expected = skillSourceParts()
1848
+ return (
1849
+ expected !== null &&
1850
+ skill.name === 'notifai' &&
1851
+ skill.source === expected.source &&
1852
+ skill.sourceType === 'github' &&
1853
+ skill.ref === expected.ref
1854
+ )
1855
+ }
1856
+
1857
+ async function skillReadiness(
1858
+ deps: CommandDeps,
1859
+ selectedScope?: SkillScope,
1860
+ ): Promise<ReadinessState> {
1861
+ const scopes: SkillScope[] = selectedScope === undefined ? ['project', 'global'] : [selectedScope]
1862
+ const results = await Promise.all(
1863
+ scopes.map(async (scope) => {
1864
+ if (deps.nativeSkills === undefined) return { scope, skills: [] as NativeSkill[] }
1865
+ try {
1866
+ return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) }
1867
+ } catch (err) {
1868
+ return { scope, skills: [] as NativeSkill[], error: String(err) }
1869
+ }
1870
+ }),
1871
+ )
1872
+ const installed = results.flatMap(({ skills }) => skills).find(expectedSkill)
1873
+ if (installed !== undefined) {
1874
+ return {
1875
+ id: 'skill',
1876
+ title: 'Agent guidance skill',
1877
+ status: 'ready',
1878
+ detail: `installed from ${SKILLS_SOURCE} in the ${installed.scope} scope`,
1467
1879
  }
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
1880
+ }
1881
+
1882
+ const errors = results
1883
+ .filter((result) => result.error !== undefined)
1884
+ .map((result) => `${result.scope}: ${result.error}`)
1885
+ const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`
1886
+ return {
1887
+ id: 'skill',
1888
+ title: 'Agent guidance skill',
1889
+ status: 'optional-gap',
1890
+ detail:
1891
+ errors.length > 0
1892
+ ? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
1893
+ : `not installed from ${SKILLS_SOURCE} in ${scopeText}`,
1894
+ remedy: {
1895
+ by: 'cli',
1896
+ summary: 'install the skill agents follow when deciding to notify',
1897
+ command:
1898
+ selectedScope === undefined
1899
+ ? 'notifai init --skills'
1900
+ : `notifai init --skills --skills-scope ${selectedScope}`,
1901
+ },
1477
1902
  }
1478
1903
  }
1479
1904
 
@@ -1496,6 +1921,8 @@ export interface InitFlags {
1496
1921
  * never spawn npx against the network by default.
1497
1922
  */
1498
1923
  skills?: boolean
1924
+ /** Scope selected by an unattended caller; humans choose inside npx skills. */
1925
+ skillsScope?: SkillScope
1499
1926
  /** Same tri-state, for the harness hooks. */
1500
1927
  hooks?: boolean
1501
1928
  }
@@ -1507,11 +1934,47 @@ interface SetupProofRecord {
1507
1934
  started_at: string
1508
1935
  }
1509
1936
 
1510
- const DEVICE_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000
1937
+ /** Long enough for a first TestFlight install; keep-waiting extends another budget. */
1938
+ const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000
1511
1939
  const DEVICE_BRIDGE_POLL_MS = 2_000
1512
1940
  const PROOF_TIMEOUT_MS = 30_000
1513
1941
  const PROOF_POLL_MS = 1_000
1514
1942
 
1943
+ function supportPageUrl(baseUrl: string): string {
1944
+ return `${baseUrl.replace(/\/$/, '')}/support`
1945
+ }
1946
+
1947
+ function formatWaitBudget(milliseconds: number): string {
1948
+ const minutes = Math.round(milliseconds / 60_000)
1949
+ return minutes === 1 ? '1 minute' : `${minutes} minutes`
1950
+ }
1951
+
1952
+ /** Same-account line for the device hop; prefers the real email when known. */
1953
+ function sameAccountSignInLine(email: string | null | undefined): string {
1954
+ return email
1955
+ ? `sign in with the same email as this account (${email})`
1956
+ : 'sign in with the same email as this account'
1957
+ }
1958
+
1959
+ function deviceInstallRemedy(options: {
1960
+ baseUrl: string
1961
+ email: string | null
1962
+ devices: readonly RoutableDevice[]
1963
+ }): string {
1964
+ const support = supportPageUrl(options.baseUrl)
1965
+ const sameEmail = sameAccountSignInLine(options.email)
1966
+ if (options.devices.length === 0) {
1967
+ return (
1968
+ `open the install steps at ${support} on that device (or your phone’s browser), ` +
1969
+ `install Notifai, ${sameEmail}, and allow notifications`
1970
+ )
1971
+ }
1972
+ if (options.devices.some((d) => d.permission_status === 'denied')) {
1973
+ return 'allow notifications for Notifai in that device’s system settings'
1974
+ }
1975
+ return 'open Notifai on that device and allow its notification prompt'
1976
+ }
1977
+
1515
1978
  function setupProofPath(deps: CommandDeps): string {
1516
1979
  let projectDir = path.resolve(deps.cwd)
1517
1980
  try {
@@ -1624,18 +2087,27 @@ async function closeGap(
1624
2087
  }
1625
2088
 
1626
2089
  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
- })
2090
+ if (deps.nativeSkills === undefined) {
2091
+ deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
2092
+ return 'failed'
2093
+ }
2094
+ const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`
2095
+ deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`)
2096
+ const addOptions = {
2097
+ source: SKILLS_SOURCE,
2098
+ skill: 'notifai',
2099
+ cwd: deps.cwd,
2100
+ env: deps.env,
2101
+ ...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
2102
+ }
2103
+ const code = await deps.nativeSkills.add(addOptions)
1636
2104
  if (code !== 0) {
1637
2105
  deps.io.err('Skill installation failed — run it manually with:')
1638
- deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`)
2106
+ deps.io.err(
2107
+ ` npx skills add ${SKILLS_SOURCE} --skill notifai${
2108
+ flags.skillsScope === 'global' ? ' --global' : ''
2109
+ }${flags.skillsScope === undefined ? '' : ' --yes'}`,
2110
+ )
1639
2111
  }
1640
2112
  return code === 0 ? 'closed' : 'failed'
1641
2113
  }
@@ -1669,55 +2141,104 @@ function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
1669
2141
 
1670
2142
  /**
1671
2143
  * 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.
2144
+ * app-side work. The live bridge is the dashboard `/support` page (TestFlight
2145
+ * steps). Interactive runs offer to open it so the user never types the URL;
2146
+ * non-interactive/agent paths only print plain text and never wait on a prompt.
2147
+ *
2148
+ * The wait budget is stated up front. On expiry we say the *timer* expired —
2149
+ * not the setup — and offer another budget when a human is present. Agents
2150
+ * never hang: this whole path is gated on `io.interactive`, and keep-waiting
2151
+ * uses `confirm` which resolves the safe default when not interactive.
1675
2152
  */
1676
2153
  async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Promise<GapCloseResult> {
1677
2154
  const remedy = state.remedy
1678
2155
  if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
1679
2156
 
2157
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
2158
+ const authed = authedClient(deps, config)
2159
+ if (!authed) {
2160
+ deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
2161
+ return 'failed'
2162
+ }
2163
+
2164
+ const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS)
2165
+ const supportUrl = supportPageUrl(authed.baseUrl)
1680
2166
  await deps.io.note?.(
1681
- `${state.detail}\n${remedy.summary}`,
2167
+ [
2168
+ state.detail,
2169
+ remedy.summary,
2170
+ `Install steps (no typing): ${supportUrl}`,
2171
+ `I will wait up to ${budgetLabel} for a companion device to become ready.`,
2172
+ ].join('\n'),
1682
2173
  'Finish setup on your companion device',
1683
2174
  )
2175
+
2176
+ // Open the real support page so the user never has to type the URL. Decline
2177
+ // is fine — the URL remains in the note and in the Next: line if they leave.
2178
+ if (await deps.io.confirm('Open install instructions in your browser?', true)) {
2179
+ deps.io.openUrl(supportUrl)
2180
+ }
2181
+
1684
2182
  if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
2183
+ deps.io.out(
2184
+ `OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`,
2185
+ )
1685
2186
  return 'pending'
1686
2187
  }
1687
2188
 
1688
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1689
- const authed = authedClient(deps, config)
1690
- if (!authed) return 'failed'
1691
2189
  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…')
2190
+ const sleep =
2191
+ deps.sleep ??
2192
+ ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
2193
+ const spinner = await deps.io.spinner?.(`Waiting up to ${budgetLabel} for a companion device…`)
1695
2194
  let lastDevices: RoutableDevice[] = []
2195
+ let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
1696
2196
 
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'
2197
+ for (;;) {
2198
+ while (now() < deadline) {
2199
+ try {
2200
+ const response = await authed.client.listDevices()
2201
+ lastDevices = response.devices
2202
+ const ready = response.devices.find(deviceCanReceive)
2203
+ if (ready) {
2204
+ spinner?.stop(`${ready.display_name} is ready to receive`)
2205
+ return 'closed'
2206
+ }
2207
+ spinner?.message(deviceBridgeMessage(response.devices))
2208
+ } catch (err) {
2209
+ if (!(err instanceof NetworkError)) {
2210
+ spinner?.error('Could not check companion readiness')
2211
+ reportError(deps, err)
2212
+ return 'failed'
2213
+ }
2214
+ spinner?.message('Connection lost — still watching…')
1712
2215
  }
1713
- spinner?.message('Connection lost — still watching…')
2216
+ await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
1714
2217
  }
1715
- await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
1716
- }
1717
2218
 
1718
- spinner?.error('No companion device became ready')
1719
- deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
1720
- return 'pending'
2219
+ // Timer expired — setup did not. Offer another budget only when a human
2220
+ // can answer; confirm() already returns the fallback for agents, so this
2221
+ // never hangs unattended even if interactive were mis-set.
2222
+ spinner?.error(`The ${budgetLabel} wait timer expired`)
2223
+ deps.io.err(
2224
+ `The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`,
2225
+ )
2226
+ deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
2227
+ deps.io.err(
2228
+ `Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`,
2229
+ )
2230
+
2231
+ const keepWaiting = await deps.io.confirm(
2232
+ `Keep waiting for another ${budgetLabel}?`,
2233
+ false,
2234
+ )
2235
+ if (!keepWaiting) {
2236
+ deps.io.out('Stopping the wait. Device setup can continue; re-run `notifai init` when ready.')
2237
+ return 'pending'
2238
+ }
2239
+ spinner?.message(`Waiting another ${budgetLabel} for a companion device…`)
2240
+ deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
2241
+ }
1721
2242
  }
1722
2243
 
1723
2244
  function setupProofDraft(
@@ -1726,7 +2247,7 @@ function setupProofDraft(
1726
2247
  ): ReturnType<typeof buildDraft> {
1727
2248
  const project = config.project.value
1728
2249
  return buildDraft(config, {
1729
- title: 'NotifAI is ready',
2250
+ title: 'Notifai is ready',
1730
2251
  body:
1731
2252
  project === null
1732
2253
  ? 'This real notification completed setup verification.'
@@ -1804,7 +2325,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
1804
2325
  ) ?? candidates[0]
1805
2326
  if (!target) {
1806
2327
  deps.io.err(
1807
- 'Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts.',
2328
+ "Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts (the app's delivery confirmation).",
1808
2329
  )
1809
2330
  return 'pending'
1810
2331
  }
@@ -1836,7 +2357,9 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
1836
2357
  const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
1837
2358
  const deadline = now() + PROOF_TIMEOUT_MS
1838
2359
  const spinner = deps.io.interactive === true
1839
- ? await deps.io.spinner?.('Waiting for a Companion Receipt…')
2360
+ ? await deps.io.spinner?.(
2361
+ "Waiting for a Companion Receipt (the app's delivery confirmation)…",
2362
+ )
1840
2363
  : null
1841
2364
  let lastError: unknown = null
1842
2365
  let replacedMissingProof = false
@@ -1887,11 +2410,11 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
1887
2410
  await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())))
1888
2411
  }
1889
2412
 
1890
- spinner?.error('Companion Receipt not observed yet')
2413
+ spinner?.stop('Delivery confirmation not observed yet')
1891
2414
  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.',
2415
+ deps.io.out(
2416
+ `Provider accepted the notification; Companion Receipt (the app's delivery confirmation) for ${proof.request_id} was not observed within ${PROOF_TIMEOUT_MS / 1000}s. ` +
2417
+ 'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.',
1895
2418
  )
1896
2419
  return 'pending'
1897
2420
  }
@@ -1910,7 +2433,7 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
1910
2433
  const question =
1911
2434
  state.id === 'hooks'
1912
2435
  ? 'Install harness hooks, so questions reach your devices when you are away?'
1913
- : 'Install/update the agent guidance skill in this repo?'
2436
+ : 'Install/update the agent guidance skill through the native npx skills flow?'
1914
2437
  return deps.io.confirm(question, true)
1915
2438
  }
1916
2439
 
@@ -1931,9 +2454,36 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
1931
2454
  * states rather than four branches to enumerate.
1932
2455
  */
1933
2456
  export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
1934
- await deps.io.intro?.('NotifAI setup')
2457
+ if (
2458
+ flags.skillsScope !== undefined &&
2459
+ flags.skillsScope !== 'project' &&
2460
+ flags.skillsScope !== 'global'
2461
+ ) {
2462
+ deps.io.err('Invalid skill scope. Choose `project` or `global`.')
2463
+ return EXIT.usage
2464
+ }
2465
+ if (flags.skillsScope !== undefined && flags.skills !== true) {
2466
+ deps.io.err('`--skills-scope` requires `--skills`. Choose project or global in the native installer.')
2467
+ return EXIT.usage
2468
+ }
2469
+ if (
2470
+ flags.skills === true &&
2471
+ deps.io.interactive !== true &&
2472
+ flags.skillsScope === undefined
2473
+ ) {
2474
+ deps.io.err(
2475
+ 'Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.',
2476
+ )
2477
+ return EXIT.usage
2478
+ }
2479
+ await deps.io.intro?.('Notifai setup')
1935
2480
 
1936
- let readiness = await assessReadiness(deps)
2481
+ const reassess = () =>
2482
+ assessReadiness(
2483
+ deps,
2484
+ flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope },
2485
+ )
2486
+ let readiness = await reassess()
1937
2487
  let failed = false
1938
2488
  const attempted = new Set<string>()
1939
2489
 
@@ -1963,11 +2513,16 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
1963
2513
  attempted.add(state.id)
1964
2514
  const result = await closeGap(deps, state, flags)
1965
2515
  if (result === 'failed') failed = true
2516
+ if (result === 'failed' && state.status === 'optional-gap') {
2517
+ readiness = await reassess()
2518
+ advanced = true
2519
+ break
2520
+ }
1966
2521
  if (result !== 'closed') {
1967
2522
  stop = true
1968
2523
  break
1969
2524
  }
1970
- readiness = await assessReadiness(deps)
2525
+ readiness = await reassess()
1971
2526
  advanced = true
1972
2527
  break
1973
2528
  }
@@ -1980,30 +2535,26 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
1980
2535
  stop = true
1981
2536
  break
1982
2537
  }
1983
- readiness = await assessReadiness(deps)
2538
+ readiness = await reassess()
1984
2539
  advanced = true
1985
2540
  break
1986
2541
  }
1987
2542
 
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.
2543
+ // Its to launch, theirs to complete. Running `init` is the consent; announce
2544
+ // and open rather than re-asking. An agent never reaches this path.
1991
2545
  if (
1992
2546
  remedy.by === 'user-here' &&
1993
2547
  remedy.interactive === true &&
1994
2548
  deps.io.interactive === true
1995
2549
  ) {
1996
2550
  attempted.add(state.id)
1997
- if (!(await deps.io.confirm('Sign in now? (opens your browser)', true))) {
1998
- stop = true
1999
- break
2000
- }
2551
+ deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
2001
2552
  if ((await loginCommand(deps, {})) !== EXIT.ok) {
2002
2553
  failed = true
2003
2554
  stop = true
2004
2555
  break
2005
2556
  }
2006
- readiness = await assessReadiness(deps)
2557
+ readiness = await reassess()
2007
2558
  advanced = true
2008
2559
  break
2009
2560
  }
@@ -2020,7 +2571,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
2020
2571
  stop = true
2021
2572
  break
2022
2573
  }
2023
- readiness = await assessReadiness(deps)
2574
+ readiness = await reassess()
2024
2575
  advanced = true
2025
2576
  break
2026
2577
  }
@@ -2033,14 +2584,16 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
2033
2584
  if (stop || !advanced) break
2034
2585
  }
2035
2586
 
2036
- readiness = await assessReadiness(deps)
2587
+ readiness = await reassess()
2037
2588
  for (const state of readiness.states.filter((s) => s.status === 'ready')) {
2038
2589
  deps.io.out(`${state.title}: ${state.detail}`)
2039
2590
  }
2040
2591
 
2041
2592
  const blocker = firstBlocker(readiness)
2042
2593
  if (blocker === null) {
2043
- const skipped = openItems(readiness)
2594
+ const skipped = openItems(readiness).filter(
2595
+ (state) => !(state.id === 'skill' && flags.skills === false),
2596
+ )
2044
2597
  for (const state of skipped) deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
2045
2598
  deps.io.out('All set. Agents in this project can notify you and ask questions.')
2046
2599
  await deps.io.outro?.('All set ✨')
@@ -2053,7 +2606,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
2053
2606
  deps.io.out('')
2054
2607
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
2055
2608
  deps.io.out(` ${remedyLine(blocker)}`)
2056
- if (blocker.remedy?.by === 'user-elsewhere') {
2609
+ // Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
2610
+ // how the rest is recovered either way.
2611
+ if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
2057
2612
  deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
2058
2613
  }
2059
2614
  await deps.io.outro?.('One step remains (above)')
@@ -2093,9 +2648,8 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
2093
2648
  ok: false,
2094
2649
  detail:
2095
2650
  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`,
2651
+ ? `server speaks schema v${remote}, this CLI speaks v${local} — the service is being updated; try again later`
2652
+ : `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI: npm install -g @raidiant/notifai`,
2099
2653
  }
2100
2654
  } catch (err) {
2101
2655
  return {
@@ -2115,7 +2669,10 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
2115
2669
  * `unknown`, which is the honest answer and keeps a network outage from
2116
2670
  * looking like a broken install.
2117
2671
  */
2118
- export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
2672
+ export async function assessReadiness(
2673
+ deps: CommandDeps,
2674
+ options: { skillScope?: SkillScope } = {},
2675
+ ): Promise<Readiness> {
2119
2676
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
2120
2677
  const states: ReadinessState[] = []
2121
2678
  let accountClient: ApiClient | null = null
@@ -2223,58 +2780,35 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
2223
2780
  )
2224
2781
  }
2225
2782
 
2783
+ let accountEmail: string | null = null
2784
+ let accountLookupFailed = false
2226
2785
  if (!credential || !reachable) {
2227
2786
  const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
2228
2787
  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
2788
  } else {
2231
2789
  const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`)
2232
2790
  accountClient = client
2233
2791
  try {
2234
- const { devices } = await client.listDevices()
2792
+ const [{ devices }, email] = await Promise.all([
2793
+ client.listDevices(),
2794
+ Promise.resolve()
2795
+ .then(async () => (await client.accessStatus()).email)
2796
+ .catch(() => null as string | null),
2797
+ ])
2235
2798
  accountDevices = devices
2236
- const ready = devices.filter(deviceCanReceive)
2799
+ accountEmail = email
2237
2800
  states.push({
2238
2801
  id: 'auth',
2239
2802
  title: 'Account',
2240
2803
  status: 'ready',
2241
- detail: `machine ${credential.machineId} accepted`,
2804
+ detail: accountEmail
2805
+ ? `machine ${credential.machineId} accepted (${accountEmail})`
2806
+ : `machine ${credential.machineId} accepted`,
2242
2807
  })
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
2808
  } catch (err) {
2276
2809
  // A credential the server rejects is revocation, not absence, and the
2277
2810
  // remedy is the same sign-in either way.
2811
+ accountLookupFailed = true
2278
2812
  states.push({
2279
2813
  id: 'auth',
2280
2814
  title: 'Account',
@@ -2286,32 +2820,56 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
2286
2820
  command: 'notifai login',
2287
2821
  },
2288
2822
  })
2289
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
2290
2823
  }
2291
2824
  }
2292
2825
 
2826
+ // Optional setup that works without a companion device must appear before the
2827
+ // device gap: init stops at the first user-elsewhere blocker, and hooks/skill
2828
+ // are reachable without a phone.
2293
2829
  states.push(...hookStates(deps))
2830
+ states.push(await skillReadiness(deps, options.skillScope))
2294
2831
 
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',
2832
+ if (!credential || !reachable) {
2833
+ const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
2834
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` })
2835
+ } else if (accountLookupFailed || accountDevices === null) {
2836
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
2837
+ } else {
2838
+ const devices = accountDevices
2839
+ const ready = devices.filter(deviceCanReceive)
2840
+ states.push(
2841
+ ready.length > 0
2842
+ ? {
2843
+ id: 'devices',
2844
+ title: 'Your devices',
2845
+ status: 'ready',
2846
+ detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
2847
+ }
2848
+ : {
2849
+ id: 'devices',
2850
+ title: 'Your devices',
2851
+ status: 'gap',
2852
+ // The one gap that cannot be closed from this terminal, and the
2853
+ // likeliest place a first setup is abandoned. Naming which of
2854
+ // the three sub-states it is matters: "install the app" is
2855
+ // useless advice to someone who installed it and denied the
2856
+ // permission prompt. The live bridge is /support on the
2857
+ // dashboard origin — not a placeholder, and not typed by hand.
2858
+ detail:
2859
+ devices.length === 0
2860
+ ? `nothing registered yet; install Notifai on iPhone or Mac via ${supportPageUrl(baseUrl)}`
2861
+ : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
2862
+ remedy: {
2863
+ by: 'user-elsewhere',
2864
+ summary: deviceInstallRemedy({
2865
+ baseUrl,
2866
+ email: accountEmail,
2867
+ devices,
2868
+ }),
2869
+ },
2312
2870
  },
2313
- },
2314
- )
2871
+ )
2872
+ }
2315
2873
 
2316
2874
  states.push(await setupProofState(deps, config, accountClient, accountDevices))
2317
2875
 
@@ -2345,16 +2903,18 @@ async function setupProofState(
2345
2903
 
2346
2904
  const ios = readyIosDevices(devices)
2347
2905
  if (ios.length === 0) {
2906
+ // Honest non-blocking caveat: notifications can reach the Mac; only the
2907
+ // receipt proof path is unavailable in this release. Never emit a Next:
2908
+ // step the user cannot satisfy, and never claim unobserved proof.
2348
2909
  return {
2349
2910
  id: 'proof',
2350
2911
  title: 'Delivery proof',
2351
- status: 'gap',
2912
+ status: 'optional-gap',
2352
2913
  detail:
2353
- 'blocked — the current macOS notification path does not emit Companion Receipts, so this CLI cannot prove receipt on a macOS-only setup',
2914
+ "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
2915
  remedy: {
2355
2916
  by: 'user-elsewhere',
2356
- summary:
2357
- 'pair a receipt-capable iPhone build; no supported macOS receipt bridge is available in this release',
2917
+ summary: 'receipt proof needs an iPhone in this release (notifications still reach this Mac)',
2358
2918
  },
2359
2919
  }
2360
2920
  }
@@ -2366,10 +2926,12 @@ async function setupProofState(
2366
2926
  id: 'proof',
2367
2927
  title: 'Delivery proof',
2368
2928
  status: 'gap',
2369
- detail: 'no Companion Receipt has proven this project on this machine yet',
2929
+ detail:
2930
+ "no Companion Receipt (the app's delivery confirmation) has proven this project on this machine yet",
2370
2931
  remedy: {
2371
2932
  by: 'cli',
2372
- summary: 'send one real verification notification and wait for its Companion Receipt',
2933
+ summary:
2934
+ "send one real verification notification and wait for its Companion Receipt (the app's delivery confirmation)",
2373
2935
  command: 'notifai init',
2374
2936
  },
2375
2937
  }
@@ -2383,14 +2945,14 @@ async function setupProofState(
2383
2945
  id: 'proof',
2384
2946
  title: 'Delivery proof',
2385
2947
  status: 'ready',
2386
- detail: `Companion Receipt observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
2948
+ detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
2387
2949
  }
2388
2950
  }
2389
2951
  return {
2390
2952
  id: 'proof',
2391
2953
  title: 'Delivery proof',
2392
2954
  status: 'gap',
2393
- detail: `${proof.request_id} was sent, but its Companion Receipt is still unknown`,
2955
+ detail: `${proof.request_id} was sent, but its Companion Receipt (the app's delivery confirmation) is still unknown`,
2394
2956
  remedy: {
2395
2957
  by: 'cli',
2396
2958
  summary: 'check the same verification notification again',
@@ -2418,7 +2980,7 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
2418
2980
  const ok = blocker === null
2419
2981
 
2420
2982
  if (flags.json) {
2421
- deps.io.out(JSON.stringify({ ok, states: readiness.states }, null, 2))
2983
+ deps.io.out(JSON.stringify({ ok, exit_code: ok ? EXIT.ok : EXIT.failed, states: readiness.states }, null, 2))
2422
2984
  return ok ? EXIT.ok : EXIT.failed
2423
2985
  }
2424
2986
 
@@ -2428,12 +2990,19 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
2428
2990
  // problems in dependency order has an obvious first move and saying so
2429
2991
  // costs nothing.
2430
2992
  if (deps.io.interactive === true && deps.io.check) {
2431
- await deps.io.intro?.('NotifAI doctor')
2993
+ await deps.io.intro?.('Notifai doctor')
2432
2994
  for (const s of readiness.states) await deps.io.check(s.status !== 'gap', line(s))
2433
2995
  await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
2434
2996
  } else {
2435
2997
  for (const s of readiness.states) {
2436
- const mark = s.status === 'gap' ? 'FAIL' : s.status === 'unknown' ? ' ? ' : 'ok '
2998
+ const mark =
2999
+ s.status === 'gap'
3000
+ ? 'FAIL'
3001
+ : s.status === 'unknown'
3002
+ ? ' ? '
3003
+ : s.status === 'optional-gap'
3004
+ ? ' --'
3005
+ : 'ok '
2437
3006
  deps.io.out(`${mark} ${line(s)}`)
2438
3007
  }
2439
3008
  if (!ok) deps.io.out(`\nStart with: ${remedyLine(blocker)}`)
@@ -2476,39 +3045,67 @@ function remedyLine(state: ReadinessState): string {
2476
3045
  */
2477
3046
  function hookStates(deps: CommandDeps): ReadinessState[] {
2478
3047
  const installations = findInstallations(deps.cwd, deps.env)
3048
+ const active = activeHarnessSession(deps.env)
3049
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
3050
+ const settings: ReadinessState = {
3051
+ id: 'question-routing-settings',
3052
+ title: 'Question routing settings',
3053
+ status: 'ready',
3054
+ detail: [
3055
+ `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
3056
+ `require_idle=${config.require_idle.value} (${config.require_idle.source})`,
3057
+ `away_after_seconds=${config.away_after_seconds.value} (${config.away_after_seconds.source})`,
3058
+ `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
3059
+ `hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
3060
+ ].join(', '),
3061
+ }
2479
3062
  if (installations.length === 0) {
2480
3063
  return [
2481
3064
  {
2482
3065
  id: 'hooks',
2483
3066
  title: 'Question routing',
2484
- status: 'optional-gap',
2485
- detail: 'hooks not installed, so questions stay in the terminal',
3067
+ status: active === null ? 'optional-gap' : 'gap',
3068
+ detail:
3069
+ active === null
3070
+ ? 'hooks not installed, so questions stay in the terminal'
3071
+ : `active ${active.label} session detected, but ${active.label} hooks are not installed; \`notifai ask\` cannot route this session`,
2486
3072
  remedy: {
2487
3073
  by: 'cli',
2488
3074
  summary: 'install harness hooks so questions reach your devices when you are away',
2489
- command: 'notifai hooks install',
3075
+ command:
3076
+ active === null
3077
+ ? 'notifai hooks install'
3078
+ : `notifai hooks install --harness ${active.harness}`,
2490
3079
  },
2491
3080
  },
3081
+ settings,
2492
3082
  ]
2493
3083
  }
2494
3084
 
2495
3085
  /** 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
- }))
3086
+ const informational = new Set(['hooks (opencode continuation)'])
3087
+ if (active === null) informational.add('hooks (fired)')
3088
+ return [
3089
+ ...hookChecks(deps).map((check) => ({
3090
+ id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
3091
+ title:
3092
+ check.name === 'hooks' || check.name.startsWith('hooks (active')
3093
+ ? 'Question routing'
3094
+ : check.name,
3095
+ status: check.ok ? 'ready' as const : informational.has(check.name) ? 'optional-gap' as const : 'gap' as const,
3096
+ detail: check.detail,
3097
+ ...(check.ok
3098
+ ? {}
3099
+ : {
3100
+ remedy: {
3101
+ by: 'user-here' as const,
3102
+ summary: 'the detail above names what to change',
3103
+ command: 'notifai hooks install',
3104
+ },
3105
+ }),
3106
+ })),
3107
+ settings,
3108
+ ]
2512
3109
  }
2513
3110
 
2514
3111
  function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: string }[] {
@@ -2533,6 +3130,39 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
2533
3130
  .join(', '),
2534
3131
  })
2535
3132
 
3133
+ const active = activeHarnessSession(deps.env)
3134
+ const activeInstallations =
3135
+ active === null
3136
+ ? []
3137
+ : installations.filter((installation) => installation.harness === active.harness)
3138
+ if (active !== null) {
3139
+ checks.push({
3140
+ name: 'hooks (active harness)',
3141
+ ok: activeInstallations.length > 0,
3142
+ detail:
3143
+ activeInstallations.length > 0
3144
+ ? `active ${active.label} session has a matching hook installation`
3145
+ : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
3146
+ })
3147
+ if (activeInstallations.length > 0) {
3148
+ const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
3149
+ const matches =
3150
+ pointer !== null &&
3151
+ pointer.harness === active.harness &&
3152
+ (active.sessionId === undefined || pointer.sessionId === active.sessionId)
3153
+ checks.push({
3154
+ name: 'hooks (active session)',
3155
+ ok: matches,
3156
+ detail:
3157
+ pointer === null
3158
+ ? `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`
3159
+ : matches
3160
+ ? `the project pointer belongs to the active ${active.label} session`
3161
+ : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
3162
+ })
3163
+ }
3164
+ }
3165
+
2536
3166
  // A handler naming an event this build dropped exits 2 every time the harness
2537
3167
  // fires it, which the harness reports as a hook failure.
2538
3168
  const stale = installations.flatMap((i) =>
@@ -2563,6 +3193,29 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
2563
3193
  })
2564
3194
  }
2565
3195
 
3196
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
3197
+ const requiredTimeout = blockingHookTimeoutSeconds(
3198
+ config.ask_grace_seconds.value,
3199
+ config.hook_reply_timeout_seconds.value,
3200
+ )
3201
+ const shortTimeouts = installations.flatMap((installation) =>
3202
+ installation.handlers
3203
+ .filter((handler) => handlerEvent(handler.command) === 'stop')
3204
+ .filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
3205
+ .map(
3206
+ (handler) =>
3207
+ `${installation.harness} declares ${handler.timeout ?? 'no'}s, needs ${requiredTimeout}s — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
3208
+ ),
3209
+ )
3210
+ checks.push({
3211
+ name: 'hooks (timeout)',
3212
+ ok: shortTimeouts.length === 0,
3213
+ detail:
3214
+ shortTimeouts.length === 0
3215
+ ? `installed Stop timeouts cover the current ${requiredTimeout}s grace/reply budget`
3216
+ : shortTimeouts.join('; '),
3217
+ })
3218
+
2566
3219
  // Two checkouts each installing hooks means both fire for the same event, and
2567
3220
  // the user gets every question twice.
2568
3221
  //
@@ -2586,19 +3239,22 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
2586
3239
  detail: duplicated
2587
3240
  .map(
2588
3241
  (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(', ')}`,
3242
+ `${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
3243
  )
2591
3244
  .join('; '),
2592
3245
  })
2593
3246
  }
2594
3247
 
2595
- const fired = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)()) !== null
3248
+ const firedPointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
3249
+ const fired = firedPointer !== null
3250
+ const activationInstallations =
3251
+ active !== null && activeInstallations.length > 0 ? activeInstallations : installations
2596
3252
  checks.push({
2597
3253
  name: 'hooks (fired)',
2598
3254
  ok: fired,
2599
3255
  detail: fired
2600
3256
  ? 'a session in this directory has run them'
2601
- : `no session pointer from the last 24 hours — ${hookActivationAdvice(installations)}`,
3257
+ : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
2602
3258
  })
2603
3259
 
2604
3260
  if (installations.some((installation) => installation.harness === 'opencode')) {
@@ -2606,7 +3262,7 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
2606
3262
  name: 'hooks (opencode continuation)',
2607
3263
  ok: false,
2608
3264
  detail:
2609
- 'question routing is installed, but OpenCode cannot reliably resume an idle agent turn with the answer; use `notifai send --reply` for decisions',
3265
+ 'question routing is installed; OpenCode cannot resume an idle turn automatically, but its next user prompt collects the answer',
2610
3266
  })
2611
3267
  }
2612
3268
 
@@ -2731,10 +3387,16 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
2731
3387
  else log.error(message)
2732
3388
  },
2733
3389
  openUrl: (url) => {
2734
- const command =
2735
- process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
2736
3390
  try {
2737
- spawn(command, [url], { stdio: 'ignore', detached: true }).unref()
3391
+ if (process.platform === 'darwin') {
3392
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
3393
+ } else if (process.platform === 'win32') {
3394
+ // `start` is a cmd builtin; spawn('start', …) cannot work on Windows.
3395
+ // Empty title argument keeps URLs with special characters intact.
3396
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref()
3397
+ } else {
3398
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
3399
+ }
2738
3400
  } catch {
2739
3401
  // Browser opening is best-effort; the URL is printed anyway.
2740
3402
  }