@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/dist/commands.js CHANGED
@@ -4,16 +4,16 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSyn
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
7
- import { CAPABILITIES_V1, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
7
+ import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
8
8
  import { sha256Hex } from '@raidiant/notifai-protocol/node';
9
9
  import { ApiCallError, NetworkError, createClient, } from './client.js';
10
10
  import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
11
11
  import { firstBlocker, openItems } from './readiness.js';
12
- import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readProjectSession, registerQuestion, } from './hooks.js';
12
+ import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
13
13
  import { readIdleSeconds } from './idle.js';
14
14
  import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
15
15
  import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
16
- import { CHOICE_USAGE, ambiguousChoiceSplit, buildDraft, formatReceipt, parseChoices, receiptExitCode, } from './send.js';
16
+ import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
17
17
  export const EXIT = {
18
18
  ok: 0,
19
19
  failed: 1,
@@ -79,7 +79,7 @@ export async function loginCommand(deps, flags) {
79
79
  }
80
80
  const interactive = deps.io.interactive === true;
81
81
  if (interactive) {
82
- await deps.io.intro?.('NotifAI sign in');
82
+ await deps.io.intro?.('Notifai sign in');
83
83
  await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine');
84
84
  }
85
85
  else {
@@ -93,7 +93,14 @@ export async function loginCommand(deps, flags) {
93
93
  const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000;
94
94
  const now = deps.now ?? Date.now;
95
95
  const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
96
- const spinner = interactive ? await deps.io.spinner?.('Waiting for approval…') : null;
96
+ const approvalWaitMessage = () => {
97
+ const remainingSec = Math.max(0, Math.ceil((expiresAt - now()) / 1000));
98
+ const minutes = Math.floor(remainingSec / 60);
99
+ const seconds = remainingSec % 60;
100
+ const remaining = minutes > 0 ? `${minutes}m ${seconds.toString().padStart(2, '0')}s` : `${seconds}s`;
101
+ return `Waiting for approval… code ${begin.code} · ${remaining} left`;
102
+ };
103
+ const spinner = interactive ? await deps.io.spinner?.(approvalWaitMessage()) : null;
97
104
  while (now() < expiresAt) {
98
105
  await sleep(intervalMs);
99
106
  let poll;
@@ -102,7 +109,7 @@ export async function loginCommand(deps, flags) {
102
109
  }
103
110
  catch (err) {
104
111
  if (err instanceof NetworkError) {
105
- spinner?.message('Connection lost — retrying…');
112
+ spinner?.message(`Connection lost — retrying… code ${begin.code}`);
106
113
  continue;
107
114
  }
108
115
  spinner?.error('Pairing failed');
@@ -124,9 +131,18 @@ export async function loginCommand(deps, flags) {
124
131
  deps.io.err('Pairing was denied from the dashboard.');
125
132
  return EXIT.auth;
126
133
  }
134
+ if (poll.status === 'no_active_plan') {
135
+ const next = poll.next_action ??
136
+ `Open ${baseUrl.replace(/\/$/, '')}/support to request Alpha access, then retry.`;
137
+ spinner?.error('Account has no Alpha access');
138
+ deps.io.err('This account has no active plan or temporary Alpha access.');
139
+ deps.io.err(`next: ${next}`);
140
+ deps.io.err('After access is granted, run `notifai login` again.');
141
+ return EXIT.auth;
142
+ }
127
143
  if (poll.status === 'expired')
128
144
  break;
129
- spinner?.message('Waiting for approval…');
145
+ spinner?.message(approvalWaitMessage());
130
146
  }
131
147
  spinner?.error('Pairing expired');
132
148
  deps.io.err('Pairing expired before it was approved. Run `notifai login` again.');
@@ -160,6 +176,32 @@ export function authStatusCommand(deps, flags) {
160
176
  deps.io.out(`Credential store: ${deps.store.describe()}`);
161
177
  return EXIT.ok;
162
178
  }
179
+ /** Show the server's account access decision without attempting a product mutation. */
180
+ export async function accessStatusCommand(deps, flags) {
181
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
182
+ const authed = authedClient(deps, config);
183
+ if (!authed)
184
+ return EXIT.auth;
185
+ try {
186
+ const access = await authed.client.accessStatus();
187
+ if (flags.json) {
188
+ deps.io.out(JSON.stringify(access, null, 2));
189
+ return access.status === 'active' ? EXIT.ok : EXIT.failed;
190
+ }
191
+ if (access.status === 'no_active_plan') {
192
+ const supportUrl = `${authed.baseUrl.replace(/\/$/, '')}/support`;
193
+ deps.io.out('No active plan or temporary Alpha access for this account.');
194
+ deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`);
195
+ return EXIT.failed;
196
+ }
197
+ const expiry = access.expires_at ? ` until ${access.expires_at}` : '';
198
+ deps.io.out(`Access active (${access.reason})${expiry}`);
199
+ return EXIT.ok;
200
+ }
201
+ catch (err) {
202
+ return reportError(deps, err);
203
+ }
204
+ }
163
205
  // ---------------------------------------------------------------------------
164
206
  // devices / capabilities
165
207
  // ---------------------------------------------------------------------------
@@ -175,7 +217,15 @@ export async function devicesCommand(deps, flags) {
175
217
  return EXIT.ok;
176
218
  }
177
219
  if (result.devices.length === 0) {
178
- deps.io.out('No devices. Install a NotifAI companion app on a device and sign in.');
220
+ const supportUrl = supportPageUrl(authed.baseUrl);
221
+ let email = null;
222
+ try {
223
+ email = (await authed.client.accessStatus()).email;
224
+ }
225
+ catch {
226
+ // Best-effort: the empty-state copy still points at /support without it.
227
+ }
228
+ deps.io.out(`No devices yet. Install Notifai from ${supportUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`);
179
229
  return EXIT.ok;
180
230
  }
181
231
  for (const d of result.devices) {
@@ -215,6 +265,10 @@ export async function sendCommand(deps, flags) {
215
265
  const hasReplyChoice = Array.isArray(flags.replyChoice)
216
266
  ? flags.replyChoice.length > 0
217
267
  : flags.replyChoice !== undefined;
268
+ if (flags.reply && flags.kind === 'done') {
269
+ deps.io.err('--kind done cannot be combined with --reply; a reply request is a question.');
270
+ return EXIT.usage;
271
+ }
218
272
  if (!flags.reply &&
219
273
  (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)) {
220
274
  deps.io.err('Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.');
@@ -281,6 +335,7 @@ export async function sendCommand(deps, flags) {
281
335
  deps.io.err(`${issue.path}: ${issue.message}`);
282
336
  return EXIT.usage;
283
337
  }
338
+ emitSendWarnings(deps, flags, config);
284
339
  if (!flags.reply &&
285
340
  (flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))) {
286
341
  deps.io.err('Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --reply-choice) so it can be answered from the notification.');
@@ -292,10 +347,19 @@ export async function sendCommand(deps, flags) {
292
347
  const receiptExit = receiptExitCode(receipt);
293
348
  if (!flags.json)
294
349
  deps.io.out(formatReceipt(receipt));
350
+ else if (flags.reply)
351
+ deps.io.out(JSON.stringify({ type: 'receipt', receipt }));
295
352
  // A zero wait can no longer reach here: --reply guarantees a positive one.
296
353
  if (!flags.reply || receiptExit !== EXIT.ok) {
297
354
  if (flags.json) {
298
- deps.io.out(JSON.stringify(flags.reply ? { receipt, replies: [] } : receipt, null, 2));
355
+ deps.io.out(flags.reply
356
+ ? JSON.stringify({
357
+ type: 'reply_result',
358
+ request_id: receipt.request_id,
359
+ replies: [],
360
+ degraded: false,
361
+ })
362
+ : JSON.stringify(receipt, null, 2));
299
363
  }
300
364
  return receiptExit;
301
365
  }
@@ -306,7 +370,12 @@ export async function sendCommand(deps, flags) {
306
370
  sleep: deps.sleep,
307
371
  });
308
372
  if (flags.json) {
309
- deps.io.out(JSON.stringify({ receipt, replies: result.response.replies, degraded: result.degraded }, null, 2));
373
+ deps.io.out(JSON.stringify({
374
+ type: 'reply_result',
375
+ request_id: receipt.request_id,
376
+ replies: result.response.replies,
377
+ degraded: result.degraded,
378
+ }));
310
379
  }
311
380
  else if (result.response.replies.length > 0)
312
381
  printReplies(deps, result.response.replies);
@@ -316,13 +385,40 @@ export async function sendCommand(deps, flags) {
316
385
  deps.io.err(DEGRADED_WAIT_WARNING);
317
386
  return EXIT.network;
318
387
  }
388
+ if (result.timedOut) {
389
+ deps.io.err(`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
390
+ `\`notifai close ${receipt.request_id}\`.`);
391
+ }
319
392
  return result.timedOut ? EXIT.noReply : EXIT.ok;
320
393
  }
321
394
  catch (err) {
322
395
  return reportError(deps, err);
323
396
  }
324
397
  }
325
- export async function repliesCommand(deps, requestId, flags) {
398
+ function emitSendWarnings(deps, flags, config) {
399
+ if (flags.title.length > 40) {
400
+ deps.io.err(`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`);
401
+ }
402
+ if (looksLikeMarkdown(flags.body)) {
403
+ deps.io.err('Heads up: --body looks like Markdown, but banners show plain text. Put long-form Markdown in --detail or --detail-file.');
404
+ }
405
+ const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update');
406
+ if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
407
+ deps.io.err(`Heads up: this title announces completion but the notification kind is update. Use --kind done for finished work.`);
408
+ }
409
+ if (flags.collapseKey === undefined &&
410
+ config.collapse_key.value !== null &&
411
+ config.collapse_key.source.startsWith('global:')) {
412
+ deps.io.err('Heads up: collapse_key comes from machine-global config, so unrelated notifications may replace each other. Prefer a project or command-specific --collapse-key.');
413
+ }
414
+ if (flags.ttl !== undefined && flags.ttl > 259_200) {
415
+ deps.io.err('Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.');
416
+ }
417
+ }
418
+ function looksLikeMarkdown(value) {
419
+ return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(value);
420
+ }
421
+ export async function repliesCommand(deps, requestedId, flags) {
326
422
  const waitSeconds = flags.wait ?? 0;
327
423
  const afterSeq = flags.after ?? 0;
328
424
  if (!isNonNegativeInteger(waitSeconds)) {
@@ -333,6 +429,27 @@ export async function repliesCommand(deps, requestId, flags) {
333
429
  deps.io.err('--after must be a non-negative integer sequence number.');
334
430
  return EXIT.usage;
335
431
  }
432
+ if (flags.pending === true && requestedId !== undefined) {
433
+ deps.io.err('Pass a request id or --pending, not both.');
434
+ return EXIT.usage;
435
+ }
436
+ let requestId = requestedId;
437
+ if (flags.pending === true) {
438
+ const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
439
+ if (sessionId === null) {
440
+ deps.io.err('No active session pointer is available in this directory.');
441
+ return EXIT.noReply;
442
+ }
443
+ requestId = readSessionState(sessionId, deps.env).pending?.request_id;
444
+ if (requestId === undefined) {
445
+ deps.io.err(`Session ${sessionId} has no pushed question pending.`);
446
+ return EXIT.noReply;
447
+ }
448
+ }
449
+ if (requestId === undefined) {
450
+ deps.io.err('Pass a request id or --pending.');
451
+ return EXIT.usage;
452
+ }
336
453
  const config = loadConfig({ cwd: deps.cwd, env: deps.env });
337
454
  const authed = authedClient(deps, config);
338
455
  if (!authed)
@@ -347,8 +464,11 @@ export async function repliesCommand(deps, requestId, flags) {
347
464
  if (flags.json) {
348
465
  deps.io.out(JSON.stringify({ ...result.response, degraded: result.degraded }, null, 2));
349
466
  }
350
- else if (result.response.replies.length > 0)
467
+ else if (result.response.replies.length > 0) {
468
+ if (flags.pending === true)
469
+ deps.io.out(`pending request ${requestId}`);
351
470
  printReplies(deps, result.response.replies);
471
+ }
352
472
  else
353
473
  printNoReply(deps, requestId, result.response.reply_expires_at);
354
474
  if (result.degraded) {
@@ -430,29 +550,27 @@ function printReplies(deps, replies) {
430
550
  deps.io.err(contradiction);
431
551
  }
432
552
  /**
433
- * The other half of first-reply-wins.
553
+ * The correction note for a wait that saw more than one answer.
434
554
  *
435
- * A blocking send returns the first non-empty batch and acts on it, so a later
436
- * answer that disagrees is silently ignored. That is the right default — an
437
- * agent wants one answer, not a quorum — but silently is the wrong way to do
438
- * it. The server now retires the question on the other devices the moment one
439
- * answers, which makes this rare; it is still reachable, because a device can
440
- * answer between the first reply landing and its retirement arriving.
555
+ * A later reply that conflicts with an earlier one is a correction, so the
556
+ * latest answer is the one that counts. The server retires the question on
557
+ * the other devices the moment one answers, which makes this rare; it is
558
+ * still reachable, because a device can answer between the first reply
559
+ * landing and its retirement arriving.
441
560
  *
442
- * Ordered by seq, so `replies[0]` is the one that won.
561
+ * Ordered by seq, so `replies.at(-1)` is the answer to act on.
443
562
  */
444
563
  export function contradictingAnswer(replies) {
445
- const winner = replies[0];
564
+ const winner = replies.at(-1);
446
565
  if (winner === undefined || replies.length < 2)
447
566
  return null;
448
- const disagreeing = replies
449
- .slice(1)
450
- .filter((reply) => (reply.choice_id ?? reply.text) !== (winner.choice_id ?? winner.text));
451
- if (disagreeing.length === 0)
567
+ const key = (reply) => reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text;
568
+ const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner));
569
+ if (corrected.length === 0)
452
570
  return null;
453
- const names = [...new Set(disagreeing.map((reply) => reply.device_name))].join(', ');
454
- return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — it arrived first. ` +
455
- `${names} answered differently afterwards and that answer was not used.`);
571
+ const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ');
572
+ return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
573
+ `answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`);
456
574
  }
457
575
  function printNoReply(deps, requestId, expiresAt) {
458
576
  // A harness hook may have retired this question, in which case promising an
@@ -481,11 +599,11 @@ export async function statusCommand(deps, requestId, flags) {
481
599
  deps.io.out(` Provider Acceptance: ${d.state === 'provider_accepted' ? 'accepted' : 'not recorded'}`);
482
600
  if (d.companion_receipt.state === 'observed') {
483
601
  const latency = d.companion_receipt.latency_ms;
484
- deps.io.out(` Companion Receipt: observed at ${d.companion_receipt.observed_at}` +
602
+ deps.io.out(` Companion Receipt (the app's delivery confirmation): observed at ${d.companion_receipt.observed_at}` +
485
603
  (latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`));
486
604
  }
487
605
  else {
488
- deps.io.out(' Companion Receipt: unknown — not observed; this is not a failure or proof of non-receipt');
606
+ deps.io.out(" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt");
489
607
  }
490
608
  for (const e of d.events) {
491
609
  deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
@@ -569,7 +687,19 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
569
687
  }
570
688
  let envelope;
571
689
  try {
572
- envelope = parseHookInput(await readStdin());
690
+ const raw = await readStdin();
691
+ if (raw.trim() !== '') {
692
+ try {
693
+ const parsed = JSON.parse(raw);
694
+ if (typeof parsed !== 'object' || parsed === null)
695
+ throw new Error('not an object');
696
+ }
697
+ catch {
698
+ deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken');
699
+ return EXIT.ok;
700
+ }
701
+ }
702
+ envelope = parseHookInput(raw);
573
703
  if (harness === 'cursor') {
574
704
  const sessionId = envelope.session_id ?? envelope.conversation_id;
575
705
  const cwd = envelope.cwd ?? envelope.workspace_roots?.[0];
@@ -602,8 +732,10 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
602
732
  const cwd = envelope.cwd ?? deps.cwd;
603
733
  const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
604
734
  const credential = deps.store.load();
605
- if (!credential)
735
+ if (!credential) {
736
+ deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`');
606
737
  return EXIT.ok;
738
+ }
607
739
  // Pin authenticated traffic to the origin the credential was issued for. A
608
740
  // repository can commit `.notifai/config.toml`, and honouring a base_url
609
741
  // from it would hand this machine's bearer token to whatever host it names.
@@ -636,6 +768,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
636
768
  degraded: result.degraded,
637
769
  };
638
770
  },
771
+ ...(harness === undefined ? {} : { harness }),
639
772
  };
640
773
  // Real clock, deliberately, not `deps.now`. This compares against file
641
774
  // mtimes, which are wall-clock facts — handing it a virtual or skewed clock
@@ -706,6 +839,88 @@ function rejectedPaths(details) {
706
839
  .filter((path) => path !== null && path !== '')
707
840
  .slice(0, 5);
708
841
  }
842
+ /**
843
+ * Turn ask input into the question set that will ride the push. Everything is
844
+ * validated here, at registration, because the push happens inside a hook
845
+ * where a rejection becomes a stderr note the agent never reads.
846
+ */
847
+ export function buildQuestions(flags, question) {
848
+ if (flags.form !== undefined) {
849
+ if (question !== undefined || flags.choice?.length || flags.multi) {
850
+ return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' };
851
+ }
852
+ let parsed;
853
+ try {
854
+ parsed = JSON.parse(flags.form);
855
+ }
856
+ catch {
857
+ return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' };
858
+ }
859
+ if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.questions)) {
860
+ return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
861
+ }
862
+ const form = parsed;
863
+ if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
864
+ return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` };
865
+ }
866
+ if (form.detail !== undefined && typeof form.detail !== 'string') {
867
+ return { ok: false, error: '"detail" must be a markdown string.' };
868
+ }
869
+ const questions = [];
870
+ const usedIds = new Set();
871
+ for (const [index, entry] of form.questions.entries()) {
872
+ if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
873
+ return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
874
+ }
875
+ const spec = entry;
876
+ const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds);
877
+ if ('error' in built)
878
+ return { ok: false, error: `Question ${index + 1}: ${built.error}` };
879
+ questions.push(built.question);
880
+ }
881
+ return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) };
882
+ }
883
+ if (question === undefined || question.trim() === '') {
884
+ return { ok: false, error: 'The question cannot be empty.' };
885
+ }
886
+ const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
887
+ if ('error' in built)
888
+ return { ok: false, error: built.error };
889
+ return {
890
+ ok: true,
891
+ questions: [built.question],
892
+ ...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
893
+ };
894
+ }
895
+ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
896
+ const trimmed = text.trim();
897
+ if (trimmed === '')
898
+ return { error: 'the question text cannot be empty.' };
899
+ if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
900
+ return {
901
+ error: `a question must be readable where it is answered: keep it within ` +
902
+ `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
903
+ };
904
+ }
905
+ const choices = parseChoices(choiceLabels);
906
+ if (choices === 'invalid')
907
+ return { error: CHOICE_USAGE };
908
+ if (multi && choices === null) {
909
+ return { error: '--multi needs answers to select between; add --choice.' };
910
+ }
911
+ let id = slugify(trimmed);
912
+ if (id === '' || usedIds.has(id))
913
+ id = `q${index + 1}`;
914
+ usedIds.add(id);
915
+ return {
916
+ question: {
917
+ id,
918
+ text: trimmed,
919
+ ...(choices !== null ? { choices } : {}),
920
+ ...(multi ? { multi: true } : {}),
921
+ },
922
+ };
923
+ }
709
924
  /**
710
925
  * Registers a question for turn-end routing under the user's presence config.
711
926
  * Returns immediately so the agent can ask in prose and end its turn. With the
@@ -713,55 +928,140 @@ function rejectedPaths(details) {
713
928
  * `require_idle = false` intentionally permits a push while the user is active.
714
929
  */
715
930
  export function askCommand(deps, question, flags) {
716
- // An agent calling this gets no hook payload and no harness exports its
717
- // session id, so the UserPromptSubmit hook leaves a pointer keyed on the
718
- // project directory and we read it back here. The pointer outranks the
931
+ // An agent calling this gets no hook payload. Harness-native environment
932
+ // markers identify the active owner, while the UserPromptSubmit hook leaves
933
+ // a pointer keyed on the project directory with the hook's canonical id.
934
+ // The pointer outranks the
719
935
  // NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
720
936
  // label rather than the harness's own id, and the hooks key state by the
721
937
  // latter — the env var is only trusted when no hook has spoken.
722
938
  const now = (deps.now ?? Date.now)();
723
- const sessionId = flags.session ??
724
- readProjectSession(deps.cwd, deps.env, now) ??
725
- deps.env['NOTIFAI_SESSION'] ??
726
- undefined;
939
+ const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now);
940
+ const pointer = projectPointer?.sessionId ?? null;
941
+ const active = activeHarnessSession(deps.env);
942
+ let sessionId;
943
+ if (flags.session !== undefined) {
944
+ sessionId = flags.session;
945
+ }
946
+ else if (active !== null) {
947
+ const installations = findInstallations(deps.cwd, deps.env);
948
+ const activeInstalled = installations.some((installation) => installation.harness === active.harness);
949
+ if (!activeInstalled) {
950
+ for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
951
+ deps.io.err(line);
952
+ }
953
+ return EXIT.usage;
954
+ }
955
+ if (projectPointer === null) {
956
+ for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
957
+ deps.io.err(line);
958
+ }
959
+ return EXIT.usage;
960
+ }
961
+ if (projectPointer.harness !== active.harness ||
962
+ (active.sessionId !== undefined && projectPointer.sessionId !== active.sessionId)) {
963
+ for (const line of diagnoseActiveHarnessSession(active, 'different-session', installations)) {
964
+ deps.io.err(line);
965
+ }
966
+ return EXIT.usage;
967
+ }
968
+ sessionId = projectPointer.sessionId;
969
+ }
970
+ else {
971
+ sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined;
972
+ }
727
973
  if (!sessionId) {
728
974
  for (const line of diagnoseMissingSession(deps))
729
975
  deps.io.err(line);
730
976
  return EXIT.usage;
731
977
  }
