@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/README.md +9 -2
- package/dist/atomic-file.d.ts +12 -0
- package/dist/atomic-file.js +55 -0
- package/dist/atomic-file.js.map +1 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +17 -4
- package/dist/commands.js +643 -212
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +20 -7
- package/dist/hooks.js +472 -149
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +8 -4
- package/dist/install-hooks.js +45 -64
- package/dist/install-hooks.js.map +1 -1
- package/dist/main.js +45 -33
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +31 -0
- package/dist/native-skills.js +117 -0
- package/dist/native-skills.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -0
- package/dist/opencode-plugin.js +37 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +0 -3
- package/dist/send.js +28 -13
- package/dist/send.js.map +1 -1
- package/package.json +12 -12
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +932 -63
- package/src/commands.ts +780 -225
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +398 -5
- package/src/hooks.ts +557 -161
- package/src/install-hooks.test.ts +36 -1
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +54 -37
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +32 -12
- package/src/skill.test.ts +43 -0
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?.('
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
717
|
-
//
|
|
718
|
-
//
|
|
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
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
|
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.
|
|
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('
|
|
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
|
-
`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
986
|
-
deps.io.out('
|
|
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:
|
|
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
|
|
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
|
|
1030
|
-
: `No
|
|
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
|
|
1051
|
-
: `No
|
|
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 = '
|
|
1163
|
-
function
|
|
1384
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.1.8';
|
|
1385
|
+
function skillSourceParts() {
|
|
1164
1386
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
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.
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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.
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
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 ??
|
|
1329
|
-
|
|
1330
|
-
const spinner = await deps.io.spinner?.(
|
|
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
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
-
|
|
1658
|
+
await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())));
|
|
1350
1659
|
}
|
|
1351
|
-
|
|
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: '
|
|
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(
|
|
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?.(
|
|
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?.
|
|
1824
|
+
spinner?.stop('Delivery confirmation not observed yet');
|
|
1506
1825
|
if (lastError instanceof NetworkError)
|
|
1507
1826
|
deps.io.err(lastError.message);
|
|
1508
|
-
deps.io.
|
|
1509
|
-
'
|
|
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
|
|
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
|
-
|
|
1549
|
-
|
|
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
|
|
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
|
|
1938
|
+
readiness = await reassess();
|
|
1598
1939
|
advanced = true;
|
|
1599
1940
|
break;
|
|
1600
1941
|
}
|
|
1601
|
-
// Its to launch, theirs to complete.
|
|
1602
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1700
|
-
|
|
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
|
|
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
|
-
|
|
2173
|
+
accountEmail = email;
|
|
1830
2174
|
states.push({
|
|
1831
2175
|
id: 'auth',
|
|
1832
2176
|
title: 'Account',
|
|
1833
2177
|
status: 'ready',
|
|
1834
|
-
detail:
|
|
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(
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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:
|
|
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: '
|
|
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:
|
|
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:
|
|
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?.('
|
|
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'
|
|
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:
|
|
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:
|
|
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 (
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|