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