732
- if (question.trim() === '') {
733
- deps.io.err('The question cannot be empty.');
734
- return EXIT.usage;
735
- }
736
978
  // Validate here, not at push time. The push happens inside a hook, where a
737
979
  // rejection becomes a stderr note the agent never reads — so a malformed
738
- // choice set would look like it registered fine and then silently never ask.
739
- const choices = parseChoices(flags.choice);
740
- if (choices === 'invalid') {
741
- deps.io.err(CHOICE_USAGE);
980
+ // question set would look like it registered fine and then silently never ask.
981
+ const built = buildQuestions(flags, question);
982
+ if (!built.ok) {
983
+ deps.io.err(built.error);
742
984
  return EXIT.usage;
743
985
  }
744
986
  try {
745
987
  registerQuestion(sessionId, deps.env, {
746
- question: question.trim(),
747
- ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
988
+ question: built.questions[0].text,
989
+ questions: built.questions,
990
+ ...(built.detail !== undefined ? { detail: built.detail } : {}),
748
991
  }, (deps.now ?? Date.now)());
749
992
  }
750
993
  catch (err) {
751
994
  deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
752
995
  return EXIT.failed;
753
996
  }
754
- if (choices !== null) {
755
- deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`);
997
+ for (const [index, entry] of built.questions.entries()) {
998
+ const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
999
+ if (entry.choices !== undefined) {
1000
+ const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
1001
+ deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
1002
+ }
1003
+ else if (built.questions.length > 1) {
1004
+ deps.io.out(`${prefix}${entry.text} — free text`);
1005
+ }
756
1006
  }
757
- // On stdout, not stderr: the agent reads the former and this is only useful
758
- // if it is read while there is still time to fix the question.
759
- const ambiguous = ambiguousChoiceSplit(flags.choice);
760
- if (ambiguous !== null)
761
- deps.io.out(ambiguous);
762
- deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.');
1007
+ deps.io.out(built.questions.length > 1
1008
+ ? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
1009
+ : 'Question registered. Ask it in the conversation as usual and end your turn.');
763
1010
  return EXIT.ok;
764
1011
  }
1012
+ function activeHarnessSession(env) {
1013
+ const explicit = env['NOTIFAI_ACTIVE_HARNESS'];
1014
+ if (explicit === 'opencode') {
1015
+ const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID'];
1016
+ return {
1017
+ harness: 'opencode',
1018
+ label: 'OpenCode',
1019
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1020
+ };
1021
+ }
1022
+ if (env['CLAUDECODE'] === '1') {
1023
+ const sessionId = env['CLAUDE_CODE_SESSION_ID'];
1024
+ return {
1025
+ harness: 'claude-code',
1026
+ label: 'Claude Code',
1027
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1028
+ };
1029
+ }
1030
+ const codexSession = env['CODEX_THREAD_ID'];
1031
+ if (codexSession !== undefined && codexSession !== '') {
1032
+ return { harness: 'codex', label: 'Codex', sessionId: codexSession };
1033
+ }
1034
+ if ((env['CURSOR_AGENT'] ?? '') !== '')
1035
+ return { harness: 'cursor', label: 'Cursor' };
1036
+ return null;
1037
+ }
1038
+ function diagnoseActiveHarnessSession(active, problem, installations) {
1039
+ if (problem === 'not-installed') {
1040
+ const others = installations.map((installation) => installation.harness);
1041
+ return [
1042
+ `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
1043
+ ...(others.length === 0
1044
+ ? []
1045
+ : [
1046
+ `Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
1047
+ ]),
1048
+ `Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
1049
+ `Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
1050
+ ];
1051
+ }
1052
+ if (problem === 'different-session') {
1053
+ return [
1054
+ `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
1055
+ `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
1056
+ `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
1057
+ ];
1058
+ }
1059
+ return [
1060
+ `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
1061
+ `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
1062
+ `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
1063
+ ];
1064
+ }
765
1065
  /**
766
1066
  * Why `ask` cannot see a session, in terms of what to do about it.
767
1067
  *
@@ -775,13 +1075,13 @@ function diagnoseMissingSession(deps) {
775
1075
  const installations = findInstallations(deps.cwd, deps.env);
776
1076
  if (installations.length === 0) {
777
1077
  return [
778
- 'Could not tell which harness session this is: no NotifAI hooks are installed for this project.',
1078
+ 'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
779
1079
  'Run `notifai hooks install`, then follow the activation instruction it prints.',
780
1080
  ];
781
1081
  }
782
1082
  const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ');
783
1083
  return [
784
- `Could not tell which harness session this is. NotifAI hooks are installed (${where}),`,
1084
+ `Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
785
1085
  'but no usable session pointer from the last 24 hours exists here.',
786
1086
  hookActivationAdvice(installations),
787
1087
  'To ask from this session anyway, pass --session <id>.',
@@ -807,7 +1107,7 @@ function hookActivationAdvice(installations) {
807
1107
  advice.push('Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
808
1108
  }
809
1109
  if (harnesses.has('opencode')) {
810
- advice.push('OpenCode: restart it, then send one prompt; plugins load at startup');
1110
+ advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, and a device answer is delivered on the next prompt');
811
1111
  }
812
1112
  return `${advice.join('. ')}.`;
813
1113
  }
@@ -857,6 +1157,7 @@ export function hooksInstallCommand(deps, flags) {
857
1157
  scriptPath,
858
1158
  replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
859
1159
  graceSeconds: config.ask_grace_seconds.value,
1160
+ harness: 'cursor',
860
1161
  }), scriptPath);
861
1162
  try {
862
1163
  applyPlan(file, merged.document);
@@ -878,8 +1179,8 @@ export function hooksInstallCommand(deps, flags) {
878
1179
  deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
879
1180
  deps.io.out('start a new Cursor session and try one prompt again.');
880
1181
  }
881
- deps.io.out('A companion-device answer is submitted as one');
882
- deps.io.out('follow-up user message; loop_limit = 1 prevents repeated answer turns.');
1182
+ deps.io.out('A companion-device answer is submitted as one follow-up user message.');
1183
+ deps.io.out('loop_limit = 3 permits bounded chained questions without an unbounded loop.');
883
1184
  return EXIT.ok;
884
1185
  }
885
1186
  let document;
@@ -895,6 +1196,7 @@ export function hooksInstallCommand(deps, flags) {
895
1196
  scriptPath,
896
1197
  replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
897
1198
  graceSeconds: config.ask_grace_seconds.value,
1199
+ harness,
898
1200
  }), scriptPath);
