@raidiant/notifai 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -9,7 +9,7 @@ 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';
@@ -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) {
@@ -481,11 +601,11 @@ export async function statusCommand(deps, requestId, flags) {
481
601
  deps.io.out(` Provider Acceptance: ${d.state === 'provider_accepted' ? 'accepted' : 'not recorded'}`);
482
602
  if (d.companion_receipt.state === 'observed') {
483
603
  const latency = d.companion_receipt.latency_ms;
484
- deps.io.out(` Companion Receipt: observed at ${d.companion_receipt.observed_at}` +
604
+ deps.io.out(` Companion Receipt (the app's delivery confirmation): observed at ${d.companion_receipt.observed_at}` +
485
605
  (latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`));
486
606
  }
487
607
  else {
488
- deps.io.out(' Companion Receipt: unknown — not observed; this is not a failure or proof of non-receipt');
608
+ deps.io.out(" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt");
489
609
  }
490
610
  for (const e of d.events) {
491
611
  deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
@@ -569,7 +689,19 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
569
689
  }
570
690
  let envelope;
571
691
  try {
572
- envelope = parseHookInput(await readStdin());
692
+ const raw = await readStdin();
693
+ if (raw.trim() !== '') {
694
+ try {
695
+ const parsed = JSON.parse(raw);
696
+ if (typeof parsed !== 'object' || parsed === null)
697
+ throw new Error('not an object');
698
+ }
699
+ catch {
700
+ deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken');
701
+ return EXIT.ok;
702
+ }
703
+ }
704
+ envelope = parseHookInput(raw);
573
705
  if (harness === 'cursor') {
574
706
  const sessionId = envelope.session_id ?? envelope.conversation_id;
575
707
  const cwd = envelope.cwd ?? envelope.workspace_roots?.[0];
@@ -602,8 +734,10 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
602
734
  const cwd = envelope.cwd ?? deps.cwd;
603
735
  const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
604
736
  const credential = deps.store.load();
605
- if (!credential)
737
+ if (!credential) {
738
+ deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`');
606
739
  return EXIT.ok;
740
+ }
607
741
  // Pin authenticated traffic to the origin the credential was issued for. A
608
742
  // repository can commit `.notifai/config.toml`, and honouring a base_url
609
743
  // from it would hand this machine's bearer token to whatever host it names.
@@ -636,6 +770,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
636
770
  degraded: result.degraded,
637
771
  };
638
772
  },
773
+ ...(harness === undefined ? {} : { harness }),
639
774
  };
640
775
  // Real clock, deliberately, not `deps.now`. This compares against file
641
776
  // mtimes, which are wall-clock facts — handing it a virtual or skewed clock
@@ -713,17 +848,48 @@ function rejectedPaths(details) {
713
848
  * `require_idle = false` intentionally permits a push while the user is active.
714
849
  */
715
850
  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
851
+ // An agent calling this gets no hook payload. Harness-native environment
852
+ // markers identify the active owner, while the UserPromptSubmit hook leaves
853
+ // a pointer keyed on the project directory with the hook's canonical id.
854
+ // The pointer outranks the
719
855
  // NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
720
856
  // label rather than the harness's own id, and the hooks key state by the
721
857
  // latter — the env var is only trusted when no hook has spoken.
722
858
  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;
859
+ const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now);
860
+ const pointer = projectPointer?.sessionId ?? null;
861
+ const active = activeHarnessSession(deps.env);
862
+ let sessionId;
863
+ if (flags.session !== undefined) {
864
+ sessionId = flags.session;
865
+ }
866
+ else if (active !== null) {
867
+ const installations = findInstallations(deps.cwd, deps.env);
868
+ const activeInstalled = installations.some((installation) => installation.harness === active.harness);
869
+ if (!activeInstalled) {
870
+ for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
871
+ deps.io.err(line);
872
+ }
873
+ return EXIT.usage;
874
+ }
875
+ if (projectPointer === null) {
876
+ for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
877
+ deps.io.err(line);
878
+ }
879
+ return EXIT.usage;
880
+ }
881
+ if (projectPointer.harness !== active.harness ||
882
+ (active.sessionId !== undefined && projectPointer.sessionId !== active.sessionId)) {
883
+ for (const line of diagnoseActiveHarnessSession(active, 'different-session', installations)) {
884
+ deps.io.err(line);
885
+ }
886
+ return EXIT.usage;
887
+ }
888
+ sessionId = projectPointer.sessionId;
889
+ }
890
+ else {
891
+ sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined;
892
+ }
727
893
  if (!sessionId) {
728
894
  for (const line of diagnoseMissingSession(deps))
729
895
  deps.io.err(line);
@@ -762,6 +928,59 @@ export function askCommand(deps, question, flags) {
762
928
  deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.');
763
929
  return EXIT.ok;
764
930
  }
931
+ function activeHarnessSession(env) {
932
+ const explicit = env['NOTIFAI_ACTIVE_HARNESS'];
933
+ if (explicit === 'opencode') {
934
+ const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID'];
935
+ return {
936
+ harness: 'opencode',
937
+ label: 'OpenCode',
938
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
939
+ };
940
+ }
941
+ if (env['CLAUDECODE'] === '1') {
942
+ const sessionId = env['CLAUDE_CODE_SESSION_ID'];
943
+ return {
944
+ harness: 'claude-code',
945
+ label: 'Claude Code',
946
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
947
+ };
948
+ }
949
+ const codexSession = env['CODEX_THREAD_ID'];
950
+ if (codexSession !== undefined && codexSession !== '') {
951
+ return { harness: 'codex', label: 'Codex', sessionId: codexSession };
952
+ }
953
+ if ((env['CURSOR_AGENT'] ?? '') !== '')
954
+ return { harness: 'cursor', label: 'Cursor' };
955
+ return null;
956
+ }
957
+ function diagnoseActiveHarnessSession(active, problem, installations) {
958
+ if (problem === 'not-installed') {
959
+ const others = installations.map((installation) => installation.harness);
960
+ return [
961
+ `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
962
+ ...(others.length === 0
963
+ ? []
964
+ : [
965
+ `Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
966
+ ]),
967
+ `Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
968
+ `Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
969
+ ];
970
+ }
971
+ if (problem === 'different-session') {
972
+ return [
973
+ `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
974
+ `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
975
+ `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
976
+ ];
977
+ }
978
+ return [
979
+ `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
980
+ `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
981
+ `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
982
+ ];
983
+ }
765
984
  /**
766
985
  * Why `ask` cannot see a session, in terms of what to do about it.
767
986
  *
@@ -775,13 +994,13 @@ function diagnoseMissingSession(deps) {
775
994
  const installations = findInstallations(deps.cwd, deps.env);
776
995
  if (installations.length === 0) {
777
996
  return [
778
- 'Could not tell which harness session this is: no NotifAI hooks are installed for this project.',
997
+ 'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
779
998
  'Run `notifai hooks install`, then follow the activation instruction it prints.',
780
999
  ];
781
1000
  }
782
1001
  const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ');
783
1002
  return [
784
- `Could not tell which harness session this is. NotifAI hooks are installed (${where}),`,
1003
+ `Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
785
1004
  'but no usable session pointer from the last 24 hours exists here.',
786
1005
  hookActivationAdvice(installations),
787
1006
  'To ask from this session anyway, pass --session <id>.',
@@ -807,7 +1026,7 @@ function hookActivationAdvice(installations) {
807
1026
  advice.push('Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
808
1027
  }
809
1028
  if (harnesses.has('opencode')) {
810
- advice.push('OpenCode: restart it, then send one prompt; plugins load at startup');
1029
+ advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, and a device answer is delivered on the next prompt');
811
1030
  }
812
1031
  return `${advice.join('. ')}.`;
813
1032
  }
@@ -857,6 +1076,7 @@ export function hooksInstallCommand(deps, flags) {
857
1076
  scriptPath,
858
1077
  replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
859
1078
  graceSeconds: config.ask_grace_seconds.value,
1079
+ harness: 'cursor',
860
1080
  }), scriptPath);
861
1081
  try {
862
1082
  applyPlan(file, merged.document);
@@ -878,8 +1098,8 @@ export function hooksInstallCommand(deps, flags) {
878
1098
  deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
879
1099
  deps.io.out('start a new Cursor session and try one prompt again.');
880
1100
  }
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.');
1101
+ deps.io.out('A companion-device answer is submitted as one follow-up user message.');
1102
+ deps.io.out('loop_limit = 3 permits bounded chained questions without an unbounded loop.');
883
1103
  return EXIT.ok;
884
1104
  }
885
1105
  let document;
@@ -895,6 +1115,7 @@ export function hooksInstallCommand(deps, flags) {
895
1115
  scriptPath,
896
1116
  replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
897
1117
  graceSeconds: config.ask_grace_seconds.value,
1118
+ harness,
898
1119
  }), scriptPath);
899
1120
  try {
900
1121
  applyPlan(file, merged.document);
@@ -917,11 +1138,11 @@ export function hooksInstallCommand(deps, flags) {
917
1138
  `nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
918
1139
  `until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
919
1140
  `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.`
1141
+ `Run \`notifai config set require_idle false\` to be notified even while you are working.`
921
1142
  : `A question registered with \`notifai ask\` stays in the terminal for ` +
922
1143
  `${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
923
1144
  `whether or not you are at this machine ` +
924
- `(\`require_idle = false\`).`);
1145
+ `(\`notifai config set require_idle false\` is already in effect).`);
925
1146
  if (config.require_idle.value) {
926
1147
  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
1148
  }
@@ -957,7 +1178,7 @@ export function hooksInstallCommand(deps, flags) {
957
1178
  return EXIT.ok;
958
1179
  }
959
1180
  /**
960
- * Writes the OpenCode plugin, replacing any NotifAI plugin already there —
1181
+ * Writes the OpenCode plugin, replacing any Notifai plugin already there —
961
1182
  * including one a different checkout wrote, matched on the managed marker for
962
1183
  * the same reason command hooks are.
963
1184
  */
