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