899
1201
  try {
900
1202
  applyPlan(file, merged.document);
@@ -917,11 +1219,11 @@ export function hooksInstallCommand(deps, flags) {
917
1219
  `nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
918
1220
  `until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
919
1221
  `has elapsed; it goes to your devices only while the machine also meets the idle threshold. ` +
920
- `Set \`require_idle = false\` to be notified even while you are working.`
1222
+ `Run \`notifai config set require_idle false\` to be notified even while you are working.`
921
1223
  : `A question registered with \`notifai ask\` stays in the terminal for ` +
922
1224
  `${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
923
1225
  `whether or not you are at this machine ` +
924
- `(\`require_idle = false\`).`);
1226
+ `(\`notifai config set require_idle false\` is already in effect).`);
925
1227
  if (config.require_idle.value) {
926
1228
  deps.io.out('If this OS exposes no keyboard/mouse idle signal, the hook falls back to prompt silence and skips the blocking grace once it decides you are away.');
927
1229
  }
@@ -957,7 +1259,7 @@ export function hooksInstallCommand(deps, flags) {
957
1259
  return EXIT.ok;
958
1260
  }
959
1261
  /**
960
- * Writes the OpenCode plugin, replacing any NotifAI plugin already there —
1262
+ * Writes the OpenCode plugin, replacing any Notifai plugin already there —
961
1263
  * including one a different checkout wrote, matched on the managed marker for
962
1264
  * the same reason command hooks are.
963
1265
  */
@@ -965,7 +1267,7 @@ function installOpencodePlugin(deps, file, options) {
965
1267
  if (existsSync(file)) {
966
1268
  const existing = readFileSync(file, 'utf8');
967
1269
  if (!isOurOpencodePlugin(existing)) {
968
- deps.io.err(`${file} exists and was not written by NotifAI; move it aside first.`);
1270
+ deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`);
969
1271
  return EXIT.failed;
970
1272
  }
971
1273
  }
@@ -982,8 +1284,9 @@ function installOpencodePlugin(deps, file, options) {
982
1284
  deps.io.out('It maps chat.message to presence, session.idle to question escalation, and');
983
1285
  deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands');
984
1286
  deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.');
985
- deps.io.out('OpenCode cannot reliably resume an idle agent turn with a device answer; use');
986
- deps.io.out('`notifai send --reply` when the answer must return to the agent.');
1287
+ deps.io.out('OpenCode pushes without holding its idle event open; the next user prompt');
1288
+ deps.io.out('collects a device answer and adds it to the agent context. Use `notifai');
1289
+ deps.io.out('send --reply` only when the answer must return before another prompt.');
987
1290
  deps.io.out('');
988
1291
  deps.io.out('Restart OpenCode: it loads plugins once at start.');
989
1292
  return EXIT.ok;
@@ -1001,11 +1304,11 @@ export function hooksUninstallCommand(deps, flags) {
1001
1304
  if (harness === 'opencode') {
1002
1305
  // We own the whole file, but only if we wrote it.
1003
1306
  if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
1004
- deps.io.out(`Left ${file} alone: NotifAI did not write it.`);
1307
+ deps.io.out(`Left ${file} alone: Notifai did not write it.`);
1005
1308
  return EXIT.ok;
1006
1309
  }
1007
1310
  rmSync(file, { force: true });
1008
- deps.io.out(`Removed the NotifAI OpenCode plugin at ${file}`);
1311
+ deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`);
1009
1312
  return EXIT.ok;
1010
1313
  }
1011
1314
  if (harness === 'cursor') {
@@ -1026,8 +1329,8 @@ export function hooksUninstallCommand(deps, flags) {
1026
1329
  return EXIT.failed;
1027
1330
  }
1028
1331
  deps.io.out(stripped.replaced.length > 0
1029
- ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1030
- : `No NotifAI hooks found in ${file}`);
1332
+ ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1333
+ : `No Notifai hooks found in ${file}`);
1031
1334
  return EXIT.ok;
1032
1335
  }
1033
1336
  let document;
@@ -1047,8 +1350,8 @@ export function hooksUninstallCommand(deps, flags) {
1047
1350
  return EXIT.failed;
1048
1351
  }
1049
1352
  deps.io.out(stripped.replaced.length > 0
1050
- ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1051
- : `No NotifAI hooks found in ${file}`);
1353
+ ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1354
+ : `No Notifai hooks found in ${file}`);
1052
1355
  return EXIT.ok;
1053
1356
  }
1054
1357
  function resolveHarness(deps, requested) {
@@ -1159,27 +1462,59 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1159
1462
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1160
1463
  * Keep this immutable and public because the command is printed to users.
1161
1464
  */
1162
- export const SKILLS_SOURCE = 'RafaelVidaurre/notifai#v0.1.7';
1163
- function pinnedSkillInstalled(cwd) {
1465
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.0';
1466
+ function skillSourceParts() {
1164
1467
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
1165
- if (!match)
1166
- return false;
1167
- const lockPath = path.join(cwd, 'skills-lock.json');
1168
- const installedPath = path.join(cwd, '.agents', 'skills', 'notifai', 'SKILL.md');
1169
- if (!existsSync(lockPath) || !existsSync(installedPath))
1170
- return false;
1171
- try {
1172
- const parsed = JSON.parse(readFileSync(lockPath, 'utf8'));
1173
- const skill = parsed.skills?.notifai;
1174
- if (!skill)
1175
- return false;
1176
- return (skill.source === match[1] &&
1177
- skill.ref === match[2] &&
1178
- skill.skillPath === 'skills/notifai/SKILL.md');
1179
- }
1180
- catch {
1181
- return false;
1468
+ return match === null ? null : { source: match[1], ref: match[2] };
1469
+ }
1470
+ function expectedSkill(skill) {
1471
+ const expected = skillSourceParts();
1472
+ return (expected !== null &&
1473
+ skill.name === 'notifai' &&
1474
+ skill.source === expected.source &&
1475
+ skill.sourceType === 'github' &&
1476
+ skill.ref === expected.ref);
1477
+ }
1478
+ async function skillReadiness(deps, selectedScope) {
1479
+ const scopes = selectedScope === undefined ? ['project', 'global'] : [selectedScope];
1480
+ const results = await Promise.all(scopes.map(async (scope) => {
1481
+ if (deps.nativeSkills === undefined)
1482
+ return { scope, skills: [] };
1483
+ try {
1484
+ return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) };
1485
+ }
1486
+ catch (err) {
1487
+ return { scope, skills: [], error: String(err) };
1488
+ }
1489
+ }));
1490
+ const installed = results.flatMap(({ skills }) => skills).find(expectedSkill);
1491
+ if (installed !== undefined) {
1492
+ return {
1493
+ id: 'skill',
1494
+ title: 'Agent guidance skill',
1495
+ status: 'ready',
1496
+ detail: `installed from ${SKILLS_SOURCE} in the ${installed.scope} scope`,
1497
+ };
1182
1498
  }
1499
+ const errors = results
1500
+ .filter((result) => result.error !== undefined)
1501
+ .map((result) => `${result.scope}: ${result.error}`);
1502
+ const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`;
1503
+ return {
1504
+ id: 'skill',
1505
+ title: 'Agent guidance skill',
1506
+ status: 'optional-gap',
1507
+ detail: errors.length > 0
1508
+ ? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
1509
+ : `not installed from ${SKILLS_SOURCE} in ${scopeText}`,
1510
+ remedy: {
1511
+ by: 'cli',
1512
+ summary: 'install the skill agents follow when deciding to notify',
1513
+ command: selectedScope === undefined
1514
+ ? 'notifai init --skills'
1515
+ : `notifai init --skills --skills-scope ${selectedScope}`,
1516
+ },
1517
+ };
1183
1518
  }
1184
1519
  /** Derive a contract-valid project slug from a directory name. */
1185
1520
  export function projectSlugFrom(name) {
@@ -1190,10 +1525,36 @@ export function projectSlugFrom(name) {
1190
1525
  .slice(0, 64);
1191
1526
  return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project';
1192
1527
  }
1193
- const DEVICE_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
1528
+ /** Long enough for a first TestFlight install; keep-waiting extends another budget. */
1529
+ const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000;
1194
1530
  const DEVICE_BRIDGE_POLL_MS = 2_000;
1195
1531
  const PROOF_TIMEOUT_MS = 30_000;
1196
1532
  const PROOF_POLL_MS = 1_000;
1533
+ function supportPageUrl(baseUrl) {
1534
+ return `${baseUrl.replace(/\/$/, '')}/support`;
1535
+ }
1536
+ function formatWaitBudget(milliseconds) {
1537
+ const minutes = Math.round(milliseconds / 60_000);
1538
+ return minutes === 1 ? '1 minute' : `${minutes} minutes`;
1539
+ }
1540
+ /** Same-account line for the device hop; prefers the real email when known. */
1541
+ function sameAccountSignInLine(email) {
1542
+ return email
1543
+ ? `sign in with the same email as this account (${email})`
1544
+ : 'sign in with the same email as this account';
1545
+ }
1546
+ function deviceInstallRemedy(options) {
1547
+ const support = supportPageUrl(options.baseUrl);
1548
+ const sameEmail = sameAccountSignInLine(options.email);
1549
+ if (options.devices.length === 0) {
1550
+ return (`open the install steps at ${support} on that device (or your phone’s browser), ` +
1551
+ `install Notifai, ${sameEmail}, and allow notifications`);
1552
+ }
1553
+ if (options.devices.some((d) => d.permission_status === 'denied')) {
1554
+ return 'allow notifications for Notifai in that device’s system settings';
1555
+ }
1556
+ return 'open Notifai on that device and allow its notification prompt';
1557
+ }
1197
1558
  function setupProofPath(deps) {
1198
1559
  let projectDir = path.resolve(deps.cwd);
1199
1560
  try {
@@ -1268,18 +1629,23 @@ async function closeGap(deps, state, flags) {
1268
1629
  return hooksInstallCommand(deps, { harness }) === EXIT.ok ? 'closed' : 'failed';
1269
1630
  }
1270
1631
  if (state.id === 'skill') {
1271
- deps.io.out(`Installing the notifai agent skill (npx skills add ${SKILLS_SOURCE})...`);
1272
- const code = await new Promise((resolve) => {
1273
- const child = spawn('npx', ['-y', 'skills', 'add', SKILLS_SOURCE, '--skill', 'notifai'], {
1274
- cwd: deps.cwd,
1275
- stdio: 'inherit',
1276
- });
1277
- child.on('error', () => resolve(1));
1278
- child.on('exit', (exitCode) => resolve(exitCode ?? 1));
1279
- });
1632
+ if (deps.nativeSkills === undefined) {
1633
+ deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.');
1634
+ return 'failed';
1635
+ }
1636
+ const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`;
1637
+ deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`);
1638
+ const addOptions = {
1639
+ source: SKILLS_SOURCE,
1640
+ skill: 'notifai',
1641
+ cwd: deps.cwd,
1642
+ env: deps.env,
1643
+ ...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
1644
+ };
1645
+ const code = await deps.nativeSkills.add(addOptions);
1280
1646
  if (code !== 0) {
1281
1647
  deps.io.err('Skill installation failed — run it manually with:');
1282
- deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`);
1648
+ deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai${flags.skillsScope === 'global' ? ' --global' : ''}${flags.skillsScope === undefined ? '' : ' --yes'}`);
1283
1649
  }
1284
1650
  return code === 0 ? 'closed' : 'failed';
1285
1651
  }
@@ -1308,56 +1674,90 @@ function deviceBridgeMessage(devices) {
1308
1674
  }
1309
1675
  /**
1310
1676
  * Observe the supported Device Installation path while the user finishes the
1311
- * app-side work. There is deliberately no invented QR or download URL here:
1312
- * this release exposes neither an App Store/TestFlight URL nor a device-pairing
1313
- * endpoint, and a placeholder bridge is worse than an explicit boundary.
1677
+ * app-side work. The live bridge is the dashboard `/support` page (TestFlight
1678
+ * steps). Interactive runs offer to open it so the user never types the URL;
1679
+ * non-interactive/agent paths only print plain text and never wait on a prompt.
1680
+ *
1681
+ * The wait budget is stated up front. On expiry we say the *timer* expired —
1682
+ * not the setup — and offer another budget when a human is present. Agents
1683
+ * never hang: this whole path is gated on `io.interactive`, and keep-waiting
1684
+ * uses `confirm` which resolves the safe default when not interactive.
1314
1685
  */
1315
1686
  async function waitForReadyDevice(deps, state) {
1316
1687
  const remedy = state.remedy;
1317
1688
  if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere')
1318
1689
  return 'pending';
1319
- await deps.io.note?.(`${state.detail}\n${remedy.summary}`, 'Finish setup on your companion device');
1320
- if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
1321
- return 'pending';
1322
- }
1323
1690
  const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1324
1691
  const authed = authedClient(deps, config);
1325
- if (!authed)
1692
+ if (!authed) {
1693
+ deps.io.err('Could not start the companion-device wait: this machine is not signed in.');
1326
1694
  return 'failed';
1695
+ }
1696
+ const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS);
1697
+ const supportUrl = supportPageUrl(authed.baseUrl);
1698
+ await deps.io.note?.([
1699
+ state.detail,
1700
+ remedy.summary,
1701
+ `Install steps (no typing): ${supportUrl}`,
1702
+ `I will wait up to ${budgetLabel} for a companion device to become ready.`,
1703
+ ].join('\n'), 'Finish setup on your companion device');
1704
+ // Open the real support page so the user never has to type the URL. Decline
1705
+ // is fine — the URL remains in the note and in the Next: line if they leave.
1706
+ if (await deps.io.confirm('Open install instructions in your browser?', true)) {
1707
+ deps.io.openUrl(supportUrl);
1708
+ }
1709
+ if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
1710
+ deps.io.out(`OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`);
1711
+ return 'pending';
1712
+ }
1327
1713
  const now = deps.now ?? Date.now;
1328
- const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
1329
- const deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
1330
- const spinner = await deps.io.spinner?.('Waiting for a companion device…');
1714
+ const sleep = deps.sleep ??
1715
+ ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
1716
+ const spinner = await deps.io.spinner?.(`Waiting up to ${budgetLabel} for a companion device…`);
1331
1717
  let lastDevices = [];
1332
- while (now() < deadline) {
1333
- try {
1334
- const response = await authed.client.listDevices();
1335
- lastDevices = response.devices;
1336
- const ready = response.devices.find(deviceCanReceive);
1337
- if (ready) {
1338
- spinner?.stop(`${ready.display_name} is ready to receive`);
1339
- return 'closed';
1718
+ let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
1719
+ for (;;) {
1720
+ while (now() < deadline) {
1721
+ try {
1722
+ const response = await authed.client.listDevices();
1723
+ lastDevices = response.devices;
1724
+ const ready = response.devices.find(deviceCanReceive);
1725
+ if (ready) {
1726
+ spinner?.stop(`${ready.display_name} is ready to receive`);
1727
+ return 'closed';
1728
+ }
1729
+ spinner?.message(deviceBridgeMessage(response.devices));
1340
1730
  }
1341
- spinner?.message(deviceBridgeMessage(response.devices));
1342
- }
1343
- catch (err) {
1344
- if (!(err instanceof NetworkError)) {
1345
- spinner?.error('Could not check companion readiness');
1346
- reportError(deps, err);
1347
- return 'failed';
1731
+ catch (err) {
1732
+ if (!(err instanceof NetworkError)) {
1733
+ spinner?.error('Could not check companion readiness');
1734
+ reportError(deps, err);
1735
+ return 'failed';
1736
+ }
1737
+ spinner?.message('Connection lost — still watching…');
1348
1738
  }
1349
- spinner?.message('Connection lost — still watching…');
1739
+ await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())));
1350
1740
  }
1351
- await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())));
1741
+ // Timer expired — setup did not. Offer another budget only when a human
1742
+ // can answer; confirm() already returns the fallback for agents, so this
1743
+ // never hangs unattended even if interactive were mis-set.
1744
+ spinner?.error(`The ${budgetLabel} wait timer expired`);
1745
+ deps.io.err(`The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`);
1746
+ deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'));
1747
+ deps.io.err(`Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`);
1748
+ const keepWaiting = await deps.io.confirm(`Keep waiting for another ${budgetLabel}?`, false);
1749
+ if (!keepWaiting) {
1750
+ deps.io.out('Stopping the wait. Device setup can continue; re-run `notifai init` when ready.');
1751
+ return 'pending';
1752
+ }
1753
+ spinner?.message(`Waiting another ${budgetLabel} for a companion device…`);
1754
+ deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
1352
1755
  }
1353
- spinner?.error('No companion device became ready');
1354
- deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'));
1355
- return 'pending';
1356
1756
  }