@@ -965,7 +1186,7 @@ function installOpencodePlugin(deps, file, options) {
965
1186
  if (existsSync(file)) {
966
1187
  const existing = readFileSync(file, 'utf8');
967
1188
  if (!isOurOpencodePlugin(existing)) {
968
- deps.io.err(`${file} exists and was not written by NotifAI; move it aside first.`);
1189
+ deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`);
969
1190
  return EXIT.failed;
970
1191
  }
971
1192
  }
@@ -982,8 +1203,9 @@ function installOpencodePlugin(deps, file, options) {
982
1203
  deps.io.out('It maps chat.message to presence, session.idle to question escalation, and');
983
1204
  deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands');
984
1205
  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.');
1206
+ deps.io.out('OpenCode pushes without holding its idle event open; the next user prompt');
1207
+ deps.io.out('collects a device answer and adds it to the agent context. Use `notifai');
1208
+ deps.io.out('send --reply` only when the answer must return before another prompt.');
987
1209
  deps.io.out('');
988
1210
  deps.io.out('Restart OpenCode: it loads plugins once at start.');
989
1211
  return EXIT.ok;
@@ -1001,11 +1223,11 @@ export function hooksUninstallCommand(deps, flags) {
1001
1223
  if (harness === 'opencode') {
1002
1224
  // We own the whole file, but only if we wrote it.
1003
1225
  if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
1004
- deps.io.out(`Left ${file} alone: NotifAI did not write it.`);
1226
+ deps.io.out(`Left ${file} alone: Notifai did not write it.`);
1005
1227
  return EXIT.ok;
1006
1228
  }
1007
1229
  rmSync(file, { force: true });
1008
- deps.io.out(`Removed the NotifAI OpenCode plugin at ${file}`);
1230
+ deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`);
1009
1231
  return EXIT.ok;
1010
1232
  }
1011
1233
  if (harness === 'cursor') {
@@ -1026,8 +1248,8 @@ export function hooksUninstallCommand(deps, flags) {
1026
1248
  return EXIT.failed;
1027
1249
  }
1028
1250
  deps.io.out(stripped.replaced.length > 0
1029
- ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1030
- : `No NotifAI hooks found in ${file}`);
1251
+ ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1252
+ : `No Notifai hooks found in ${file}`);
1031
1253
  return EXIT.ok;
1032
1254
  }
1033
1255
  let document;
@@ -1047,8 +1269,8 @@ export function hooksUninstallCommand(deps, flags) {
1047
1269
  return EXIT.failed;
1048
1270
  }
1049
1271
  deps.io.out(stripped.replaced.length > 0
1050
- ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1051
- : `No NotifAI hooks found in ${file}`);
1272
+ ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1273
+ : `No Notifai hooks found in ${file}`);
1052
1274
  return EXIT.ok;
1053
1275
  }
1054
1276
  function resolveHarness(deps, requested) {
@@ -1159,27 +1381,59 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1159
1381
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1160
1382
  * Keep this immutable and public because the command is printed to users.
1161
1383
  */
1162
- export const SKILLS_SOURCE = 'RafaelVidaurre/notifai#v0.1.7';
1163
- function pinnedSkillInstalled(cwd) {
1384
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.1.8';
1385
+ function skillSourceParts() {
1164
1386
  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;
1387
+ return match === null ? null : { source: match[1], ref: match[2] };
1388
+ }
1389
+ function expectedSkill(skill) {
1390
+ const expected = skillSourceParts();
1391
+ return (expected !== null &&
1392
+ skill.name === 'notifai' &&
1393
+ skill.source === expected.source &&
1394
+ skill.sourceType === 'github' &&
1395
+ skill.ref === expected.ref);
1396
+ }
1397
+ async function skillReadiness(deps, selectedScope) {
1398
+ const scopes = selectedScope === undefined ? ['project', 'global'] : [selectedScope];
1399
+ const results = await Promise.all(scopes.map(async (scope) => {
1400
+ if (deps.nativeSkills === undefined)
1401
+ return { scope, skills: [] };
1402
+ try {
1403
+ return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) };
1404
+ }
1405
+ catch (err) {
1406
+ return { scope, skills: [], error: String(err) };
1407
+ }
1408
+ }));
1409
+ const installed = results.flatMap(({ skills }) => skills).find(expectedSkill);
1410
+ if (installed !== undefined) {
1411
+ return {
1412
+ id: 'skill',
1413
+ title: 'Agent guidance skill',
1414
+ status: 'ready',
1415
+ detail: `installed from ${SKILLS_SOURCE} in the ${installed.scope} scope`,
1416
+ };
1182
1417
  }
1418
+ const errors = results
1419
+ .filter((result) => result.error !== undefined)
1420
+ .map((result) => `${result.scope}: ${result.error}`);
1421
+ const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`;
1422
+ return {
1423
+ id: 'skill',
1424
+ title: 'Agent guidance skill',
1425
+ status: 'optional-gap',
1426
+ detail: errors.length > 0
1427
+ ? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
1428
+ : `not installed from ${SKILLS_SOURCE} in ${scopeText}`,
1429
+ remedy: {
1430
+ by: 'cli',
1431
+ summary: 'install the skill agents follow when deciding to notify',
1432
+ command: selectedScope === undefined
1433
+ ? 'notifai init --skills'
1434
+ : `notifai init --skills --skills-scope ${selectedScope}`,
1435
+ },
1436
+ };
1183
1437
  }
1184
1438
  /** Derive a contract-valid project slug from a directory name. */
1185
1439
  export function projectSlugFrom(name) {
@@ -1190,10 +1444,36 @@ export function projectSlugFrom(name) {
1190
1444
  .slice(0, 64);
1191
1445
  return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project';
1192
1446
  }
1193
- const DEVICE_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
1447
+ /** Long enough for a first TestFlight install; keep-waiting extends another budget. */
1448
+ const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000;
1194
1449
  const DEVICE_BRIDGE_POLL_MS = 2_000;
1195
1450
  const PROOF_TIMEOUT_MS = 30_000;
1196
1451
  const PROOF_POLL_MS = 1_000;
1452
+ function supportPageUrl(baseUrl) {
1453
+ return `${baseUrl.replace(/\/$/, '')}/support`;
1454
+ }
1455
+ function formatWaitBudget(milliseconds) {
1456
+ const minutes = Math.round(milliseconds / 60_000);
1457
+ return minutes === 1 ? '1 minute' : `${minutes} minutes`;
1458
+ }
1459
+ /** Same-account line for the device hop; prefers the real email when known. */
1460
+ function sameAccountSignInLine(email) {
1461
+ return email
1462
+ ? `sign in with the same email as this account (${email})`
1463
+ : 'sign in with the same email as this account';
1464
+ }
1465
+ function deviceInstallRemedy(options) {
1466
+ const support = supportPageUrl(options.baseUrl);
1467
+ const sameEmail = sameAccountSignInLine(options.email);
1468
+ if (options.devices.length === 0) {
1469
+ return (`open the install steps at ${support} on that device (or your phone’s browser), ` +
1470
+ `install Notifai, ${sameEmail}, and allow notifications`);
1471
+ }
1472
+ if (options.devices.some((d) => d.permission_status === 'denied')) {
1473
+ return 'allow notifications for Notifai in that device’s system settings';
1474
+ }
1475
+ return 'open Notifai on that device and allow its notification prompt';
1476
+ }
1197
1477
  function setupProofPath(deps) {
1198
1478
  let projectDir = path.resolve(deps.cwd);
1199
1479
  try {
@@ -1268,18 +1548,23 @@ async function closeGap(deps, state, flags) {
1268
1548
  return hooksInstallCommand(deps, { harness }) === EXIT.ok ? 'closed' : 'failed';
1269
1549
  }
1270
1550
  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
- });
1551
+ if (deps.nativeSkills === undefined) {
1552
+ deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.');
1553
+ return 'failed';
1554
+ }
1555
+ const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`;
1556
+ deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`);
1557
+ const addOptions = {
1558
+ source: SKILLS_SOURCE,
1559
+ skill: 'notifai',
1560
+ cwd: deps.cwd,
1561
+ env: deps.env,
1562
+ ...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
1563
+ };
1564
+ const code = await deps.nativeSkills.add(addOptions);
1280
1565
  if (code !== 0) {
1281
1566
  deps.io.err('Skill installation failed — run it manually with:');
1282
- deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`);
1567
+ deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai${flags.skillsScope === 'global' ? ' --global' : ''}${flags.skillsScope === undefined ? '' : ' --yes'}`);
1283
1568
  }
1284
1569
  return code === 0 ? 'closed' : 'failed';
1285
1570
  }
@@ -1308,56 +1593,90 @@ function deviceBridgeMessage(devices) {
1308
1593
  }
1309
1594
  /**
1310
1595
  * 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.
1596
+ * app-side work. The live bridge is the dashboard `/support` page (TestFlight
1597
+ * steps). Interactive runs offer to open it so the user never types the URL;
1598
+ * non-interactive/agent paths only print plain text and never wait on a prompt.
1599
+ *
1600
+ * The wait budget is stated up front. On expiry we say the *timer* expired —
1601
+ * not the setup — and offer another budget when a human is present. Agents
1602
+ * never hang: this whole path is gated on `io.interactive`, and keep-waiting
1603
+ * uses `confirm` which resolves the safe default when not interactive.
1314
1604
  */
1315
1605
  async function waitForReadyDevice(deps, state) {
1316
1606
  const remedy = state.remedy;
1317
1607
  if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere')
1318
1608
  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
1609
  const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1324
1610
  const authed = authedClient(deps, config);
1325
- if (!authed)
1611
+ if (!authed) {
1612
+ deps.io.err('Could not start the companion-device wait: this machine is not signed in.');
1326
1613
  return 'failed';
1614
+ }
1615
+ const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS);
1616
+ const supportUrl = supportPageUrl(authed.baseUrl);
1617
+ await deps.io.note?.([
1618
+ state.detail,
1619
+ remedy.summary,
1620
+ `Install steps (no typing): ${supportUrl}`,
1621
+ `I will wait up to ${budgetLabel} for a companion device to become ready.`,
1622
+ ].join('\n'), 'Finish setup on your companion device');
1623
+ // Open the real support page so the user never has to type the URL. Decline
1624
+ // is fine — the URL remains in the note and in the Next: line if they leave.
1625
+ if (await deps.io.confirm('Open install instructions in your browser?', true)) {
1626
+ deps.io.openUrl(supportUrl);
1627
+ }
1628
+ if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
1629
+ deps.io.out(`OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`);
1630
+ return 'pending';
1631
+ }
1327
1632
  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…');