1357
1757
  function setupProofDraft(config, device) {
1358
1758
  const project = config.project.value;
1359
1759
  return buildDraft(config, {
1360
- title: 'NotifAI is ready',
1760
+ title: 'Notifai is ready',
1361
1761
  body: project === null
1362
1762
  ? 'This real notification completed setup verification.'
1363
1763
  : `This real notification completed setup verification for ${project}.`,
@@ -1421,7 +1821,7 @@ async function runSetupProof(deps) {
1421
1821
  const existing = readSetupProof(deps);
1422
1822
  const target = candidates.find((device) => device.device_id === existing?.device_id && existing.project === config.project.value) ?? candidates[0];
1423
1823
  if (!target) {
1424
- deps.io.err('Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts.');
1824
+ deps.io.err("Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts (the app's delivery confirmation).");
1425
1825
  return 'pending';
1426
1826
  }
1427
1827
  let proof = existing?.device_id === target.device_id && existing.project === config.project.value
@@ -1452,7 +1852,7 @@ async function runSetupProof(deps) {
1452
1852
  const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
1453
1853
  const deadline = now() + PROOF_TIMEOUT_MS;
1454
1854
  const spinner = deps.io.interactive === true
1455
- ? await deps.io.spinner?.('Waiting for a Companion Receipt…')
1855
+ ? await deps.io.spinner?.("Waiting for a Companion Receipt (the app's delivery confirmation)…")
1456
1856
  : null;
1457
1857
  let lastError = null;
1458
1858
  let replacedMissingProof = false;
@@ -1502,11 +1902,11 @@ async function runSetupProof(deps) {
1502
1902
  break;
1503
1903
  await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())));
1504
1904
  }
1505
- spinner?.error('Companion Receipt not observed yet');
1905
+ spinner?.stop('Delivery confirmation not observed yet');
1506
1906
  if (lastError instanceof NetworkError)
1507
1907
  deps.io.err(lastError.message);
1508
- deps.io.err(`No Companion Receipt was observed for ${proof.request_id} within ${PROOF_TIMEOUT_MS / 1000}s. ` +
1509
- 'That is not proof of non-receipt; re-run `notifai init` to check this same notification again.');
1908
+ deps.io.out(`Provider accepted the notification; Companion Receipt (the app's delivery confirmation) for ${proof.request_id} was not observed within ${PROOF_TIMEOUT_MS / 1000}s. ` +
1909
+ 'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.');
1510
1910
  return 'pending';
1511
1911
  }
1512
1912
  /** Whether an optional gap should be closed, given flags and who is watching. */
@@ -1525,7 +1925,7 @@ function wantsOptional(deps, state, flags) {
1525
1925
  return Promise.resolve(false);
1526
1926
  const question = state.id === 'hooks'
1527
1927
  ? 'Install harness hooks, so questions reach your devices when you are away?'
1528
- : 'Install/update the agent guidance skill in this repo?';
1928
+ : 'Install/update the agent guidance skill through the native npx skills flow?';
1529
1929
  return deps.io.confirm(question, true);
1530
1930
  }
1531
1931
  /**
@@ -1545,8 +1945,25 @@ function wantsOptional(deps, state, flags) {
1545
1945
  * states rather than four branches to enumerate.
1546
1946
  */
1547
1947
  export async function initCommand(deps, flags) {
1548
- await deps.io.intro?.('NotifAI setup');
1549
- let readiness = await assessReadiness(deps);
1948
+ if (flags.skillsScope !== undefined &&
1949
+ flags.skillsScope !== 'project' &&
1950
+ flags.skillsScope !== 'global') {
1951
+ deps.io.err('Invalid skill scope. Choose `project` or `global`.');
1952
+ return EXIT.usage;
1953
+ }
1954
+ if (flags.skillsScope !== undefined && flags.skills !== true) {
1955
+ deps.io.err('`--skills-scope` requires `--skills`. Choose project or global in the native installer.');
1956
+ return EXIT.usage;
1957
+ }
1958
+ if (flags.skills === true &&
1959
+ deps.io.interactive !== true &&
1960
+ flags.skillsScope === undefined) {
1961
+ deps.io.err('Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.');
1962
+ return EXIT.usage;
1963
+ }
1964
+ await deps.io.intro?.('Notifai setup');
1965
+ const reassess = () => assessReadiness(deps, flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope });
1966
+ let readiness = await reassess();
1550
1967
  let failed = false;
1551
1968
  const attempted = new Set();
1552
1969
  // Re-assess after every successful action. This is how a browser approval or
@@ -1577,11 +1994,16 @@ export async function initCommand(deps, flags) {
1577
1994
  const result = await closeGap(deps, state, flags);
1578
1995
  if (result === 'failed')
1579
1996
  failed = true;
1997
+ if (result === 'failed' && state.status === 'optional-gap') {
1998
+ readiness = await reassess();
1999
+ advanced = true;
2000
+ break;
2001
+ }
1580
2002
  if (result !== 'closed') {
1581
2003
  stop = true;
1582
2004
  break;
1583
2005
  }
1584
- readiness = await assessReadiness(deps);
2006
+ readiness = await reassess();
1585
2007
  advanced = true;
1586
2008
  break;
1587
2009
  }
@@ -1594,27 +2016,23 @@ export async function initCommand(deps, flags) {
1594
2016
  stop = true;
1595
2017
  break;
1596
2018
  }
1597
- readiness = await assessReadiness(deps);
2019
+ readiness = await reassess();
1598
2020
  advanced = true;
1599
2021
  break;
1600
2022
  }
1601
- // Its to launch, theirs to complete. Offering to start the sign-in is
1602
- // useful only when someone is demonstrably watching; an agent never
1603
- // reaches this prompt or opens a browser.
2023
+ // Its to launch, theirs to complete. Running `init` is the consent; announce
2024
+ // and open rather than re-asking. An agent never reaches this path.
1604
2025
  if (remedy.by === 'user-here' &&
1605
2026
  remedy.interactive === true &&
1606
2027
  deps.io.interactive === true) {
1607
2028
  attempted.add(state.id);
1608
- if (!(await deps.io.confirm('Sign in now? (opens your browser)', true))) {
1609
- stop = true;
1610
- break;
1611
- }
2029
+ deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.');
1612
2030
  if ((await loginCommand(deps, {})) !== EXIT.ok) {
1613
2031
  failed = true;
1614
2032
  stop = true;
1615
2033
  break;
1616
2034
  }
1617
- readiness = await assessReadiness(deps);
2035
+ readiness = await reassess();
1618
2036
  advanced = true;
1619
2037
  break;
1620
2038
  }
@@ -1629,7 +2047,7 @@ export async function initCommand(deps, flags) {
1629
2047
  stop = true;
1630
2048
  break;
1631
2049
  }
1632
- readiness = await assessReadiness(deps);
2050
+ readiness = await reassess();
1633
2051
  advanced = true;