1633
+ const sleep = deps.sleep ??
1634
+ ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
1635
+ const spinner = await deps.io.spinner?.(`Waiting up to ${budgetLabel} for a companion device…`);
1331
1636
  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';
1637
+ let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
1638
+ for (;;) {
1639
+ while (now() < deadline) {
1640
+ try {
1641
+ const response = await authed.client.listDevices();
1642
+ lastDevices = response.devices;
1643
+ const ready = response.devices.find(deviceCanReceive);
1644
+ if (ready) {
1645
+ spinner?.stop(`${ready.display_name} is ready to receive`);
1646
+ return 'closed';
1647
+ }
1648
+ spinner?.message(deviceBridgeMessage(response.devices));
1340
1649
  }
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';
1650
+ catch (err) {
1651
+ if (!(err instanceof NetworkError)) {
1652
+ spinner?.error('Could not check companion readiness');
1653
+ reportError(deps, err);
1654
+ return 'failed';
1655
+ }
1656
+ spinner?.message('Connection lost — still watching…');
1348
1657
  }
1349
- spinner?.message('Connection lost — still watching…');
1658
+ await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())));
1350
1659
  }
1351
- await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())));
1660
+ // Timer expired — setup did not. Offer another budget only when a human
1661
+ // can answer; confirm() already returns the fallback for agents, so this
1662
+ // never hangs unattended even if interactive were mis-set.
1663
+ spinner?.error(`The ${budgetLabel} wait timer expired`);
1664
+ deps.io.err(`The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`);
1665
+ deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'));
1666
+ deps.io.err(`Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`);
1667
+ const keepWaiting = await deps.io.confirm(`Keep waiting for another ${budgetLabel}?`, false);
1668
+ if (!keepWaiting) {
1669
+ deps.io.out('Stopping the wait. Device setup can continue; re-run `notifai init` when ready.');
1670
+ return 'pending';
1671
+ }
1672
+ spinner?.message(`Waiting another ${budgetLabel} for a companion device…`);
1673
+ deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
1352
1674
  }