1634
2052
  break;
1635
2053
  }
@@ -1640,13 +2058,13 @@ export async function initCommand(deps, flags) {
1640
2058
  if (stop || !advanced)
1641
2059
  break;
1642
2060
  }
1643
- readiness = await assessReadiness(deps);
2061
+ readiness = await reassess();
1644
2062
  for (const state of readiness.states.filter((s) => s.status === 'ready')) {
1645
2063
  deps.io.out(`${state.title}: ${state.detail}`);
1646
2064
  }
1647
2065
  const blocker = firstBlocker(readiness);
1648
2066
  if (blocker === null) {
1649
- const skipped = openItems(readiness);
2067
+ const skipped = openItems(readiness).filter((state) => !(state.id === 'skill' && flags.skills === false));
1650
2068
  for (const state of skipped)
1651
2069
  deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
1652
2070
  deps.io.out('All set. Agents in this project can notify you and ask questions.');
@@ -1659,7 +2077,9 @@ export async function initCommand(deps, flags) {
1659
2077
  deps.io.out('');
1660
2078
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
1661
2079
  deps.io.out(` ${remedyLine(blocker)}`);
1662
- if (blocker.remedy?.by === 'user-elsewhere') {
2080
+ // Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
2081
+ // how the rest is recovered either way.
2082
+ if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
1663
2083
  deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
1664
2084
  }
1665
2085
  await deps.io.outro?.('One step remains (above)');
@@ -1696,9 +2116,8 @@ async function contractCheck(client) {
1696
2116
  name: 'contract',
1697
2117
  ok: false,
1698
2118
  detail: local !== undefined && remote < local
1699
- ? `server speaks schema v${remote}, this CLI speaks v${local} — the server needs deploying, ` +
1700
- 'until then sends carrying newer fields are rejected'
1701
- : `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI`,
2119
+ ? `server speaks schema v${remote}, this CLI speaks v${local} — the service is being updated; try again later`
2120
+ : `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI: npm install -g @raidiant/notifai`,
1702
2121
  };
1703
2122
  }
1704
2123
  catch (err) {
@@ -1718,7 +2137,7 @@ async function contractCheck(client) {
1718
2137
  * `unknown`, which is the honest answer and keeps a network outage from
1719
2138
  * looking like a broken install.
1720
2139
  */
1721
- export async function assessReadiness(deps) {
2140
+ export async function assessReadiness(deps, options = {}) {
1722
2141
  const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1723
2142
  const states = [];
1724
2143
  let accountClient = null;
@@ -1815,56 +2234,37 @@ export async function assessReadiness(deps) {
1815
2234
  },
1816
2235
  });
1817
2236
  }
2237
+ let accountEmail = null;
2238
+ let accountLookupFailed = false;
1818
2239
  if (!credential || !reachable) {
1819
2240
  const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
1820
2241
  states.push({ id: 'auth', title: 'Account', status: 'unknown', detail: `not checked — ${why}` });
1821
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` });
1822
2242
  }
1823
2243
  else {
1824
2244
  const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`);
1825
2245
  accountClient = client;
1826
2246
  try {
1827
- const { devices } = await client.listDevices();
2247
+ const [{ devices }, email] = await Promise.all([
2248
+ client.listDevices(),
2249
+ Promise.resolve()
2250
+ .then(async () => (await client.accessStatus()).email)
2251
+ .catch(() => null),
2252
+ ]);
1828
2253
  accountDevices = devices;
1829
- const ready = devices.filter(deviceCanReceive);
2254
+ accountEmail = email;
1830
2255
  states.push({
1831
2256
  id: 'auth',
1832
2257
  title: 'Account',
1833
2258
  status: 'ready',
1834
- detail: `machine ${credential.machineId} accepted`,
2259
+ detail: accountEmail
2260
+ ? `machine ${credential.machineId} accepted (${accountEmail})`
2261
+ : `machine ${credential.machineId} accepted`,
1835
2262
  });
1836
- states.push(ready.length > 0
1837
- ? {
1838
- id: 'devices',
1839
- title: 'Your devices',
1840
- status: 'ready',
1841
- detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
1842
- }
1843
- : {
1844
- id: 'devices',
1845
- title: 'Your devices',
1846
- status: 'gap',
1847
- // The one gap that cannot be closed from this terminal, and the
1848
- // likeliest place a first setup is abandoned. Naming which of
1849
- // the three sub-states it is matters: "install the app" is
1850
- // useless advice to someone who installed it and denied the
1851
- // permission prompt.
1852
- detail: devices.length === 0
1853
- ? 'nothing registered yet; this release has no supported App Store/TestFlight URL for the CLI to open'
1854
- : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
1855
- remedy: {
1856
- by: 'user-elsewhere',
1857
- summary: devices.length === 0
1858
- ? 'install NotifAI through a supported distribution you already have, open it, sign in with the same account, and allow notifications'
1859
- : devices.some((d) => d.permission_status === 'denied')
1860
- ? 'allow notifications for NotifAI in that device’s system settings'
1861
- : 'open NotifAI on that device and allow its notification prompt',
1862
- },
1863
- });
1864
2263
  }
1865
2264
  catch (err) {
1866
2265
  // A credential the server rejects is revocation, not absence, and the
1867
2266
  // remedy is the same sign-in either way.
2267
+ accountLookupFailed = true;
1868
2268
  states.push({
1869
2269
  id: 'auth',
1870
2270
  title: 'Account',
@@ -1876,28 +2276,53 @@ export async function assessReadiness(deps) {
1876
2276
  command: 'notifai login',
1877
2277
  },
1878
2278
  });
1879
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' });
1880
2279
  }
1881
2280
  }
2281
+ // Optional setup that works without a companion device must appear before the
2282
+ // device gap: init stops at the first user-elsewhere blocker, and hooks/skill
2283
+ // are reachable without a phone.
1882
2284
  states.push(...hookStates(deps));
1883
- states.push(pinnedSkillInstalled(deps.cwd)
1884
- ? {
1885
- id: 'skill',
1886
- title: 'Agent guidance skill',
1887
- status: 'ready',
1888
- detail: `installed from ${SKILLS_SOURCE} in this project`,
1889
- }
1890
- : {
1891
- id: 'skill',
1892
- title: 'Agent guidance skill',
1893
- status: 'optional-gap',
1894
- detail: `not installed from ${SKILLS_SOURCE} in this project`,
1895
- remedy: {
1896
- by: 'cli',
1897
- summary: 'install the skill agents follow when deciding to notify',
1898
- command: 'notifai init --skills',
1899
- },
1900
- });
2285
+ states.push(await skillReadiness(deps, options.skillScope));
2286
+ if (!credential || !reachable) {
2287
+ const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
2288
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` });
2289
+ }
2290
+ else if (accountLookupFailed || accountDevices === null) {
2291
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' });
2292
+ }
2293
+ else {
2294
+ const devices = accountDevices;
2295
+ const ready = devices.filter(deviceCanReceive);
2296
+ states.push(ready.length > 0
2297
+ ? {
2298
+ id: 'devices',
2299
+ title: 'Your devices',
2300
+ status: 'ready',
2301
+ detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
2302
+ }
2303
+ : {
2304
+ id: 'devices',
2305
+ title: 'Your devices',
2306
+ status: 'gap',
2307
+ // The one gap that cannot be closed from this terminal, and the
2308
+ // likeliest place a first setup is abandoned. Naming which of
2309
+ // the three sub-states it is matters: "install the app" is
2310
+ // useless advice to someone who installed it and denied the
2311
+ // permission prompt. The live bridge is /support on the
2312
+ // dashboard origin — not a placeholder, and not typed by hand.
2313
+ detail: devices.length === 0
2314
+ ? `nothing registered yet; install Notifai on iPhone or Mac via ${supportPageUrl(baseUrl)}`
2315
+ : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
2316
+ remedy: {
2317
+ by: 'user-elsewhere',
2318
+ summary: deviceInstallRemedy({
2319
+ baseUrl,
2320
+ email: accountEmail,
2321
+ devices,
2322
+ }),
2323
+ },
2324
+ });
2325
+ }
1901
2326
  states.push(await setupProofState(deps, config, accountClient, accountDevices));
1902
2327
  return { states };
1903
2328
  }
@@ -1921,14 +2346,17 @@ async function setupProofState(deps, config, client, devices) {
1921
2346
  }
1922
2347
  const ios = readyIosDevices(devices);
1923
2348
  if (ios.length === 0) {
2349
+ // Honest non-blocking caveat: notifications can reach the Mac; only the
2350
+ // receipt proof path is unavailable in this release. Never emit a Next:
2351
+ // step the user cannot satisfy, and never claim unobserved proof.
1924
2352
  return {
1925
2353
  id: 'proof',
1926
2354
  title: 'Delivery proof',
1927
- status: 'gap',
1928
- detail: 'blocked — the current macOS notification path does not emit Companion Receipts, so this CLI cannot prove receipt on a macOS-only setup',
2355
+ status: 'optional-gap',
2356
+ detail: "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",
1929
2357
  remedy: {
1930
2358
  by: 'user-elsewhere',
1931
- summary: 'pair a receipt-capable iPhone build; no supported macOS receipt bridge is available in this release',
2359
+ summary: 'receipt proof needs an iPhone in this release (notifications still reach this Mac)',
1932
2360
  },
1933
2361
  };
1934
2362
  }
@@ -1939,10 +2367,10 @@ async function setupProofState(deps, config, client, devices) {
1939
2367
  id: 'proof',
1940
2368
  title: 'Delivery proof',
1941
2369
  status: 'gap',
1942
- detail: 'no Companion Receipt has proven this project on this machine yet',
2370
+ detail: "no Companion Receipt (the app's delivery confirmation) has proven this project on this machine yet",
1943
2371
  remedy: {
1944
2372
  by: 'cli',
1945
- summary: 'send one real verification notification and wait for its Companion Receipt',
2373
+ summary: "send one real verification notification and wait for its Companion Receipt (the app's delivery confirmation)",
1946
2374
  command: 'notifai init',
1947
2375
  },
1948
2376
  };
@@ -1955,14 +2383,14 @@ async function setupProofState(deps, config, client, devices) {
1955
2383
  id: 'proof',
1956
2384
  title: 'Delivery proof',
1957
2385
  status: 'ready',
1958
- detail: `Companion Receipt observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
2386
+ detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
1959
2387
  };
1960
2388
  }
1961
2389
  return {
1962
2390
  id: 'proof',
1963
2391
  title: 'Delivery proof',
1964
2392
  status: 'gap',
1965
- detail: `${proof.request_id} was sent, but its Companion Receipt is still unknown`,
2393
+ detail: `${proof.request_id} was sent, but its Companion Receipt (the app's delivery confirmation) is still unknown`,
1966
2394
  remedy: {
1967
2395
  by: 'cli',
1968
2396
  summary: 'check the same verification notification again',
@@ -1989,7 +2417,7 @@ export async function doctorCommand(deps, flags) {
1989
2417
  const blocker = firstBlocker(readiness);
1990
2418
  const ok = blocker === null;
1991
2419
  if (flags.json) {
1992
- deps.io.out(JSON.stringify({ ok, states: readiness.states }, null, 2));
2420
+ deps.io.out(JSON.stringify({ ok, exit_code: ok ? EXIT.ok : EXIT.failed, states: readiness.states }, null, 2));
1993
2421
  return ok ? EXIT.ok : EXIT.failed;
1994
2422
  }
1995
2423
  const line = (s) => `${s.title}: ${s.detail}`;
@@ -1998,14 +2426,20 @@ export async function doctorCommand(deps, flags) {
1998
2426
  // problems in dependency order has an obvious first move and saying so
1999
2427
  // costs nothing.
2000
2428
  if (deps.io.interactive === true && deps.io.check) {
2001
- await deps.io.intro?.('NotifAI doctor');
2429
+ await deps.io.intro?.('Notifai doctor');
2002
2430
  for (const s of readiness.states)
2003
2431
  await deps.io.check(s.status !== 'gap', line(s));
2004
2432
  await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`);
2005
2433
  }
2006
2434
  else {
2007
2435
  for (const s of readiness.states) {
2008
- const mark = s.status === 'gap' ? 'FAIL' : s.status === 'unknown' ? ' ? ' : 'ok ';
2436
+ const mark = s.status === 'gap'
2437
+ ? 'FAIL'
2438
+ : s.status === 'unknown'
2439
+ ? ' ? '
2440
+ : s.status === 'optional-gap'
2441
+ ? ' --'
2442
+ : 'ok ';
2009
2443
  deps.io.out(`${mark} ${line(s)}`);
2010
2444
  }
2011
2445
  if (!ok)
@@ -2049,38 +2483,64 @@ function remedyLine(state) {
2049
2483
  */
2050
2484
  function hookStates(deps) {
2051
2485
  const installations = findInstallations(deps.cwd, deps.env);
2486
+ const active = activeHarnessSession(deps.env);
2487
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
2488
+ const settings = {
2489
+ id: 'question-routing-settings',
2490
+ title: 'Question routing settings',
2491
+ status: 'ready',
2492
+ detail: [
2493
+ `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
2494
+ `require_idle=${config.require_idle.value} (${config.require_idle.source})`,
2495
+ `away_after_seconds=${config.away_after_seconds.value} (${config.away_after_seconds.source})`,
2496
+ `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
2497
+ `hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
2498
+ ].join(', '),
2499
+ };
2052
2500
  if (installations.length === 0) {
2053
2501
  return [
2054
2502
  {
2055
2503
  id: 'hooks',
2056
2504
  title: 'Question routing',
2057
- status: 'optional-gap',
2058
- detail: 'hooks not installed, so questions stay in the terminal',
2505
+ status: active === null ? 'optional-gap' : 'gap',
2506
+ detail: active === null
2507
+ ? 'hooks not installed, so questions stay in the terminal'
2508
+ : `active ${active.label} session detected, but ${active.label} hooks are not installed; \`notifai ask\` cannot route this session`,
2059
2509
  remedy: {
2060
2510
  by: 'cli',
2061
2511
  summary: 'install harness hooks so questions reach your devices when you are away',
2062
- command: 'notifai hooks install',
2512
+ command: active === null
2513
+ ? 'notifai hooks install'
2514
+ : `notifai hooks install --harness ${active.harness}`,
2063
2515
  },
2064
2516
  },
2517
+ settings,
2065
2518
  ];
2066
2519
  }
2067
2520
  /** Real but not in the way; see the note above. */
2068
- const informational = new Set(['hooks (fired)', 'hooks (opencode continuation)']);
2069
- return hookChecks(deps).map((check) => ({
2070
- id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
2071
- title: check.name === 'hooks' ? 'Question routing' : check.name,
2072
- status: check.ok ? 'ready' : informational.has(check.name) ? 'optional-gap' : 'gap',
2073
- detail: check.detail,
2074
- ...(check.ok
2075
- ? {}
2076
- : {
2077
- remedy: {
2078
- by: 'user-here',
2079
- summary: 'the detail above names what to change',
2080
- command: 'notifai hooks install',
2081
- },
2082
- }),
2083
- }));
2521
+ const informational = new Set(['hooks (opencode continuation)']);
2522
+ if (active === null)
2523
+ informational.add('hooks (fired)');
2524
+ return [
2525
+ ...hookChecks(deps).map((check) => ({
2526
+ id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
2527
+ title: check.name === 'hooks' || check.name.startsWith('hooks (active')
2528
+ ? 'Question routing'
2529
+ : check.name,
2530
+ status: check.ok ? 'ready' : informational.has(check.name) ? 'optional-gap' : 'gap',
2531
+ detail: check.detail,
2532
+ ...(check.ok
2533
+ ? {}
2534
+ : {
2535
+ remedy: {
2536
+ by: 'user-here',
2537
+ summary: 'the detail above names what to change',
2538
+ command: 'notifai hooks install',
2539
+ },
2540
+ }),
2541
+ })),
2542
+ settings,
2543
+ ];
2084
2544
  }
2085
2545
  function hookChecks(deps) {
2086
2546
  const checks = [];
@@ -2102,6 +2562,34 @@ function hookChecks(deps) {
2102
2562
  .map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
2103
2563
  .join(', '),
2104
2564
  });
2565
+ const active = activeHarnessSession(deps.env);
2566
+ const activeInstallations = active === null
2567
+ ? []
2568
+ : installations.filter((installation) => installation.harness === active.harness);
2569
+ if (active !== null) {
2570
+ checks.push({
2571
+ name: 'hooks (active harness)',
2572
+ ok: activeInstallations.length > 0,
2573
+ detail: activeInstallations.length > 0
2574
+ ? `active ${active.label} session has a matching hook installation`
2575
+ : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
2576
+ });
2577
+ if (activeInstallations.length > 0) {
2578
+ const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)());
2579
+ const matches = pointer !== null &&
2580
+ pointer.harness === active.harness &&
2581
+ (active.sessionId === undefined || pointer.sessionId === active.sessionId);
2582
+ checks.push({
2583
+ name: 'hooks (active session)',
2584
+ ok: matches,
2585
+ detail: pointer === null
2586
+ ? `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`
2587
+ : matches
2588
+ ? `the project pointer belongs to the active ${active.label} session`
2589
+ : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
2590
+ });
2591
+ }
2592
+ }
2105
2593
  // A handler naming an event this build dropped exits 2 every time the harness