1353
- spinner?.error('No companion device became ready');
1354
- deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'));
1355
- return 'pending';
1356
1675
  }
1357
1676
  function setupProofDraft(config, device) {
1358
1677
  const project = config.project.value;
1359
1678
  return buildDraft(config, {
1360
- title: 'NotifAI is ready',
1679
+ title: 'Notifai is ready',
1361
1680
  body: project === null
1362
1681
  ? 'This real notification completed setup verification.'
1363
1682
  : `This real notification completed setup verification for ${project}.`,
@@ -1421,7 +1740,7 @@ async function runSetupProof(deps) {
1421
1740
  const existing = readSetupProof(deps);
1422
1741
  const target = candidates.find((device) => device.device_id === existing?.device_id && existing.project === config.project.value) ?? candidates[0];
1423
1742
  if (!target) {
1424
- deps.io.err('Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts.');
1743
+ 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
1744
  return 'pending';
1426
1745
  }
1427
1746
  let proof = existing?.device_id === target.device_id && existing.project === config.project.value
@@ -1452,7 +1771,7 @@ async function runSetupProof(deps) {
1452
1771
  const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
1453
1772
  const deadline = now() + PROOF_TIMEOUT_MS;
1454
1773
  const spinner = deps.io.interactive === true
1455
- ? await deps.io.spinner?.('Waiting for a Companion Receipt…')
1774
+ ? await deps.io.spinner?.("Waiting for a Companion Receipt (the app's delivery confirmation)…")
1456
1775
  : null;
1457
1776
  let lastError = null;
1458
1777
  let replacedMissingProof = false;
@@ -1502,11 +1821,11 @@ async function runSetupProof(deps) {
1502
1821
  break;
1503
1822
  await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())));
1504
1823
  }
1505
- spinner?.error('Companion Receipt not observed yet');
1824
+ spinner?.stop('Delivery confirmation not observed yet');
1506
1825
  if (lastError instanceof NetworkError)
1507
1826
  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.');
1827
+ 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. ` +
1828
+ 'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.');
1510
1829
  return 'pending';
1511
1830
  }
1512
1831
  /** Whether an optional gap should be closed, given flags and who is watching. */
@@ -1525,7 +1844,7 @@ function wantsOptional(deps, state, flags) {
1525
1844
  return Promise.resolve(false);
1526
1845
  const question = state.id === 'hooks'
1527
1846
  ? 'Install harness hooks, so questions reach your devices when you are away?'
1528
- : 'Install/update the agent guidance skill in this repo?';
1847
+ : 'Install/update the agent guidance skill through the native npx skills flow?';
1529
1848
  return deps.io.confirm(question, true);
1530
1849
  }
1531
1850
  /**
@@ -1545,8 +1864,25 @@ function wantsOptional(deps, state, flags) {
1545
1864
  * states rather than four branches to enumerate.
1546
1865
  */
1547
1866
  export async function initCommand(deps, flags) {
1548
- await deps.io.intro?.('NotifAI setup');
1549
- let readiness = await assessReadiness(deps);
1867
+ if (flags.skillsScope !== undefined &&
1868
+ flags.skillsScope !== 'project' &&
1869
+ flags.skillsScope !== 'global') {
1870
+ deps.io.err('Invalid skill scope. Choose `project` or `global`.');
1871
+ return EXIT.usage;
1872
+ }
1873
+ if (flags.skillsScope !== undefined && flags.skills !== true) {
1874
+ deps.io.err('`--skills-scope` requires `--skills`. Choose project or global in the native installer.');
1875
+ return EXIT.usage;
1876
+ }
1877
+ if (flags.skills === true &&
1878
+ deps.io.interactive !== true &&
1879
+ flags.skillsScope === undefined) {
1880
+ deps.io.err('Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.');
1881
+ return EXIT.usage;
1882
+ }
1883
+ await deps.io.intro?.('Notifai setup');
1884
+ const reassess = () => assessReadiness(deps, flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope });
1885
+ let readiness = await reassess();
1550
1886
  let failed = false;
1551
1887
  const attempted = new Set();
1552
1888
  // Re-assess after every successful action. This is how a browser approval or
@@ -1577,11 +1913,16 @@ export async function initCommand(deps, flags) {
1577
1913
  const result = await closeGap(deps, state, flags);
1578
1914
  if (result === 'failed')
1579
1915
  failed = true;
1916
+ if (result === 'failed' && state.status === 'optional-gap') {
1917
+ readiness = await reassess();
1918
+ advanced = true;
1919
+ break;
1920
+ }
1580
1921
  if (result !== 'closed') {
1581
1922
  stop = true;
1582
1923
  break;
1583
1924
  }
1584
- readiness = await assessReadiness(deps);
1925
+ readiness = await reassess();
1585
1926
  advanced = true;
1586
1927
  break;
1587
1928
  }
@@ -1594,27 +1935,23 @@ export async function initCommand(deps, flags) {
1594
1935
  stop = true;
1595
1936
  break;
1596
1937
  }
1597
- readiness = await assessReadiness(deps);
1938
+ readiness = await reassess();
1598
1939
  advanced = true;
1599
1940
  break;
1600
1941
  }
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.
1942
+ // Its to launch, theirs to complete. Running `init` is the consent; announce
1943
+ // and open rather than re-asking. An agent never reaches this path.
1604
1944
  if (remedy.by === 'user-here' &&
1605
1945
  remedy.interactive === true &&
1606
1946
  deps.io.interactive === true) {
1607
1947
  attempted.add(state.id);
1608
- if (!(await deps.io.confirm('Sign in now? (opens your browser)', true))) {
1609
- stop = true;
1610
- break;
1611
- }
1948
+ deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.');
1612
1949
  if ((await loginCommand(deps, {})) !== EXIT.ok) {
1613
1950
  failed = true;
1614
1951
  stop = true;
1615
1952
  break;
1616
1953
  }
1617
- readiness = await assessReadiness(deps);
1954
+ readiness = await reassess();
1618
1955
  advanced = true;
1619
1956
  break;
1620
1957
  }
@@ -1629,7 +1966,7 @@ export async function initCommand(deps, flags) {
1629
1966
  stop = true;
1630
1967
  break;
1631
1968
  }
1632
- readiness = await assessReadiness(deps);
1969
+ readiness = await reassess();
1633
1970
  advanced = true;
1634
1971
  break;
1635
1972
  }
@@ -1640,13 +1977,13 @@ export async function initCommand(deps, flags) {
1640
1977
  if (stop || !advanced)
1641
1978
  break;
1642
1979
  }
1643
- readiness = await assessReadiness(deps);
1980
+ readiness = await reassess();
1644
1981
  for (const state of readiness.states.filter((s) => s.status === 'ready')) {
1645
1982
  deps.io.out(`${state.title}: ${state.detail}`);
1646
1983
  }
1647
1984
  const blocker = firstBlocker(readiness);
1648
1985
  if (blocker === null) {
1649
- const skipped = openItems(readiness);
1986
+ const skipped = openItems(readiness).filter((state) => !(state.id === 'skill' && flags.skills === false));
1650
1987
  for (const state of skipped)
1651
1988
  deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
1652
1989
  deps.io.out('All set. Agents in this project can notify you and ask questions.');
@@ -1659,7 +1996,9 @@ export async function initCommand(deps, flags) {
1659
1996
  deps.io.out('');
1660
1997
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
1661
1998
  deps.io.out(` ${remedyLine(blocker)}`);
1662
- if (blocker.remedy?.by === 'user-elsewhere') {
1999
+ // Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
2000
+ // how the rest is recovered either way.
2001
+ if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
1663
2002
  deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
1664
2003
  }
1665
2004
  await deps.io.outro?.('One step remains (above)');
@@ -1696,9 +2035,8 @@ async function contractCheck(client) {
1696
2035
  name: 'contract',
1697
2036
  ok: false,
1698
2037
  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`,
2038
+ ? `server speaks schema v${remote}, this CLI speaks v${local} — the service is being updated; try again later`
2039
+ : `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI: npm install -g @raidiant/notifai`,
1702
2040
  };
1703
2041
  }