2106
2594
  // fires it, which the harness reports as a hook failure.
2107
2595
  const stale = installations.flatMap((i) => i.handlers
@@ -2125,6 +2613,19 @@ function hookChecks(deps) {
2125
2613
  detail: adapterProblems.join('; '),
2126
2614
  });
2127
2615
  }
2616
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
2617
+ const requiredTimeout = blockingHookTimeoutSeconds(config.ask_grace_seconds.value, config.hook_reply_timeout_seconds.value);
2618
+ const shortTimeouts = installations.flatMap((installation) => installation.handlers
2619
+ .filter((handler) => handlerEvent(handler.command) === 'stop')
2620
+ .filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
2621
+ .map((handler) => `${installation.harness} declares ${handler.timeout ?? 'no'}s, needs ${requiredTimeout}s — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
2622
+ checks.push({
2623
+ name: 'hooks (timeout)',
2624
+ ok: shortTimeouts.length === 0,
2625
+ detail: shortTimeouts.length === 0
2626
+ ? `installed Stop timeouts cover the current ${requiredTimeout}s grace/reply budget`
2627
+ : shortTimeouts.join('; '),
2628
+ });
2128
2629
  // Two checkouts each installing hooks means both fire for the same event, and
2129
2630
  // the user gets every question twice.
2130
2631
  //
@@ -2144,23 +2645,25 @@ function hookChecks(deps) {
2144
2645
  name: 'hooks (duplicates)',
2145
2646
  ok: false,
2146
2647
  detail: duplicated
2147
- .map((entry) => `${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(', ')}`)
2648
+ .map((entry) => `${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(', ')}`)
2148
2649
  .join('; '),
2149
2650
  });
2150
2651
  }
2151
- const fired = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)()) !== null;
2652
+ const firedPointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)());
2653
+ const fired = firedPointer !== null;
2654
+ const activationInstallations = active !== null && activeInstallations.length > 0 ? activeInstallations : installations;
2152
2655
  checks.push({
2153
2656
  name: 'hooks (fired)',
2154
2657
  ok: fired,
2155
2658
  detail: fired
2156
2659
  ? 'a session in this directory has run them'
2157
- : `no session pointer from the last 24 hours — ${hookActivationAdvice(installations)}`,
2660
+ : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
2158
2661
  });
2159
2662
  if (installations.some((installation) => installation.harness === 'opencode')) {
2160
2663
  checks.push({
2161
2664
  name: 'hooks (opencode continuation)',
2162
2665
  ok: false,
2163
- detail: 'question routing is installed, but OpenCode cannot reliably resume an idle agent turn with the answer; use `notifai send --reply` for decisions',
2666
+ detail: 'question routing is installed; OpenCode cannot resume an idle turn automatically, but its next user prompt collects the answer',
2164
2667
  });
2165
2668
  }
2166
2669
  const stray = codexStrayWorktreeCheck(deps);
@@ -2285,9 +2788,18 @@ export function realIo(env = process.env) {
2285
2788
  log.error(message);
2286
2789
  },
2287
2790
  openUrl: (url) => {
2288
- const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
2289
2791
  try {
2290
- spawn(command, [url], { stdio: 'ignore', detached: true }).unref();
2792
+ if (process.platform === 'darwin') {
2793
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
2794
+ }
2795
+ else if (process.platform === 'win32') {
2796
+ // `start` is a cmd builtin; spawn('start', …) cannot work on Windows.
2797
+ // Empty title argument keeps URLs with special characters intact.
2798
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
2799
+ }
2800
+ else {
2801
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
2802
+ }
2291
2803
  }
2292
2804
  catch {
2293
2805
  // Browser opening is best-effort; the URL is printed anyway.