1704
2042
  catch (err) {
@@ -1718,7 +2056,7 @@ async function contractCheck(client) {
1718
2056
  * `unknown`, which is the honest answer and keeps a network outage from
1719
2057
  * looking like a broken install.
1720
2058
  */
1721
- export async function assessReadiness(deps) {
2059
+ export async function assessReadiness(deps, options = {}) {
1722
2060
  const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1723
2061
  const states = [];
1724
2062
  let accountClient = null;
@@ -1815,56 +2153,37 @@ export async function assessReadiness(deps) {
1815
2153
  },
1816
2154
  });
1817
2155
  }
2156
+ let accountEmail = null;
2157
+ let accountLookupFailed = false;
1818
2158
  if (!credential || !reachable) {
1819
2159
  const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
1820
2160
  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
2161
  }
1823
2162
  else {
1824
2163
  const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`);
1825
2164
  accountClient = client;
1826
2165
  try {
1827
- const { devices } = await client.listDevices();
2166
+ const [{ devices }, email] = await Promise.all([
2167
+ client.listDevices(),
2168
+ Promise.resolve()
2169
+ .then(async () => (await client.accessStatus()).email)
2170
+ .catch(() => null),
2171
+ ]);
1828
2172
  accountDevices = devices;
1829
- const ready = devices.filter(deviceCanReceive);
2173
+ accountEmail = email;
1830
2174
  states.push({
1831
2175
  id: 'auth',
1832
2176
  title: 'Account',
1833
2177
  status: 'ready',
1834
- detail: `machine ${credential.machineId} accepted`,
2178
+ detail: accountEmail
2179
+ ? `machine ${credential.machineId} accepted (${accountEmail})`
2180
+ : `machine ${credential.machineId} accepted`,
1835
2181
  });
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
2182
  }
1865
2183
  catch (err) {
1866
2184
  // A credential the server rejects is revocation, not absence, and the
1867
2185
  // remedy is the same sign-in either way.
2186
+ accountLookupFailed = true;
1868
2187
  states.push({
1869
2188
  id: 'auth',
1870
2189
  title: 'Account',
@@ -1876,28 +2195,53 @@ export async function assessReadiness(deps) {
1876
2195
  command: 'notifai login',
1877
2196
  },
1878
2197
  });
1879
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' });
1880
2198
  }
1881
2199
  }
2200
+ // Optional setup that works without a companion device must appear before the
2201
+ // device gap: init stops at the first user-elsewhere blocker, and hooks/skill
2202
+ // are reachable without a phone.
1882
2203
  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
- });
2204
+ states.push(await skillReadiness(deps, options.skillScope));
2205
+ if (!credential || !reachable) {
2206
+ const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
2207
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` });
2208
+ }
2209
+ else if (accountLookupFailed || accountDevices === null) {
2210
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' });
2211
+ }
2212
+ else {
2213
+ const devices = accountDevices;
2214
+ const ready = devices.filter(deviceCanReceive);
2215
+ states.push(ready.length > 0
2216
+ ? {
2217
+ id: 'devices',
2218
+ title: 'Your devices',
2219
+ status: 'ready',
2220
+ detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
2221
+ }
2222
+ : {
2223
+ id: 'devices',
2224
+ title: 'Your devices',
2225
+ status: 'gap',
2226
+ // The one gap that cannot be closed from this terminal, and the
2227
+ // likeliest place a first setup is abandoned. Naming which of
2228
+ // the three sub-states it is matters: "install the app" is
2229
+ // useless advice to someone who installed it and denied the
2230
+ // permission prompt. The live bridge is /support on the
2231
+ // dashboard origin — not a placeholder, and not typed by hand.
2232
+ detail: devices.length === 0
2233
+ ? `nothing registered yet; install Notifai on iPhone or Mac via ${supportPageUrl(baseUrl)}`
2234
+ : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
2235
+ remedy: {
2236
+ by: 'user-elsewhere',
2237
+ summary: deviceInstallRemedy({
2238
+ baseUrl,
2239
+ email: accountEmail,
2240
+ devices,
2241
+ }),
2242
+ },
2243
+ });
2244
+ }
1901
2245
  states.push(await setupProofState(deps, config, accountClient, accountDevices));
1902
2246
  return { states };
1903
2247
  }
@@ -1921,14 +2265,17 @@ async function setupProofState(deps, config, client, devices) {
1921
2265
  }
1922
2266
  const ios = readyIosDevices(devices);
1923
2267
  if (ios.length === 0) {
2268
+ // Honest non-blocking caveat: notifications can reach the Mac; only the
2269
+ // receipt proof path is unavailable in this release. Never emit a Next:
2270
+ // step the user cannot satisfy, and never claim unobserved proof.
1924
2271
  return {
1925
2272
  id: 'proof',
1926
2273
  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',
2274
+ status: 'optional-gap',
2275
+ 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
2276
  remedy: {
1930
2277
  by: 'user-elsewhere',
1931
- summary: 'pair a receipt-capable iPhone build; no supported macOS receipt bridge is available in this release',
2278
+ summary: 'receipt proof needs an iPhone in this release (notifications still reach this Mac)',
1932
2279
  },
1933
2280
  };
1934
2281
  }
@@ -1939,10 +2286,10 @@ async function setupProofState(deps, config, client, devices) {
1939
2286
  id: 'proof',
1940
2287
  title: 'Delivery proof',
1941
2288
  status: 'gap',
1942
- detail: 'no Companion Receipt has proven this project on this machine yet',
2289
+ detail: "no Companion Receipt (the app's delivery confirmation) has proven this project on this machine yet",
1943
2290
  remedy: {
1944
2291
  by: 'cli',
1945
- summary: 'send one real verification notification and wait for its Companion Receipt',
2292
+ summary: "send one real verification notification and wait for its Companion Receipt (the app's delivery confirmation)",
1946
2293
  command: 'notifai init',
1947
2294
  },
1948
2295
  };
@@ -1955,14 +2302,14 @@ async function setupProofState(deps, config, client, devices) {
1955
2302
  id: 'proof',
1956
2303
  title: 'Delivery proof',
1957
2304
  status: 'ready',
1958
- detail: `Companion Receipt observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
2305
+ detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
1959
2306
  };
1960
2307
  }
1961
2308
  return {
1962
2309
  id: 'proof',
1963
2310
  title: 'Delivery proof',
1964
2311
  status: 'gap',
1965
- detail: `${proof.request_id} was sent, but its Companion Receipt is still unknown`,
2312
+ detail: `${proof.request_id} was sent, but its Companion Receipt (the app's delivery confirmation) is still unknown`,
1966
2313
  remedy: {
1967
2314
  by: 'cli',
1968
2315
  summary: 'check the same verification notification again',
@@ -1989,7 +2336,7 @@ export async function doctorCommand(deps, flags) {
1989
2336
  const blocker = firstBlocker(readiness);
1990
2337
  const ok = blocker === null;
1991
2338
  if (flags.json) {
1992
- deps.io.out(JSON.stringify({ ok, states: readiness.states }, null, 2));
2339
+ deps.io.out(JSON.stringify({ ok, exit_code: ok ? EXIT.ok : EXIT.failed, states: readiness.states }, null, 2));
1993
2340
  return ok ? EXIT.ok : EXIT.failed;
1994
2341
  }
1995
2342
  const line = (s) => `${s.title}: ${s.detail}`;
@@ -1998,14 +2345,20 @@ export async function doctorCommand(deps, flags) {
1998
2345
  // problems in dependency order has an obvious first move and saying so
1999
2346
  // costs nothing.
2000
2347
  if (deps.io.interactive === true && deps.io.check) {
2001
- await deps.io.intro?.('NotifAI doctor');
2348
+ await deps.io.intro?.('Notifai doctor');
2002
2349
  for (const s of readiness.states)
2003
2350
  await deps.io.check(s.status !== 'gap', line(s));
2004
2351
  await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`);
2005
2352
  }
2006
2353
  else {
2007
2354
  for (const s of readiness.states) {
2008
- const mark = s.status === 'gap' ? 'FAIL' : s.status === 'unknown' ? ' ? ' : 'ok ';
2355
+ const mark = s.status === 'gap'
2356
+ ? 'FAIL'
2357
+ : s.status === 'unknown'
2358
+ ? ' ? '
2359
+ : s.status === 'optional-gap'
2360
+ ? ' --'
2361
+ : 'ok ';
2009
2362
  deps.io.out(`${mark} ${line(s)}`);
2010
2363
  }
2011
2364
  if (!ok)
@@ -2049,38 +2402,64 @@ function remedyLine(state) {
2049
2402
  */
2050
2403
  function hookStates(deps) {
2051
2404
  const installations = findInstallations(deps.cwd, deps.env);
2405
+ const active = activeHarnessSession(deps.env);
2406
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
2407
+ const settings = {
2408
+ id: 'question-routing-settings',
2409
+ title: 'Question routing settings',
2410
+ status: 'ready',
2411
+ detail: [
2412
+ `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
2413
+ `require_idle=${config.require_idle.value} (${config.require_idle.source})`,
2414
+ `away_after_seconds=${config.away_after_seconds.value} (${config.away_after_seconds.source})`,
2415
+ `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
2416
+ `hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
2417
+ ].join(', '),
2418
+ };
2052
2419
  if (installations.length === 0) {
2053
2420
  return [
2054
2421
  {
2055
2422
  id: 'hooks',
2056
2423
  title: 'Question routing',
2057
- status: 'optional-gap',
2058
- detail: 'hooks not installed, so questions stay in the terminal',
2424
+ status: active === null ? 'optional-gap' : 'gap',
2425
+ detail: active === null
2426
+ ? 'hooks not installed, so questions stay in the terminal'
2427
+ : `active ${active.label} session detected, but ${active.label} hooks are not installed; \`notifai ask\` cannot route this session`,
2059
2428
  remedy: {
2060
2429
  by: 'cli',
2061
2430
  summary: 'install harness hooks so questions reach your devices when you are away',
2062
- command: 'notifai hooks install',
2431
+ command: active === null
2432
+ ? 'notifai hooks install'
2433
+ : `notifai hooks install --harness ${active.harness}`,
2063
2434
  },
2064
2435
  },
2436
+ settings,
2065
2437
  ];
2066
2438
  }
2067
2439
  /** 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
- }));
2440
+ const informational = new Set(['hooks (opencode continuation)']);
2441
+ if (active === null)
2442
+ informational.add('hooks (fired)');
2443
+ return [
2444
+ ...hookChecks(deps).map((check) => ({
2445
+ id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
2446
+ title: check.name === 'hooks' || check.name.startsWith('hooks (active')
2447
+ ? 'Question routing'
2448
+ : check.name,
2449
+ status: check.ok ? 'ready' : informational.has(check.name) ? 'optional-gap' : 'gap',
2450
+ detail: check.detail,
2451
+ ...(check.ok
2452
+ ? {}
2453
+ : {
2454
+ remedy: {
2455
+ by: 'user-here',
2456
+ summary: 'the detail above names what to change',
2457
+ command: 'notifai hooks install',
2458
+ },
2459
+ }),
2460
+ })),
2461
+ settings,
2462
+ ];
2084
2463
  }
2085
2464
  function hookChecks(deps) {
2086
2465
  const checks = [];
@@ -2102,6 +2481,34 @@ function hookChecks(deps) {
2102
2481
  .map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
2103
2482
  .join(', '),
2104
2483
  });
2484
+ const active = activeHarnessSession(deps.env);
2485
+ const activeInstallations = active === null
2486
+ ? []
2487
+ : installations.filter((installation) => installation.harness === active.harness);
2488
+ if (active !== null) {
2489
+ checks.push({
2490
+ name: 'hooks (active harness)',
2491
+ ok: activeInstallations.length > 0,
2492
+ detail: activeInstallations.length > 0
2493
+ ? `active ${active.label} session has a matching hook installation`
2494
+ : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
2495
+ });
2496
+ if (activeInstallations.length > 0) {
2497
+ const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)());
2498
+ const matches = pointer !== null &&
2499
+ pointer.harness === active.harness &&
2500
+ (active.sessionId === undefined || pointer.sessionId === active.sessionId);
2501
+ checks.push({
2502
+ name: 'hooks (active session)',
2503
+ ok: matches,
2504
+ detail: pointer === null
2505
+ ? `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`
2506
+ : matches
2507
+ ? `the project pointer belongs to the active ${active.label} session`
2508
+ : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
2509
+ });
2510
+ }
2511
+ }
2105
2512
  // A handler naming an event this build dropped exits 2 every time the harness
2106
2513
  // fires it, which the harness reports as a hook failure.
2107
2514
  const stale = installations.flatMap((i) => i.handlers
@@ -2125,6 +2532,19 @@ function hookChecks(deps) {
2125
2532
  detail: adapterProblems.join('; '),
2126
2533
  });
2127
2534
  }
2535
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
2536
+ const requiredTimeout = blockingHookTimeoutSeconds(config.ask_grace_seconds.value, config.hook_reply_timeout_seconds.value);
2537
+ const shortTimeouts = installations.flatMap((installation) => installation.handlers
2538
+ .filter((handler) => handlerEvent(handler.command) === 'stop')
2539
+ .filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
2540
+ .map((handler) => `${installation.harness} declares ${handler.timeout ?? 'no'}s, needs ${requiredTimeout}s — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
2541
+ checks.push({
2542
+ name: 'hooks (timeout)',
2543
+ ok: shortTimeouts.length === 0,
2544
+ detail: shortTimeouts.length === 0
2545
+ ? `installed Stop timeouts cover the current ${requiredTimeout}s grace/reply budget`
2546
+ : shortTimeouts.join('; '),
2547
+ });
2128
2548
  // Two checkouts each installing hooks means both fire for the same event, and
2129
2549
  // the user gets every question twice.
2130
2550
  //
@@ -2144,23 +2564,25 @@ function hookChecks(deps) {
2144
2564
  name: 'hooks (duplicates)',
2145
2565
  ok: false,
2146
2566
  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(', ')}`)
2567
+ .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
2568
  .join('; '),
2149
2569
  });
2150
2570
  }
2151
- const fired = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)()) !== null;
2571
+ const firedPointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)());
2572
+ const fired = firedPointer !== null;
2573
+ const activationInstallations = active !== null && activeInstallations.length > 0 ? activeInstallations : installations;
2152
2574
  checks.push({
2153
2575
  name: 'hooks (fired)',
2154
2576
  ok: fired,
2155
2577
  detail: fired
2156
2578
  ? 'a session in this directory has run them'
2157
- : `no session pointer from the last 24 hours — ${hookActivationAdvice(installations)}`,
2579
+ : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
2158
2580
  });
2159
2581
  if (installations.some((installation) => installation.harness === 'opencode')) {
2160
2582
  checks.push({
2161
2583
  name: 'hooks (opencode continuation)',
2162
2584
  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',
2585
+ detail: 'question routing is installed; OpenCode cannot resume an idle turn automatically, but its next user prompt collects the answer',
2164
2586
  });
2165
2587
  }
2166
2588
  const stray = codexStrayWorktreeCheck(deps);
@@ -2285,9 +2707,18 @@ export function realIo(env = process.env) {
2285
2707
  log.error(message);
2286
2708
  },
2287
2709
  openUrl: (url) => {
2288
- const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
2289
2710
  try {
2290
- spawn(command, [url], { stdio: 'ignore', detached: true }).unref();
2711
+ if (process.platform === 'darwin') {
2712
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
2713
+ }
2714
+ else if (process.platform === 'win32') {
2715
+ // `start` is a cmd builtin; spawn('start', …) cannot work on Windows.
2716
+ // Empty title argument keeps URLs with special characters intact.
2717
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
2718
+ }
2719
+ else {
2720
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
2721
+ }
2291
2722
  }
2292
2723
  catch {
2293
2724
  // Browser opening is best-effort; the URL is printed anyway.