@raidiant/notifai 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/atomic-file.d.ts +12 -0
- package/dist/atomic-file.js +55 -0
- package/dist/atomic-file.js.map +1 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +17 -4
- package/dist/commands.js +643 -212
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +20 -7
- package/dist/hooks.js +472 -149
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +8 -4
- package/dist/install-hooks.js +45 -64
- package/dist/install-hooks.js.map +1 -1
- package/dist/main.js +45 -33
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +31 -0
- package/dist/native-skills.js +117 -0
- package/dist/native-skills.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -0
- package/dist/opencode-plugin.js +37 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +0 -3
- package/dist/send.js +28 -13
- package/dist/send.js.map +1 -1
- package/package.json +12 -12
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +932 -63
- package/src/commands.ts +780 -225
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +398 -5
- package/src/hooks.ts +557 -161
- package/src/install-hooks.test.ts +36 -1
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +54 -37
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +32 -12
- package/src/skill.test.ts +43 -0
package/src/commands.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
REPLY_MAX_WINDOW_SECONDS,
|
|
10
10
|
validateDraft,
|
|
11
11
|
type EvidenceSnapshot,
|
|
12
|
+
type AccountAccessResponse,
|
|
12
13
|
type ListRepliesResponse,
|
|
13
14
|
type Platform,
|
|
14
15
|
type ReplyView,
|
|
@@ -46,9 +47,12 @@ import {
|
|
|
46
47
|
parseHookInput,
|
|
47
48
|
pruneAbandonedSessions,
|
|
48
49
|
readProjectSession,
|
|
50
|
+
readProjectSessionPointer,
|
|
51
|
+
readSessionState,
|
|
49
52
|
registerQuestion,
|
|
50
53
|
type HookContext,
|
|
51
54
|
type HookEnvelope,
|
|
55
|
+
type HookHarness,
|
|
52
56
|
} from './hooks.js'
|
|
53
57
|
import { readIdleSeconds } from './idle.js'
|
|
54
58
|
import {
|
|
@@ -85,6 +89,7 @@ import {
|
|
|
85
89
|
receiptExitCode,
|
|
86
90
|
type SendFlags,
|
|
87
91
|
} from './send.js'
|
|
92
|
+
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
88
93
|
|
|
89
94
|
export interface CommandIo {
|
|
90
95
|
out(line: string): void
|
|
@@ -127,6 +132,8 @@ export interface CommandDeps {
|
|
|
127
132
|
sleep?: (milliseconds: number) => Promise<void>
|
|
128
133
|
/** Test seam for the OS idle probe; production shells out to the platform. */
|
|
129
134
|
idleSeconds?: () => number | null
|
|
135
|
+
/** Test seam and production adapter for the external native skills installer. */
|
|
136
|
+
nativeSkills?: NativeSkills
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
export const EXIT = {
|
|
@@ -207,7 +214,7 @@ export async function loginCommand(
|
|
|
207
214
|
|
|
208
215
|
const interactive = deps.io.interactive === true
|
|
209
216
|
if (interactive) {
|
|
210
|
-
await deps.io.intro?.('
|
|
217
|
+
await deps.io.intro?.('Notifai sign in')
|
|
211
218
|
await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine')
|
|
212
219
|
} else {
|
|
213
220
|
deps.io.out(`Pairing code: ${begin.code}`)
|
|
@@ -220,7 +227,15 @@ export async function loginCommand(
|
|
|
220
227
|
const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000
|
|
221
228
|
const now = deps.now ?? Date.now
|
|
222
229
|
const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
|
|
223
|
-
const
|
|
230
|
+
const approvalWaitMessage = (): string => {
|
|
231
|
+
const remainingSec = Math.max(0, Math.ceil((expiresAt - now()) / 1000))
|
|
232
|
+
const minutes = Math.floor(remainingSec / 60)
|
|
233
|
+
const seconds = remainingSec % 60
|
|
234
|
+
const remaining =
|
|
235
|
+
minutes > 0 ? `${minutes}m ${seconds.toString().padStart(2, '0')}s` : `${seconds}s`
|
|
236
|
+
return `Waiting for approval… code ${begin.code} · ${remaining} left`
|
|
237
|
+
}
|
|
238
|
+
const spinner = interactive ? await deps.io.spinner?.(approvalWaitMessage()) : null
|
|
224
239
|
while (now() < expiresAt) {
|
|
225
240
|
await sleep(intervalMs)
|
|
226
241
|
let poll
|
|
@@ -228,7 +243,7 @@ export async function loginCommand(
|
|
|
228
243
|
poll = await client.pollPairing(begin.pairing_id, pollVerifier)
|
|
229
244
|
} catch (err) {
|
|
230
245
|
if (err instanceof NetworkError) {
|
|
231
|
-
spinner?.message(
|
|
246
|
+
spinner?.message(`Connection lost — retrying… code ${begin.code}`)
|
|
232
247
|
continue
|
|
233
248
|
}
|
|
234
249
|
spinner?.error('Pairing failed')
|
|
@@ -249,8 +264,18 @@ export async function loginCommand(
|
|
|
249
264
|
deps.io.err('Pairing was denied from the dashboard.')
|
|
250
265
|
return EXIT.auth
|
|
251
266
|
}
|
|
267
|
+
if (poll.status === 'no_active_plan') {
|
|
268
|
+
const next =
|
|
269
|
+
poll.next_action ??
|
|
270
|
+
`Open ${baseUrl.replace(/\/$/, '')}/support to request Alpha access, then retry.`
|
|
271
|
+
spinner?.error('Account has no Alpha access')
|
|
272
|
+
deps.io.err('This account has no active plan or temporary Alpha access.')
|
|
273
|
+
deps.io.err(`next: ${next}`)
|
|
274
|
+
deps.io.err('After access is granted, run `notifai login` again.')
|
|
275
|
+
return EXIT.auth
|
|
276
|
+
}
|
|
252
277
|
if (poll.status === 'expired') break
|
|
253
|
-
spinner?.message(
|
|
278
|
+
spinner?.message(approvalWaitMessage())
|
|
254
279
|
}
|
|
255
280
|
spinner?.error('Pairing expired')
|
|
256
281
|
deps.io.err('Pairing expired before it was approved. Run `notifai login` again.')
|
|
@@ -293,6 +318,34 @@ export function authStatusCommand(deps: CommandDeps, flags: { json?: boolean }):
|
|
|
293
318
|
return EXIT.ok
|
|
294
319
|
}
|
|
295
320
|
|
|
321
|
+
/** Show the server's account access decision without attempting a product mutation. */
|
|
322
|
+
export async function accessStatusCommand(
|
|
323
|
+
deps: CommandDeps,
|
|
324
|
+
flags: { json?: boolean },
|
|
325
|
+
): Promise<number> {
|
|
326
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
327
|
+
const authed = authedClient(deps, config)
|
|
328
|
+
if (!authed) return EXIT.auth
|
|
329
|
+
try {
|
|
330
|
+
const access: AccountAccessResponse = await authed.client.accessStatus()
|
|
331
|
+
if (flags.json) {
|
|
332
|
+
deps.io.out(JSON.stringify(access, null, 2))
|
|
333
|
+
return access.status === 'active' ? EXIT.ok : EXIT.failed
|
|
334
|
+
}
|
|
335
|
+
if (access.status === 'no_active_plan') {
|
|
336
|
+
const supportUrl = `${authed.baseUrl.replace(/\/$/, '')}/support`
|
|
337
|
+
deps.io.out('No active plan or temporary Alpha access for this account.')
|
|
338
|
+
deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`)
|
|
339
|
+
return EXIT.failed
|
|
340
|
+
}
|
|
341
|
+
const expiry = access.expires_at ? ` until ${access.expires_at}` : ''
|
|
342
|
+
deps.io.out(`Access active (${access.reason})${expiry}`)
|
|
343
|
+
return EXIT.ok
|
|
344
|
+
} catch (err) {
|
|
345
|
+
return reportError(deps, err)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
296
349
|
// ---------------------------------------------------------------------------
|
|
297
350
|
// devices / capabilities
|
|
298
351
|
// ---------------------------------------------------------------------------
|
|
@@ -308,7 +361,16 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
|
|
|
308
361
|
return EXIT.ok
|
|
309
362
|
}
|
|
310
363
|
if (result.devices.length === 0) {
|
|
311
|
-
|
|
364
|
+
const supportUrl = supportPageUrl(authed.baseUrl)
|
|
365
|
+
let email: string | null = null
|
|
366
|
+
try {
|
|
367
|
+
email = (await authed.client.accessStatus()).email
|
|
368
|
+
} catch {
|
|
369
|
+
// Best-effort: the empty-state copy still points at /support without it.
|
|
370
|
+
}
|
|
371
|
+
deps.io.out(
|
|
372
|
+
`No devices yet. Install Notifai from ${supportUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`,
|
|
373
|
+
)
|
|
312
374
|
return EXIT.ok
|
|
313
375
|
}
|
|
314
376
|
for (const d of result.devices) {
|
|
@@ -365,6 +427,10 @@ export async function sendCommand(
|
|
|
365
427
|
const hasReplyChoice = Array.isArray(flags.replyChoice)
|
|
366
428
|
? flags.replyChoice.length > 0
|
|
367
429
|
: flags.replyChoice !== undefined
|
|
430
|
+
if (flags.reply && flags.kind === 'done') {
|
|
431
|
+
deps.io.err('--kind done cannot be combined with --reply; a reply request is a question.')
|
|
432
|
+
return EXIT.usage
|
|
433
|
+
}
|
|
368
434
|
if (
|
|
369
435
|
!flags.reply &&
|
|
370
436
|
(flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)
|
|
@@ -435,6 +501,7 @@ export async function sendCommand(
|
|
|
435
501
|
for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
|
|
436
502
|
return EXIT.usage
|
|
437
503
|
}
|
|
504
|
+
emitSendWarnings(deps, flags, config)
|
|
438
505
|
if (
|
|
439
506
|
!flags.reply &&
|
|
440
507
|
(flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
|
|
@@ -452,11 +519,21 @@ export async function sendCommand(
|
|
|
452
519
|
)
|
|
453
520
|
const receiptExit = receiptExitCode(receipt)
|
|
454
521
|
if (!flags.json) deps.io.out(formatReceipt(receipt))
|
|
522
|
+
else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
|
|
455
523
|
|
|
456
524
|
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
457
525
|
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
458
526
|
if (flags.json) {
|
|
459
|
-
deps.io.out(
|
|
527
|
+
deps.io.out(
|
|
528
|
+
flags.reply
|
|
529
|
+
? JSON.stringify({
|
|
530
|
+
type: 'reply_result',
|
|
531
|
+
request_id: receipt.request_id,
|
|
532
|
+
replies: [],
|
|
533
|
+
degraded: false,
|
|
534
|
+
})
|
|
535
|
+
: JSON.stringify(receipt, null, 2),
|
|
536
|
+
)
|
|
460
537
|
}
|
|
461
538
|
return receiptExit
|
|
462
539
|
}
|
|
@@ -469,11 +546,12 @@ export async function sendCommand(
|
|
|
469
546
|
})
|
|
470
547
|
if (flags.json) {
|
|
471
548
|
deps.io.out(
|
|
472
|
-
JSON.stringify(
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
549
|
+
JSON.stringify({
|
|
550
|
+
type: 'reply_result',
|
|
551
|
+
request_id: receipt.request_id,
|
|
552
|
+
replies: result.response.replies,
|
|
553
|
+
degraded: result.degraded,
|
|
554
|
+
}),
|
|
477
555
|
)
|
|
478
556
|
} else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
|
|
479
557
|
else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
|
|
@@ -481,16 +559,65 @@ export async function sendCommand(
|
|
|
481
559
|
deps.io.err(DEGRADED_WAIT_WARNING)
|
|
482
560
|
return EXIT.network
|
|
483
561
|
}
|
|
562
|
+
if (result.timedOut) {
|
|
563
|
+
deps.io.err(
|
|
564
|
+
`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
|
|
565
|
+
`\`notifai close ${receipt.request_id}\`.`,
|
|
566
|
+
)
|
|
567
|
+
}
|
|
484
568
|
return result.timedOut ? EXIT.noReply : EXIT.ok
|
|
485
569
|
} catch (err) {
|
|
486
570
|
return reportError(deps, err)
|
|
487
571
|
}
|
|
488
572
|
}
|
|
489
573
|
|
|
574
|
+
function emitSendWarnings(
|
|
575
|
+
deps: CommandDeps,
|
|
576
|
+
flags: SendFlags,
|
|
577
|
+
config: ReturnType<typeof loadConfig>,
|
|
578
|
+
): void {
|
|
579
|
+
if (flags.title.length > 40) {
|
|
580
|
+
deps.io.err(
|
|
581
|
+
`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`,
|
|
582
|
+
)
|
|
583
|
+
}
|
|
584
|
+
if (looksLikeMarkdown(flags.body)) {
|
|
585
|
+
deps.io.err(
|
|
586
|
+
'Heads up: --body looks like Markdown, but banners show plain text. Put long-form Markdown in --detail or --detail-file.',
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
|
|
590
|
+
if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
|
|
591
|
+
deps.io.err(
|
|
592
|
+
`Heads up: this title announces completion but the notification kind is update. Use --kind done for finished work.`,
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
if (
|
|
596
|
+
flags.collapseKey === undefined &&
|
|
597
|
+
config.collapse_key.value !== null &&
|
|
598
|
+
config.collapse_key.source.startsWith('global:')
|
|
599
|
+
) {
|
|
600
|
+
deps.io.err(
|
|
601
|
+
'Heads up: collapse_key comes from machine-global config, so unrelated notifications may replace each other. Prefer a project or command-specific --collapse-key.',
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
if (flags.ttl !== undefined && flags.ttl > 259_200) {
|
|
605
|
+
deps.io.err(
|
|
606
|
+
'Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.',
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function looksLikeMarkdown(value: string): boolean {
|
|
612
|
+
return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(
|
|
613
|
+
value,
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
|
|
490
617
|
export async function repliesCommand(
|
|
491
618
|
deps: CommandDeps,
|
|
492
|
-
|
|
493
|
-
flags: { wait?: number; after?: number; json?: boolean },
|
|
619
|
+
requestedId: string | undefined,
|
|
620
|
+
flags: { wait?: number; after?: number; json?: boolean; pending?: boolean },
|
|
494
621
|
): Promise<number> {
|
|
495
622
|
const waitSeconds = flags.wait ?? 0
|
|
496
623
|
const afterSeq = flags.after ?? 0
|
|
@@ -503,6 +630,28 @@ export async function repliesCommand(
|
|
|
503
630
|
return EXIT.usage
|
|
504
631
|
}
|
|
505
632
|
|
|
633
|
+
if (flags.pending === true && requestedId !== undefined) {
|
|
634
|
+
deps.io.err('Pass a request id or --pending, not both.')
|
|
635
|
+
return EXIT.usage
|
|
636
|
+
}
|
|
637
|
+
let requestId = requestedId
|
|
638
|
+
if (flags.pending === true) {
|
|
639
|
+
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
640
|
+
if (sessionId === null) {
|
|
641
|
+
deps.io.err('No active session pointer is available in this directory.')
|
|
642
|
+
return EXIT.noReply
|
|
643
|
+
}
|
|
644
|
+
requestId = readSessionState(sessionId, deps.env).pending?.request_id
|
|
645
|
+
if (requestId === undefined) {
|
|
646
|
+
deps.io.err(`Session ${sessionId} has no pushed question pending.`)
|
|
647
|
+
return EXIT.noReply
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (requestId === undefined) {
|
|
651
|
+
deps.io.err('Pass a request id or --pending.')
|
|
652
|
+
return EXIT.usage
|
|
653
|
+
}
|
|
654
|
+
|
|
506
655
|
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
507
656
|
const authed = authedClient(deps, config)
|
|
508
657
|
if (!authed) return EXIT.auth
|
|
@@ -515,7 +664,10 @@ export async function repliesCommand(
|
|
|
515
664
|
})
|
|
516
665
|
if (flags.json) {
|
|
517
666
|
deps.io.out(JSON.stringify({ ...result.response, degraded: result.degraded }, null, 2))
|
|
518
|
-
} else if (result.response.replies.length > 0)
|
|
667
|
+
} else if (result.response.replies.length > 0) {
|
|
668
|
+
if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
|
|
669
|
+
printReplies(deps, result.response.replies)
|
|
670
|
+
}
|
|
519
671
|
else printNoReply(deps, requestId, result.response.reply_expires_at)
|
|
520
672
|
if (result.degraded) {
|
|
521
673
|
deps.io.err(DEGRADED_WAIT_WARNING)
|
|
@@ -679,12 +831,12 @@ export async function statusCommand(
|
|
|
679
831
|
if (d.companion_receipt.state === 'observed') {
|
|
680
832
|
const latency = d.companion_receipt.latency_ms
|
|
681
833
|
deps.io.out(
|
|
682
|
-
` Companion Receipt: observed at ${d.companion_receipt.observed_at}` +
|
|
834
|
+
` Companion Receipt (the app's delivery confirmation): observed at ${d.companion_receipt.observed_at}` +
|
|
683
835
|
(latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`),
|
|
684
836
|
)
|
|
685
837
|
} else {
|
|
686
838
|
deps.io.out(
|
|
687
|
-
|
|
839
|
+
" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt",
|
|
688
840
|
)
|
|
689
841
|
}
|
|
690
842
|
for (const e of d.events) {
|
|
@@ -772,7 +924,7 @@ export async function hookRunCommand(
|
|
|
772
924
|
deps: CommandDeps,
|
|
773
925
|
event: string,
|
|
774
926
|
readStdin: () => Promise<string>,
|
|
775
|
-
harness?:
|
|
927
|
+
harness?: HookHarness,
|
|
776
928
|
): Promise<number> {
|
|
777
929
|
if (!(HOOK_EVENTS as readonly string[]).includes(event)) {
|
|
778
930
|
deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
|
|
@@ -780,7 +932,17 @@ export async function hookRunCommand(
|
|
|
780
932
|
}
|
|
781
933
|
let envelope: HookEnvelope
|
|
782
934
|
try {
|
|
783
|
-
|
|
935
|
+
const raw = await readStdin()
|
|
936
|
+
if (raw.trim() !== '') {
|
|
937
|
+
try {
|
|
938
|
+
const parsed: unknown = JSON.parse(raw)
|
|
939
|
+
if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
|
|
940
|
+
} catch {
|
|
941
|
+
deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
|
|
942
|
+
return EXIT.ok
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
envelope = parseHookInput(raw)
|
|
784
946
|
if (harness === 'cursor') {
|
|
785
947
|
const sessionId = envelope.session_id ?? envelope.conversation_id
|
|
786
948
|
const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
|
|
@@ -814,7 +976,10 @@ export async function hookRunCommand(
|
|
|
814
976
|
const cwd = envelope.cwd ?? deps.cwd
|
|
815
977
|
const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
|
|
816
978
|
const credential = deps.store.load()
|
|
817
|
-
if (!credential)
|
|
979
|
+
if (!credential) {
|
|
980
|
+
deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`')
|
|
981
|
+
return EXIT.ok
|
|
982
|
+
}
|
|
818
983
|
// Pin authenticated traffic to the origin the credential was issued for. A
|
|
819
984
|
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
820
985
|
// from it would hand this machine's bearer token to whatever host it names.
|
|
@@ -854,6 +1019,7 @@ export async function hookRunCommand(
|
|
|
854
1019
|
degraded: result.degraded,
|
|
855
1020
|
}
|
|
856
1021
|
},
|
|
1022
|
+
...(harness === undefined ? {} : { harness }),
|
|
857
1023
|
}
|
|
858
1024
|
|
|
859
1025
|
// Real clock, deliberately, not `deps.now`. This compares against file
|
|
@@ -941,18 +1107,50 @@ export interface AskFlags {
|
|
|
941
1107
|
* `require_idle = false` intentionally permits a push while the user is active.
|
|
942
1108
|
*/
|
|
943
1109
|
export function askCommand(deps: CommandDeps, question: string, flags: AskFlags): number {
|
|
944
|
-
// An agent calling this gets no hook payload
|
|
945
|
-
//
|
|
946
|
-
//
|
|
1110
|
+
// An agent calling this gets no hook payload. Harness-native environment
|
|
1111
|
+
// markers identify the active owner, while the UserPromptSubmit hook leaves
|
|
1112
|
+
// a pointer keyed on the project directory with the hook's canonical id.
|
|
1113
|
+
// The pointer outranks the
|
|
947
1114
|
// NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
|
|
948
1115
|
// label rather than the harness's own id, and the hooks key state by the
|
|
949
1116
|
// latter — the env var is only trusted when no hook has spoken.
|
|
950
1117
|
const now = (deps.now ?? Date.now)()
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1118
|
+
const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now)
|
|
1119
|
+
const pointer = projectPointer?.sessionId ?? null
|
|
1120
|
+
const active = activeHarnessSession(deps.env)
|
|
1121
|
+
let sessionId: string | undefined
|
|
1122
|
+
if (flags.session !== undefined) {
|
|
1123
|
+
sessionId = flags.session
|
|
1124
|
+
} else if (active !== null) {
|
|
1125
|
+
const installations = findInstallations(deps.cwd, deps.env)
|
|
1126
|
+
const activeInstalled = installations.some(
|
|
1127
|
+
(installation) => installation.harness === active.harness,
|
|
1128
|
+
)
|
|
1129
|
+
if (!activeInstalled) {
|
|
1130
|
+
for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
|
|
1131
|
+
deps.io.err(line)
|
|
1132
|
+
}
|
|
1133
|
+
return EXIT.usage
|
|
1134
|
+
}
|
|
1135
|
+
if (projectPointer === null) {
|
|
1136
|
+
for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
|
|
1137
|
+
deps.io.err(line)
|
|
1138
|
+
}
|
|
1139
|
+
return EXIT.usage
|
|
1140
|
+
}
|
|
1141
|
+
if (
|
|
1142
|
+
projectPointer.harness !== active.harness ||
|
|
1143
|
+
(active.sessionId !== undefined && projectPointer.sessionId !== active.sessionId)
|
|
1144
|
+
) {
|
|
1145
|
+
for (const line of diagnoseActiveHarnessSession(active, 'different-session', installations)) {
|
|
1146
|
+
deps.io.err(line)
|
|
1147
|
+
}
|
|
1148
|
+
return EXIT.usage
|
|
1149
|
+
}
|
|
1150
|
+
sessionId = projectPointer.sessionId
|
|
1151
|
+
} else {
|
|
1152
|
+
sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined
|
|
1153
|
+
}
|
|
956
1154
|
if (!sessionId) {
|
|
957
1155
|
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
958
1156
|
return EXIT.usage
|
|
@@ -994,6 +1192,79 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
|
|
|
994
1192
|
return EXIT.ok
|
|
995
1193
|
}
|
|
996
1194
|
|
|
1195
|
+
/**
|
|
1196
|
+
* Exact evidence that this shell command is running inside one supported
|
|
1197
|
+
* harness. Configuration-directory variables are deliberately absent: they
|
|
1198
|
+
* describe where a tool stores files, not which tool owns the current shell.
|
|
1199
|
+
* OpenCode's generated plugin supplies the Notifai-owned marker because its
|
|
1200
|
+
* plugin API exposes session identity but the ordinary environment does not.
|
|
1201
|
+
*/
|
|
1202
|
+
interface ActiveHarnessSession {
|
|
1203
|
+
harness: Harness
|
|
1204
|
+
label: string
|
|
1205
|
+
sessionId?: string
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function activeHarnessSession(env: NodeJS.ProcessEnv): ActiveHarnessSession | null {
|
|
1209
|
+
const explicit = env['NOTIFAI_ACTIVE_HARNESS']
|
|
1210
|
+
if (explicit === 'opencode') {
|
|
1211
|
+
const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID']
|
|
1212
|
+
return {
|
|
1213
|
+
harness: 'opencode',
|
|
1214
|
+
label: 'OpenCode',
|
|
1215
|
+
...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (env['CLAUDECODE'] === '1') {
|
|
1219
|
+
const sessionId = env['CLAUDE_CODE_SESSION_ID']
|
|
1220
|
+
return {
|
|
1221
|
+
harness: 'claude-code',
|
|
1222
|
+
label: 'Claude Code',
|
|
1223
|
+
...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const codexSession = env['CODEX_THREAD_ID']
|
|
1227
|
+
if (codexSession !== undefined && codexSession !== '') {
|
|
1228
|
+
return { harness: 'codex', label: 'Codex', sessionId: codexSession }
|
|
1229
|
+
}
|
|
1230
|
+
if ((env['CURSOR_AGENT'] ?? '') !== '') return { harness: 'cursor', label: 'Cursor' }
|
|
1231
|
+
return null
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
|
|
1235
|
+
|
|
1236
|
+
function diagnoseActiveHarnessSession(
|
|
1237
|
+
active: ActiveHarnessSession,
|
|
1238
|
+
problem: ActiveHarnessProblem,
|
|
1239
|
+
installations: Installation[],
|
|
1240
|
+
): string[] {
|
|
1241
|
+
if (problem === 'not-installed') {
|
|
1242
|
+
const others = installations.map((installation) => installation.harness)
|
|
1243
|
+
return [
|
|
1244
|
+
`Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
|
|
1245
|
+
...(others.length === 0
|
|
1246
|
+
? []
|
|
1247
|
+
: [
|
|
1248
|
+
`Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
|
|
1249
|
+
]),
|
|
1250
|
+
`Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
|
|
1251
|
+
`Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
|
|
1252
|
+
]
|
|
1253
|
+
}
|
|
1254
|
+
if (problem === 'different-session') {
|
|
1255
|
+
return [
|
|
1256
|
+
`Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
|
|
1257
|
+
`Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
|
|
1258
|
+
`Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
|
|
1259
|
+
]
|
|
1260
|
+
}
|
|
1261
|
+
return [
|
|
1262
|
+
`Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
|
|
1263
|
+
`Send one ${active.label} prompt, then run \`notifai doctor\`.`,
|
|
1264
|
+
`Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
|
|
1265
|
+
]
|
|
1266
|
+
}
|
|
1267
|
+
|
|
997
1268
|
/**
|
|
998
1269
|
* Why `ask` cannot see a session, in terms of what to do about it.
|
|
999
1270
|
*
|
|
@@ -1007,13 +1278,13 @@ function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
|
1007
1278
|
const installations = findInstallations(deps.cwd, deps.env)
|
|
1008
1279
|
if (installations.length === 0) {
|
|
1009
1280
|
return [
|
|
1010
|
-
'Could not tell which harness session this is: no
|
|
1281
|
+
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
1011
1282
|
'Run `notifai hooks install`, then follow the activation instruction it prints.',
|
|
1012
1283
|
]
|
|
1013
1284
|
}
|
|
1014
1285
|
const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ')
|
|
1015
1286
|
return [
|
|
1016
|
-
`Could not tell which harness session this is.
|
|
1287
|
+
`Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
|
|
1017
1288
|
'but no usable session pointer from the last 24 hours exists here.',
|
|
1018
1289
|
hookActivationAdvice(installations),
|
|
1019
1290
|
'To ask from this session anyway, pass --session <id>.',
|
|
@@ -1060,7 +1331,9 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1060
1331
|
)
|
|
1061
1332
|
}
|
|
1062
1333
|
if (harnesses.has('opencode')) {
|
|
1063
|
-
advice.push(
|
|
1334
|
+
advice.push(
|
|
1335
|
+
'OpenCode: restart it, then send one prompt; plugins load at startup, and a device answer is delivered on the next prompt',
|
|
1336
|
+
)
|
|
1064
1337
|
}
|
|
1065
1338
|
return `${advice.join('. ')}.`
|
|
1066
1339
|
}
|
|
@@ -1123,6 +1396,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1123
1396
|
scriptPath,
|
|
1124
1397
|
replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
|
|
1125
1398
|
graceSeconds: config.ask_grace_seconds.value,
|
|
1399
|
+
harness: 'cursor',
|
|
1126
1400
|
}),
|
|
1127
1401
|
scriptPath,
|
|
1128
1402
|
)
|
|
@@ -1142,8 +1416,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1142
1416
|
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
1143
1417
|
deps.io.out('start a new Cursor session and try one prompt again.')
|
|
1144
1418
|
}
|
|
1145
|
-
deps.io.out('A companion-device answer is submitted as one')
|
|
1146
|
-
deps.io.out('
|
|
1419
|
+
deps.io.out('A companion-device answer is submitted as one follow-up user message.')
|
|
1420
|
+
deps.io.out('loop_limit = 3 permits bounded chained questions without an unbounded loop.')
|
|
1147
1421
|
return EXIT.ok
|
|
1148
1422
|
}
|
|
1149
1423
|
|
|
@@ -1161,6 +1435,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1161
1435
|
scriptPath,
|
|
1162
1436
|
replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
|
|
1163
1437
|
graceSeconds: config.ask_grace_seconds.value,
|
|
1438
|
+
harness,
|
|
1164
1439
|
}),
|
|
1165
1440
|
scriptPath,
|
|
1166
1441
|
)
|
|
@@ -1184,11 +1459,11 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1184
1459
|
`nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
|
|
1185
1460
|
`until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
|
|
1186
1461
|
`has elapsed; it goes to your devices only while the machine also meets the idle threshold. ` +
|
|
1187
|
-
`
|
|
1462
|
+
`Run \`notifai config set require_idle false\` to be notified even while you are working.`
|
|
1188
1463
|
: `A question registered with \`notifai ask\` stays in the terminal for ` +
|
|
1189
1464
|
`${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
|
|
1190
1465
|
`whether or not you are at this machine ` +
|
|
1191
|
-
`(\`require_idle
|
|
1466
|
+
`(\`notifai config set require_idle false\` is already in effect).`,
|
|
1192
1467
|
)
|
|
1193
1468
|
if (config.require_idle.value) {
|
|
1194
1469
|
deps.io.out(
|
|
@@ -1226,7 +1501,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1226
1501
|
}
|
|
1227
1502
|
|
|
1228
1503
|
/**
|
|
1229
|
-
* Writes the OpenCode plugin, replacing any
|
|
1504
|
+
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
1230
1505
|
* including one a different checkout wrote, matched on the managed marker for
|
|
1231
1506
|
* the same reason command hooks are.
|
|
1232
1507
|
*/
|
|
@@ -1238,7 +1513,7 @@ function installOpencodePlugin(
|
|
|
1238
1513
|
if (existsSync(file)) {
|
|
1239
1514
|
const existing = readFileSync(file, 'utf8')
|
|
1240
1515
|
if (!isOurOpencodePlugin(existing)) {
|
|
1241
|
-
deps.io.err(`${file} exists and was not written by
|
|
1516
|
+
deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`)
|
|
1242
1517
|
return EXIT.failed
|
|
1243
1518
|
}
|
|
1244
1519
|
}
|
|
@@ -1254,8 +1529,9 @@ function installOpencodePlugin(
|
|
|
1254
1529
|
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
|
|
1255
1530
|
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
|
|
1256
1531
|
deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.')
|
|
1257
|
-
deps.io.out('OpenCode
|
|
1258
|
-
deps.io.out('
|
|
1532
|
+
deps.io.out('OpenCode pushes without holding its idle event open; the next user prompt')
|
|
1533
|
+
deps.io.out('collects a device answer and adds it to the agent context. Use `notifai')
|
|
1534
|
+
deps.io.out('send --reply` only when the answer must return before another prompt.')
|
|
1259
1535
|
deps.io.out('')
|
|
1260
1536
|
deps.io.out('Restart OpenCode: it loads plugins once at start.')
|
|
1261
1537
|
return EXIT.ok
|
|
@@ -1273,11 +1549,11 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1273
1549
|
if (harness === 'opencode') {
|
|
1274
1550
|
// We own the whole file, but only if we wrote it.
|
|
1275
1551
|
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
1276
|
-
deps.io.out(`Left ${file} alone:
|
|
1552
|
+
deps.io.out(`Left ${file} alone: Notifai did not write it.`)
|
|
1277
1553
|
return EXIT.ok
|
|
1278
1554
|
}
|
|
1279
1555
|
rmSync(file, { force: true })
|
|
1280
|
-
deps.io.out(`Removed the
|
|
1556
|
+
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
|
|
1281
1557
|
return EXIT.ok
|
|
1282
1558
|
}
|
|
1283
1559
|
if (harness === 'cursor') {
|
|
@@ -1297,8 +1573,8 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1297
1573
|
}
|
|
1298
1574
|
deps.io.out(
|
|
1299
1575
|
stripped.replaced.length > 0
|
|
1300
|
-
? `Removed
|
|
1301
|
-
: `No
|
|
1576
|
+
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
1577
|
+
: `No Notifai hooks found in ${file}`,
|
|
1302
1578
|
)
|
|
1303
1579
|
return EXIT.ok
|
|
1304
1580
|
}
|
|
@@ -1318,8 +1594,8 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1318
1594
|
}
|
|
1319
1595
|
deps.io.out(
|
|
1320
1596
|
stripped.replaced.length > 0
|
|
1321
|
-
? `Removed
|
|
1322
|
-
: `No
|
|
1597
|
+
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
1598
|
+
: `No Notifai hooks found in ${file}`,
|
|
1323
1599
|
)
|
|
1324
1600
|
return EXIT.ok
|
|
1325
1601
|
}
|
|
@@ -1453,27 +1729,69 @@ export async function configSetCommand(
|
|
|
1453
1729
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1454
1730
|
* Keep this immutable and public because the command is printed to users.
|
|
1455
1731
|
*/
|
|
1456
|
-
export const SKILLS_SOURCE = '
|
|
1732
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.1.8'
|
|
1457
1733
|
|
|
1458
|
-
function
|
|
1734
|
+
function skillSourceParts(): { source: string; ref: string } | null {
|
|
1459
1735
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1736
|
+
return match === null ? null : { source: match[1]!, ref: match[2]! }
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function expectedSkill(skill: NativeSkill): boolean {
|
|
1740
|
+
const expected = skillSourceParts()
|
|
1741
|
+
return (
|
|
1742
|
+
expected !== null &&
|
|
1743
|
+
skill.name === 'notifai' &&
|
|
1744
|
+
skill.source === expected.source &&
|
|
1745
|
+
skill.sourceType === 'github' &&
|
|
1746
|
+
skill.ref === expected.ref
|
|
1747
|
+
)
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
async function skillReadiness(
|
|
1751
|
+
deps: CommandDeps,
|
|
1752
|
+
selectedScope?: SkillScope,
|
|
1753
|
+
): Promise<ReadinessState> {
|
|
1754
|
+
const scopes: SkillScope[] = selectedScope === undefined ? ['project', 'global'] : [selectedScope]
|
|
1755
|
+
const results = await Promise.all(
|
|
1756
|
+
scopes.map(async (scope) => {
|
|
1757
|
+
if (deps.nativeSkills === undefined) return { scope, skills: [] as NativeSkill[] }
|
|
1758
|
+
try {
|
|
1759
|
+
return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) }
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
return { scope, skills: [] as NativeSkill[], error: String(err) }
|
|
1762
|
+
}
|
|
1763
|
+
}),
|
|
1764
|
+
)
|
|
1765
|
+
const installed = results.flatMap(({ skills }) => skills).find(expectedSkill)
|
|
1766
|
+
if (installed !== undefined) {
|
|
1767
|
+
return {
|
|
1768
|
+
id: 'skill',
|
|
1769
|
+
title: 'Agent guidance skill',
|
|
1770
|
+
status: 'ready',
|
|
1771
|
+
detail: `installed from ${SKILLS_SOURCE} in the ${installed.scope} scope`,
|
|
1467
1772
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
const errors = results
|
|
1776
|
+
.filter((result) => result.error !== undefined)
|
|
1777
|
+
.map((result) => `${result.scope}: ${result.error}`)
|
|
1778
|
+
const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`
|
|
1779
|
+
return {
|
|
1780
|
+
id: 'skill',
|
|
1781
|
+
title: 'Agent guidance skill',
|
|
1782
|
+
status: 'optional-gap',
|
|
1783
|
+
detail:
|
|
1784
|
+
errors.length > 0
|
|
1785
|
+
? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
|
|
1786
|
+
: `not installed from ${SKILLS_SOURCE} in ${scopeText}`,
|
|
1787
|
+
remedy: {
|
|
1788
|
+
by: 'cli',
|
|
1789
|
+
summary: 'install the skill agents follow when deciding to notify',
|
|
1790
|
+
command:
|
|
1791
|
+
selectedScope === undefined
|
|
1792
|
+
? 'notifai init --skills'
|
|
1793
|
+
: `notifai init --skills --skills-scope ${selectedScope}`,
|
|
1794
|
+
},
|
|
1477
1795
|
}
|
|
1478
1796
|
}
|
|
1479
1797
|
|
|
@@ -1496,6 +1814,8 @@ export interface InitFlags {
|
|
|
1496
1814
|
* never spawn npx against the network by default.
|
|
1497
1815
|
*/
|
|
1498
1816
|
skills?: boolean
|
|
1817
|
+
/** Scope selected by an unattended caller; humans choose inside npx skills. */
|
|
1818
|
+
skillsScope?: SkillScope
|
|
1499
1819
|
/** Same tri-state, for the harness hooks. */
|
|
1500
1820
|
hooks?: boolean
|
|
1501
1821
|
}
|
|
@@ -1507,11 +1827,47 @@ interface SetupProofRecord {
|
|
|
1507
1827
|
started_at: string
|
|
1508
1828
|
}
|
|
1509
1829
|
|
|
1510
|
-
|
|
1830
|
+
/** Long enough for a first TestFlight install; keep-waiting extends another budget. */
|
|
1831
|
+
const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000
|
|
1511
1832
|
const DEVICE_BRIDGE_POLL_MS = 2_000
|
|
1512
1833
|
const PROOF_TIMEOUT_MS = 30_000
|
|
1513
1834
|
const PROOF_POLL_MS = 1_000
|
|
1514
1835
|
|
|
1836
|
+
function supportPageUrl(baseUrl: string): string {
|
|
1837
|
+
return `${baseUrl.replace(/\/$/, '')}/support`
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
function formatWaitBudget(milliseconds: number): string {
|
|
1841
|
+
const minutes = Math.round(milliseconds / 60_000)
|
|
1842
|
+
return minutes === 1 ? '1 minute' : `${minutes} minutes`
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** Same-account line for the device hop; prefers the real email when known. */
|
|
1846
|
+
function sameAccountSignInLine(email: string | null | undefined): string {
|
|
1847
|
+
return email
|
|
1848
|
+
? `sign in with the same email as this account (${email})`
|
|
1849
|
+
: 'sign in with the same email as this account'
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function deviceInstallRemedy(options: {
|
|
1853
|
+
baseUrl: string
|
|
1854
|
+
email: string | null
|
|
1855
|
+
devices: readonly RoutableDevice[]
|
|
1856
|
+
}): string {
|
|
1857
|
+
const support = supportPageUrl(options.baseUrl)
|
|
1858
|
+
const sameEmail = sameAccountSignInLine(options.email)
|
|
1859
|
+
if (options.devices.length === 0) {
|
|
1860
|
+
return (
|
|
1861
|
+
`open the install steps at ${support} on that device (or your phone’s browser), ` +
|
|
1862
|
+
`install Notifai, ${sameEmail}, and allow notifications`
|
|
1863
|
+
)
|
|
1864
|
+
}
|
|
1865
|
+
if (options.devices.some((d) => d.permission_status === 'denied')) {
|
|
1866
|
+
return 'allow notifications for Notifai in that device’s system settings'
|
|
1867
|
+
}
|
|
1868
|
+
return 'open Notifai on that device and allow its notification prompt'
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1515
1871
|
function setupProofPath(deps: CommandDeps): string {
|
|
1516
1872
|
let projectDir = path.resolve(deps.cwd)
|
|
1517
1873
|
try {
|
|
@@ -1624,18 +1980,27 @@ async function closeGap(
|
|
|
1624
1980
|
}
|
|
1625
1981
|
|
|
1626
1982
|
if (state.id === 'skill') {
|
|
1627
|
-
deps.
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1983
|
+
if (deps.nativeSkills === undefined) {
|
|
1984
|
+
deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
|
|
1985
|
+
return 'failed'
|
|
1986
|
+
}
|
|
1987
|
+
const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`
|
|
1988
|
+
deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`)
|
|
1989
|
+
const addOptions = {
|
|
1990
|
+
source: SKILLS_SOURCE,
|
|
1991
|
+
skill: 'notifai',
|
|
1992
|
+
cwd: deps.cwd,
|
|
1993
|
+
env: deps.env,
|
|
1994
|
+
...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
|
|
1995
|
+
}
|
|
1996
|
+
const code = await deps.nativeSkills.add(addOptions)
|
|
1636
1997
|
if (code !== 0) {
|
|
1637
1998
|
deps.io.err('Skill installation failed — run it manually with:')
|
|
1638
|
-
deps.io.err(
|
|
1999
|
+
deps.io.err(
|
|
2000
|
+
` npx skills add ${SKILLS_SOURCE} --skill notifai${
|
|
2001
|
+
flags.skillsScope === 'global' ? ' --global' : ''
|
|
2002
|
+
}${flags.skillsScope === undefined ? '' : ' --yes'}`,
|
|
2003
|
+
)
|
|
1639
2004
|
}
|
|
1640
2005
|
return code === 0 ? 'closed' : 'failed'
|
|
1641
2006
|
}
|
|
@@ -1669,55 +2034,104 @@ function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
|
|
|
1669
2034
|
|
|
1670
2035
|
/**
|
|
1671
2036
|
* Observe the supported Device Installation path while the user finishes the
|
|
1672
|
-
* app-side work.
|
|
1673
|
-
*
|
|
1674
|
-
*
|
|
2037
|
+
* app-side work. The live bridge is the dashboard `/support` page (TestFlight
|
|
2038
|
+
* steps). Interactive runs offer to open it so the user never types the URL;
|
|
2039
|
+
* non-interactive/agent paths only print plain text and never wait on a prompt.
|
|
2040
|
+
*
|
|
2041
|
+
* The wait budget is stated up front. On expiry we say the *timer* expired —
|
|
2042
|
+
* not the setup — and offer another budget when a human is present. Agents
|
|
2043
|
+
* never hang: this whole path is gated on `io.interactive`, and keep-waiting
|
|
2044
|
+
* uses `confirm` which resolves the safe default when not interactive.
|
|
1675
2045
|
*/
|
|
1676
2046
|
async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Promise<GapCloseResult> {
|
|
1677
2047
|
const remedy = state.remedy
|
|
1678
2048
|
if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
|
|
1679
2049
|
|
|
2050
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
2051
|
+
const authed = authedClient(deps, config)
|
|
2052
|
+
if (!authed) {
|
|
2053
|
+
deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
|
|
2054
|
+
return 'failed'
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS)
|
|
2058
|
+
const supportUrl = supportPageUrl(authed.baseUrl)
|
|
1680
2059
|
await deps.io.note?.(
|
|
1681
|
-
|
|
2060
|
+
[
|
|
2061
|
+
state.detail,
|
|
2062
|
+
remedy.summary,
|
|
2063
|
+
`Install steps (no typing): ${supportUrl}`,
|
|
2064
|
+
`I will wait up to ${budgetLabel} for a companion device to become ready.`,
|
|
2065
|
+
].join('\n'),
|
|
1682
2066
|
'Finish setup on your companion device',
|
|
1683
2067
|
)
|
|
2068
|
+
|
|
2069
|
+
// Open the real support page so the user never has to type the URL. Decline
|
|
2070
|
+
// is fine — the URL remains in the note and in the Next: line if they leave.
|
|
2071
|
+
if (await deps.io.confirm('Open install instructions in your browser?', true)) {
|
|
2072
|
+
deps.io.openUrl(supportUrl)
|
|
2073
|
+
}
|
|
2074
|
+
|
|
1684
2075
|
if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
|
|
2076
|
+
deps.io.out(
|
|
2077
|
+
`OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`,
|
|
2078
|
+
)
|
|
1685
2079
|
return 'pending'
|
|
1686
2080
|
}
|
|
1687
2081
|
|
|
1688
|
-
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1689
|
-
const authed = authedClient(deps, config)
|
|
1690
|
-
if (!authed) return 'failed'
|
|
1691
2082
|
const now = deps.now ?? Date.now
|
|
1692
|
-
const sleep =
|
|
1693
|
-
|
|
1694
|
-
|
|
2083
|
+
const sleep =
|
|
2084
|
+
deps.sleep ??
|
|
2085
|
+
((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
|
|
2086
|
+
const spinner = await deps.io.spinner?.(`Waiting up to ${budgetLabel} for a companion device…`)
|
|
1695
2087
|
let lastDevices: RoutableDevice[] = []
|
|
2088
|
+
let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
|
|
1696
2089
|
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
2090
|
+
for (;;) {
|
|
2091
|
+
while (now() < deadline) {
|
|
2092
|
+
try {
|
|
2093
|
+
const response = await authed.client.listDevices()
|
|
2094
|
+
lastDevices = response.devices
|
|
2095
|
+
const ready = response.devices.find(deviceCanReceive)
|
|
2096
|
+
if (ready) {
|
|
2097
|
+
spinner?.stop(`${ready.display_name} is ready to receive`)
|
|
2098
|
+
return 'closed'
|
|
2099
|
+
}
|
|
2100
|
+
spinner?.message(deviceBridgeMessage(response.devices))
|
|
2101
|
+
} catch (err) {
|
|
2102
|
+
if (!(err instanceof NetworkError)) {
|
|
2103
|
+
spinner?.error('Could not check companion readiness')
|
|
2104
|
+
reportError(deps, err)
|
|
2105
|
+
return 'failed'
|
|
2106
|
+
}
|
|
2107
|
+
spinner?.message('Connection lost — still watching…')
|
|
1712
2108
|
}
|
|
1713
|
-
|
|
2109
|
+
await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
|
|
1714
2110
|
}
|
|
1715
|
-
await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
|
|
1716
|
-
}
|
|
1717
2111
|
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
2112
|
+
// Timer expired — setup did not. Offer another budget only when a human
|
|
2113
|
+
// can answer; confirm() already returns the fallback for agents, so this
|
|
2114
|
+
// never hangs unattended even if interactive were mis-set.
|
|
2115
|
+
spinner?.error(`The ${budgetLabel} wait timer expired`)
|
|
2116
|
+
deps.io.err(
|
|
2117
|
+
`The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`,
|
|
2118
|
+
)
|
|
2119
|
+
deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
|
|
2120
|
+
deps.io.err(
|
|
2121
|
+
`Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`,
|
|
2122
|
+
)
|
|
2123
|
+
|
|
2124
|
+
const keepWaiting = await deps.io.confirm(
|
|
2125
|
+
`Keep waiting for another ${budgetLabel}?`,
|
|
2126
|
+
false,
|
|
2127
|
+
)
|
|
2128
|
+
if (!keepWaiting) {
|
|
2129
|
+
deps.io.out('Stopping the wait. Device setup can continue; re-run `notifai init` when ready.')
|
|
2130
|
+
return 'pending'
|
|
2131
|
+
}
|
|
2132
|
+
spinner?.message(`Waiting another ${budgetLabel} for a companion device…`)
|
|
2133
|
+
deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
|
|
2134
|
+
}
|
|
1721
2135
|
}
|
|
1722
2136
|
|
|
1723
2137
|
function setupProofDraft(
|
|
@@ -1726,7 +2140,7 @@ function setupProofDraft(
|
|
|
1726
2140
|
): ReturnType<typeof buildDraft> {
|
|
1727
2141
|
const project = config.project.value
|
|
1728
2142
|
return buildDraft(config, {
|
|
1729
|
-
title: '
|
|
2143
|
+
title: 'Notifai is ready',
|
|
1730
2144
|
body:
|
|
1731
2145
|
project === null
|
|
1732
2146
|
? 'This real notification completed setup verification.'
|
|
@@ -1804,7 +2218,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
1804
2218
|
) ?? candidates[0]
|
|
1805
2219
|
if (!target) {
|
|
1806
2220
|
deps.io.err(
|
|
1807
|
-
|
|
2221
|
+
"Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts (the app's delivery confirmation).",
|
|
1808
2222
|
)
|
|
1809
2223
|
return 'pending'
|
|
1810
2224
|
}
|
|
@@ -1836,7 +2250,9 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
1836
2250
|
const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
|
|
1837
2251
|
const deadline = now() + PROOF_TIMEOUT_MS
|
|
1838
2252
|
const spinner = deps.io.interactive === true
|
|
1839
|
-
? await deps.io.spinner?.(
|
|
2253
|
+
? await deps.io.spinner?.(
|
|
2254
|
+
"Waiting for a Companion Receipt (the app's delivery confirmation)…",
|
|
2255
|
+
)
|
|
1840
2256
|
: null
|
|
1841
2257
|
let lastError: unknown = null
|
|
1842
2258
|
let replacedMissingProof = false
|
|
@@ -1887,11 +2303,11 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
1887
2303
|
await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())))
|
|
1888
2304
|
}
|
|
1889
2305
|
|
|
1890
|
-
spinner?.
|
|
2306
|
+
spinner?.stop('Delivery confirmation not observed yet')
|
|
1891
2307
|
if (lastError instanceof NetworkError) deps.io.err(lastError.message)
|
|
1892
|
-
deps.io.
|
|
1893
|
-
`
|
|
1894
|
-
'
|
|
2308
|
+
deps.io.out(
|
|
2309
|
+
`Provider accepted the notification; Companion Receipt (the app's delivery confirmation) for ${proof.request_id} was not observed within ${PROOF_TIMEOUT_MS / 1000}s. ` +
|
|
2310
|
+
'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.',
|
|
1895
2311
|
)
|
|
1896
2312
|
return 'pending'
|
|
1897
2313
|
}
|
|
@@ -1910,7 +2326,7 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
1910
2326
|
const question =
|
|
1911
2327
|
state.id === 'hooks'
|
|
1912
2328
|
? 'Install harness hooks, so questions reach your devices when you are away?'
|
|
1913
|
-
: 'Install/update the agent guidance skill
|
|
2329
|
+
: 'Install/update the agent guidance skill through the native npx skills flow?'
|
|
1914
2330
|
return deps.io.confirm(question, true)
|
|
1915
2331
|
}
|
|
1916
2332
|
|
|
@@ -1931,9 +2347,36 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
1931
2347
|
* states rather than four branches to enumerate.
|
|
1932
2348
|
*/
|
|
1933
2349
|
export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
|
|
1934
|
-
|
|
2350
|
+
if (
|
|
2351
|
+
flags.skillsScope !== undefined &&
|
|
2352
|
+
flags.skillsScope !== 'project' &&
|
|
2353
|
+
flags.skillsScope !== 'global'
|
|
2354
|
+
) {
|
|
2355
|
+
deps.io.err('Invalid skill scope. Choose `project` or `global`.')
|
|
2356
|
+
return EXIT.usage
|
|
2357
|
+
}
|
|
2358
|
+
if (flags.skillsScope !== undefined && flags.skills !== true) {
|
|
2359
|
+
deps.io.err('`--skills-scope` requires `--skills`. Choose project or global in the native installer.')
|
|
2360
|
+
return EXIT.usage
|
|
2361
|
+
}
|
|
2362
|
+
if (
|
|
2363
|
+
flags.skills === true &&
|
|
2364
|
+
deps.io.interactive !== true &&
|
|
2365
|
+
flags.skillsScope === undefined
|
|
2366
|
+
) {
|
|
2367
|
+
deps.io.err(
|
|
2368
|
+
'Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.',
|
|
2369
|
+
)
|
|
2370
|
+
return EXIT.usage
|
|
2371
|
+
}
|
|
2372
|
+
await deps.io.intro?.('Notifai setup')
|
|
1935
2373
|
|
|
1936
|
-
|
|
2374
|
+
const reassess = () =>
|
|
2375
|
+
assessReadiness(
|
|
2376
|
+
deps,
|
|
2377
|
+
flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope },
|
|
2378
|
+
)
|
|
2379
|
+
let readiness = await reassess()
|
|
1937
2380
|
let failed = false
|
|
1938
2381
|
const attempted = new Set<string>()
|
|
1939
2382
|
|
|
@@ -1963,11 +2406,16 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
1963
2406
|
attempted.add(state.id)
|
|
1964
2407
|
const result = await closeGap(deps, state, flags)
|
|
1965
2408
|
if (result === 'failed') failed = true
|
|
2409
|
+
if (result === 'failed' && state.status === 'optional-gap') {
|
|
2410
|
+
readiness = await reassess()
|
|
2411
|
+
advanced = true
|
|
2412
|
+
break
|
|
2413
|
+
}
|
|
1966
2414
|
if (result !== 'closed') {
|
|
1967
2415
|
stop = true
|
|
1968
2416
|
break
|
|
1969
2417
|
}
|
|
1970
|
-
readiness = await
|
|
2418
|
+
readiness = await reassess()
|
|
1971
2419
|
advanced = true
|
|
1972
2420
|
break
|
|
1973
2421
|
}
|
|
@@ -1980,30 +2428,26 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
1980
2428
|
stop = true
|
|
1981
2429
|
break
|
|
1982
2430
|
}
|
|
1983
|
-
readiness = await
|
|
2431
|
+
readiness = await reassess()
|
|
1984
2432
|
advanced = true
|
|
1985
2433
|
break
|
|
1986
2434
|
}
|
|
1987
2435
|
|
|
1988
|
-
// Its to launch, theirs to complete.
|
|
1989
|
-
//
|
|
1990
|
-
// reaches this prompt or opens a browser.
|
|
2436
|
+
// Its to launch, theirs to complete. Running `init` is the consent; announce
|
|
2437
|
+
// and open rather than re-asking. An agent never reaches this path.
|
|
1991
2438
|
if (
|
|
1992
2439
|
remedy.by === 'user-here' &&
|
|
1993
2440
|
remedy.interactive === true &&
|
|
1994
2441
|
deps.io.interactive === true
|
|
1995
2442
|
) {
|
|
1996
2443
|
attempted.add(state.id)
|
|
1997
|
-
|
|
1998
|
-
stop = true
|
|
1999
|
-
break
|
|
2000
|
-
}
|
|
2444
|
+
deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
|
|
2001
2445
|
if ((await loginCommand(deps, {})) !== EXIT.ok) {
|
|
2002
2446
|
failed = true
|
|
2003
2447
|
stop = true
|
|
2004
2448
|
break
|
|
2005
2449
|
}
|
|
2006
|
-
readiness = await
|
|
2450
|
+
readiness = await reassess()
|
|
2007
2451
|
advanced = true
|
|
2008
2452
|
break
|
|
2009
2453
|
}
|
|
@@ -2020,7 +2464,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
2020
2464
|
stop = true
|
|
2021
2465
|
break
|
|
2022
2466
|
}
|
|
2023
|
-
readiness = await
|
|
2467
|
+
readiness = await reassess()
|
|
2024
2468
|
advanced = true
|
|
2025
2469
|
break
|
|
2026
2470
|
}
|
|
@@ -2033,14 +2477,16 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
2033
2477
|
if (stop || !advanced) break
|
|
2034
2478
|
}
|
|
2035
2479
|
|
|
2036
|
-
readiness = await
|
|
2480
|
+
readiness = await reassess()
|
|
2037
2481
|
for (const state of readiness.states.filter((s) => s.status === 'ready')) {
|
|
2038
2482
|
deps.io.out(`${state.title}: ${state.detail}`)
|
|
2039
2483
|
}
|
|
2040
2484
|
|
|
2041
2485
|
const blocker = firstBlocker(readiness)
|
|
2042
2486
|
if (blocker === null) {
|
|
2043
|
-
const skipped = openItems(readiness)
|
|
2487
|
+
const skipped = openItems(readiness).filter(
|
|
2488
|
+
(state) => !(state.id === 'skill' && flags.skills === false),
|
|
2489
|
+
)
|
|
2044
2490
|
for (const state of skipped) deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
|
|
2045
2491
|
deps.io.out('All set. Agents in this project can notify you and ask questions.')
|
|
2046
2492
|
await deps.io.outro?.('All set ✨')
|
|
@@ -2053,7 +2499,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
2053
2499
|
deps.io.out('')
|
|
2054
2500
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
2055
2501
|
deps.io.out(` ${remedyLine(blocker)}`)
|
|
2056
|
-
|
|
2502
|
+
// Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
|
|
2503
|
+
// how the rest is recovered either way.
|
|
2504
|
+
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
2057
2505
|
deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
|
|
2058
2506
|
}
|
|
2059
2507
|
await deps.io.outro?.('One step remains (above)')
|
|
@@ -2093,9 +2541,8 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
|
|
|
2093
2541
|
ok: false,
|
|
2094
2542
|
detail:
|
|
2095
2543
|
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`,
|
|
2544
|
+
? `server speaks schema v${remote}, this CLI speaks v${local} — the service is being updated; try again later`
|
|
2545
|
+
: `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI: npm install -g @raidiant/notifai`,
|
|
2099
2546
|
}
|
|
2100
2547
|
} catch (err) {
|
|
2101
2548
|
return {
|
|
@@ -2115,7 +2562,10 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
|
|
|
2115
2562
|
* `unknown`, which is the honest answer and keeps a network outage from
|
|
2116
2563
|
* looking like a broken install.
|
|
2117
2564
|
*/
|
|
2118
|
-
export async function assessReadiness(
|
|
2565
|
+
export async function assessReadiness(
|
|
2566
|
+
deps: CommandDeps,
|
|
2567
|
+
options: { skillScope?: SkillScope } = {},
|
|
2568
|
+
): Promise<Readiness> {
|
|
2119
2569
|
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
2120
2570
|
const states: ReadinessState[] = []
|
|
2121
2571
|
let accountClient: ApiClient | null = null
|
|
@@ -2223,58 +2673,35 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
|
|
|
2223
2673
|
)
|
|
2224
2674
|
}
|
|
2225
2675
|
|
|
2676
|
+
let accountEmail: string | null = null
|
|
2677
|
+
let accountLookupFailed = false
|
|
2226
2678
|
if (!credential || !reachable) {
|
|
2227
2679
|
const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
|
|
2228
2680
|
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
2681
|
} else {
|
|
2231
2682
|
const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`)
|
|
2232
2683
|
accountClient = client
|
|
2233
2684
|
try {
|
|
2234
|
-
const { devices } = await
|
|
2685
|
+
const [{ devices }, email] = await Promise.all([
|
|
2686
|
+
client.listDevices(),
|
|
2687
|
+
Promise.resolve()
|
|
2688
|
+
.then(async () => (await client.accessStatus()).email)
|
|
2689
|
+
.catch(() => null as string | null),
|
|
2690
|
+
])
|
|
2235
2691
|
accountDevices = devices
|
|
2236
|
-
|
|
2692
|
+
accountEmail = email
|
|
2237
2693
|
states.push({
|
|
2238
2694
|
id: 'auth',
|
|
2239
2695
|
title: 'Account',
|
|
2240
2696
|
status: 'ready',
|
|
2241
|
-
detail:
|
|
2697
|
+
detail: accountEmail
|
|
2698
|
+
? `machine ${credential.machineId} accepted (${accountEmail})`
|
|
2699
|
+
: `machine ${credential.machineId} accepted`,
|
|
2242
2700
|
})
|
|
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
2701
|
} catch (err) {
|
|
2276
2702
|
// A credential the server rejects is revocation, not absence, and the
|
|
2277
2703
|
// remedy is the same sign-in either way.
|
|
2704
|
+
accountLookupFailed = true
|
|
2278
2705
|
states.push({
|
|
2279
2706
|
id: 'auth',
|
|
2280
2707
|
title: 'Account',
|
|
@@ -2286,32 +2713,56 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
|
|
|
2286
2713
|
command: 'notifai login',
|
|
2287
2714
|
},
|
|
2288
2715
|
})
|
|
2289
|
-
states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
|
|
2290
2716
|
}
|
|
2291
2717
|
}
|
|
2292
2718
|
|
|
2719
|
+
// Optional setup that works without a companion device must appear before the
|
|
2720
|
+
// device gap: init stops at the first user-elsewhere blocker, and hooks/skill
|
|
2721
|
+
// are reachable without a phone.
|
|
2293
2722
|
states.push(...hookStates(deps))
|
|
2723
|
+
states.push(await skillReadiness(deps, options.skillScope))
|
|
2294
2724
|
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2725
|
+
if (!credential || !reachable) {
|
|
2726
|
+
const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
|
|
2727
|
+
states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` })
|
|
2728
|
+
} else if (accountLookupFailed || accountDevices === null) {
|
|
2729
|
+
states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
|
|
2730
|
+
} else {
|
|
2731
|
+
const devices = accountDevices
|
|
2732
|
+
const ready = devices.filter(deviceCanReceive)
|
|
2733
|
+
states.push(
|
|
2734
|
+
ready.length > 0
|
|
2735
|
+
? {
|
|
2736
|
+
id: 'devices',
|
|
2737
|
+
title: 'Your devices',
|
|
2738
|
+
status: 'ready',
|
|
2739
|
+
detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
|
|
2740
|
+
}
|
|
2741
|
+
: {
|
|
2742
|
+
id: 'devices',
|
|
2743
|
+
title: 'Your devices',
|
|
2744
|
+
status: 'gap',
|
|
2745
|
+
// The one gap that cannot be closed from this terminal, and the
|
|
2746
|
+
// likeliest place a first setup is abandoned. Naming which of
|
|
2747
|
+
// the three sub-states it is matters: "install the app" is
|
|
2748
|
+
// useless advice to someone who installed it and denied the
|
|
2749
|
+
// permission prompt. The live bridge is /support on the
|
|
2750
|
+
// dashboard origin — not a placeholder, and not typed by hand.
|
|
2751
|
+
detail:
|
|
2752
|
+
devices.length === 0
|
|
2753
|
+
? `nothing registered yet; install Notifai on iPhone or Mac via ${supportPageUrl(baseUrl)}`
|
|
2754
|
+
: `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
|
|
2755
|
+
remedy: {
|
|
2756
|
+
by: 'user-elsewhere',
|
|
2757
|
+
summary: deviceInstallRemedy({
|
|
2758
|
+
baseUrl,
|
|
2759
|
+
email: accountEmail,
|
|
2760
|
+
devices,
|
|
2761
|
+
}),
|
|
2762
|
+
},
|
|
2312
2763
|
},
|
|
2313
|
-
|
|
2314
|
-
|
|
2764
|
+
)
|
|
2765
|
+
}
|
|
2315
2766
|
|
|
2316
2767
|
states.push(await setupProofState(deps, config, accountClient, accountDevices))
|
|
2317
2768
|
|
|
@@ -2345,16 +2796,18 @@ async function setupProofState(
|
|
|
2345
2796
|
|
|
2346
2797
|
const ios = readyIosDevices(devices)
|
|
2347
2798
|
if (ios.length === 0) {
|
|
2799
|
+
// Honest non-blocking caveat: notifications can reach the Mac; only the
|
|
2800
|
+
// receipt proof path is unavailable in this release. Never emit a Next:
|
|
2801
|
+
// step the user cannot satisfy, and never claim unobserved proof.
|
|
2348
2802
|
return {
|
|
2349
2803
|
id: 'proof',
|
|
2350
2804
|
title: 'Delivery proof',
|
|
2351
|
-
status: 'gap',
|
|
2805
|
+
status: 'optional-gap',
|
|
2352
2806
|
detail:
|
|
2353
|
-
|
|
2807
|
+
"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
2808
|
remedy: {
|
|
2355
2809
|
by: 'user-elsewhere',
|
|
2356
|
-
summary:
|
|
2357
|
-
'pair a receipt-capable iPhone build; no supported macOS receipt bridge is available in this release',
|
|
2810
|
+
summary: 'receipt proof needs an iPhone in this release (notifications still reach this Mac)',
|
|
2358
2811
|
},
|
|
2359
2812
|
}
|
|
2360
2813
|
}
|
|
@@ -2366,10 +2819,12 @@ async function setupProofState(
|
|
|
2366
2819
|
id: 'proof',
|
|
2367
2820
|
title: 'Delivery proof',
|
|
2368
2821
|
status: 'gap',
|
|
2369
|
-
detail:
|
|
2822
|
+
detail:
|
|
2823
|
+
"no Companion Receipt (the app's delivery confirmation) has proven this project on this machine yet",
|
|
2370
2824
|
remedy: {
|
|
2371
2825
|
by: 'cli',
|
|
2372
|
-
summary:
|
|
2826
|
+
summary:
|
|
2827
|
+
"send one real verification notification and wait for its Companion Receipt (the app's delivery confirmation)",
|
|
2373
2828
|
command: 'notifai init',
|
|
2374
2829
|
},
|
|
2375
2830
|
}
|
|
@@ -2383,14 +2838,14 @@ async function setupProofState(
|
|
|
2383
2838
|
id: 'proof',
|
|
2384
2839
|
title: 'Delivery proof',
|
|
2385
2840
|
status: 'ready',
|
|
2386
|
-
detail: `Companion Receipt observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
|
|
2841
|
+
detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
|
|
2387
2842
|
}
|
|
2388
2843
|
}
|
|
2389
2844
|
return {
|
|
2390
2845
|
id: 'proof',
|
|
2391
2846
|
title: 'Delivery proof',
|
|
2392
2847
|
status: 'gap',
|
|
2393
|
-
detail: `${proof.request_id} was sent, but its Companion Receipt is still unknown`,
|
|
2848
|
+
detail: `${proof.request_id} was sent, but its Companion Receipt (the app's delivery confirmation) is still unknown`,
|
|
2394
2849
|
remedy: {
|
|
2395
2850
|
by: 'cli',
|
|
2396
2851
|
summary: 'check the same verification notification again',
|
|
@@ -2418,7 +2873,7 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
|
|
|
2418
2873
|
const ok = blocker === null
|
|
2419
2874
|
|
|
2420
2875
|
if (flags.json) {
|
|
2421
|
-
deps.io.out(JSON.stringify({ ok, states: readiness.states }, null, 2))
|
|
2876
|
+
deps.io.out(JSON.stringify({ ok, exit_code: ok ? EXIT.ok : EXIT.failed, states: readiness.states }, null, 2))
|
|
2422
2877
|
return ok ? EXIT.ok : EXIT.failed
|
|
2423
2878
|
}
|
|
2424
2879
|
|
|
@@ -2428,12 +2883,19 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
|
|
|
2428
2883
|
// problems in dependency order has an obvious first move and saying so
|
|
2429
2884
|
// costs nothing.
|
|
2430
2885
|
if (deps.io.interactive === true && deps.io.check) {
|
|
2431
|
-
await deps.io.intro?.('
|
|
2886
|
+
await deps.io.intro?.('Notifai doctor')
|
|
2432
2887
|
for (const s of readiness.states) await deps.io.check(s.status !== 'gap', line(s))
|
|
2433
2888
|
await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
|
|
2434
2889
|
} else {
|
|
2435
2890
|
for (const s of readiness.states) {
|
|
2436
|
-
const mark =
|
|
2891
|
+
const mark =
|
|
2892
|
+
s.status === 'gap'
|
|
2893
|
+
? 'FAIL'
|
|
2894
|
+
: s.status === 'unknown'
|
|
2895
|
+
? ' ? '
|
|
2896
|
+
: s.status === 'optional-gap'
|
|
2897
|
+
? ' --'
|
|
2898
|
+
: 'ok '
|
|
2437
2899
|
deps.io.out(`${mark} ${line(s)}`)
|
|
2438
2900
|
}
|
|
2439
2901
|
if (!ok) deps.io.out(`\nStart with: ${remedyLine(blocker)}`)
|
|
@@ -2476,39 +2938,67 @@ function remedyLine(state: ReadinessState): string {
|
|
|
2476
2938
|
*/
|
|
2477
2939
|
function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
2478
2940
|
const installations = findInstallations(deps.cwd, deps.env)
|
|
2941
|
+
const active = activeHarnessSession(deps.env)
|
|
2942
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
2943
|
+
const settings: ReadinessState = {
|
|
2944
|
+
id: 'question-routing-settings',
|
|
2945
|
+
title: 'Question routing settings',
|
|
2946
|
+
status: 'ready',
|
|
2947
|
+
detail: [
|
|
2948
|
+
`ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
|
|
2949
|
+
`require_idle=${config.require_idle.value} (${config.require_idle.source})`,
|
|
2950
|
+
`away_after_seconds=${config.away_after_seconds.value} (${config.away_after_seconds.source})`,
|
|
2951
|
+
`ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
|
|
2952
|
+
`hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
|
|
2953
|
+
].join(', '),
|
|
2954
|
+
}
|
|
2479
2955
|
if (installations.length === 0) {
|
|
2480
2956
|
return [
|
|
2481
2957
|
{
|
|
2482
2958
|
id: 'hooks',
|
|
2483
2959
|
title: 'Question routing',
|
|
2484
|
-
status: 'optional-gap',
|
|
2485
|
-
detail:
|
|
2960
|
+
status: active === null ? 'optional-gap' : 'gap',
|
|
2961
|
+
detail:
|
|
2962
|
+
active === null
|
|
2963
|
+
? 'hooks not installed, so questions stay in the terminal'
|
|
2964
|
+
: `active ${active.label} session detected, but ${active.label} hooks are not installed; \`notifai ask\` cannot route this session`,
|
|
2486
2965
|
remedy: {
|
|
2487
2966
|
by: 'cli',
|
|
2488
2967
|
summary: 'install harness hooks so questions reach your devices when you are away',
|
|
2489
|
-
command:
|
|
2968
|
+
command:
|
|
2969
|
+
active === null
|
|
2970
|
+
? 'notifai hooks install'
|
|
2971
|
+
: `notifai hooks install --harness ${active.harness}`,
|
|
2490
2972
|
},
|
|
2491
2973
|
},
|
|
2974
|
+
settings,
|
|
2492
2975
|
]
|
|
2493
2976
|
}
|
|
2494
2977
|
|
|
2495
2978
|
/** 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
|
-
|
|
2979
|
+
const informational = new Set(['hooks (opencode continuation)'])
|
|
2980
|
+
if (active === null) informational.add('hooks (fired)')
|
|
2981
|
+
return [
|
|
2982
|
+
...hookChecks(deps).map((check) => ({
|
|
2983
|
+
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
2984
|
+
title:
|
|
2985
|
+
check.name === 'hooks' || check.name.startsWith('hooks (active')
|
|
2986
|
+
? 'Question routing'
|
|
2987
|
+
: check.name,
|
|
2988
|
+
status: check.ok ? 'ready' as const : informational.has(check.name) ? 'optional-gap' as const : 'gap' as const,
|
|
2989
|
+
detail: check.detail,
|
|
2990
|
+
...(check.ok
|
|
2991
|
+
? {}
|
|
2992
|
+
: {
|
|
2993
|
+
remedy: {
|
|
2994
|
+
by: 'user-here' as const,
|
|
2995
|
+
summary: 'the detail above names what to change',
|
|
2996
|
+
command: 'notifai hooks install',
|
|
2997
|
+
},
|
|
2998
|
+
}),
|
|
2999
|
+
})),
|
|
3000
|
+
settings,
|
|
3001
|
+
]
|
|
2512
3002
|
}
|
|
2513
3003
|
|
|
2514
3004
|
function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: string }[] {
|
|
@@ -2533,6 +3023,39 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
|
|
|
2533
3023
|
.join(', '),
|
|
2534
3024
|
})
|
|
2535
3025
|
|
|
3026
|
+
const active = activeHarnessSession(deps.env)
|
|
3027
|
+
const activeInstallations =
|
|
3028
|
+
active === null
|
|
3029
|
+
? []
|
|
3030
|
+
: installations.filter((installation) => installation.harness === active.harness)
|
|
3031
|
+
if (active !== null) {
|
|
3032
|
+
checks.push({
|
|
3033
|
+
name: 'hooks (active harness)',
|
|
3034
|
+
ok: activeInstallations.length > 0,
|
|
3035
|
+
detail:
|
|
3036
|
+
activeInstallations.length > 0
|
|
3037
|
+
? `active ${active.label} session has a matching hook installation`
|
|
3038
|
+
: `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
|
|
3039
|
+
})
|
|
3040
|
+
if (activeInstallations.length > 0) {
|
|
3041
|
+
const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
3042
|
+
const matches =
|
|
3043
|
+
pointer !== null &&
|
|
3044
|
+
pointer.harness === active.harness &&
|
|
3045
|
+
(active.sessionId === undefined || pointer.sessionId === active.sessionId)
|
|
3046
|
+
checks.push({
|
|
3047
|
+
name: 'hooks (active session)',
|
|
3048
|
+
ok: matches,
|
|
3049
|
+
detail:
|
|
3050
|
+
pointer === null
|
|
3051
|
+
? `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`
|
|
3052
|
+
: matches
|
|
3053
|
+
? `the project pointer belongs to the active ${active.label} session`
|
|
3054
|
+
: `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
|
|
3055
|
+
})
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
|
|
2536
3059
|
// A handler naming an event this build dropped exits 2 every time the harness
|
|
2537
3060
|
// fires it, which the harness reports as a hook failure.
|
|
2538
3061
|
const stale = installations.flatMap((i) =>
|
|
@@ -2563,6 +3086,29 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
|
|
|
2563
3086
|
})
|
|
2564
3087
|
}
|
|
2565
3088
|
|
|
3089
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
3090
|
+
const requiredTimeout = blockingHookTimeoutSeconds(
|
|
3091
|
+
config.ask_grace_seconds.value,
|
|
3092
|
+
config.hook_reply_timeout_seconds.value,
|
|
3093
|
+
)
|
|
3094
|
+
const shortTimeouts = installations.flatMap((installation) =>
|
|
3095
|
+
installation.handlers
|
|
3096
|
+
.filter((handler) => handlerEvent(handler.command) === 'stop')
|
|
3097
|
+
.filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
|
|
3098
|
+
.map(
|
|
3099
|
+
(handler) =>
|
|
3100
|
+
`${installation.harness} declares ${handler.timeout ?? 'no'}s, needs ${requiredTimeout}s — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
|
|
3101
|
+
),
|
|
3102
|
+
)
|
|
3103
|
+
checks.push({
|
|
3104
|
+
name: 'hooks (timeout)',
|
|
3105
|
+
ok: shortTimeouts.length === 0,
|
|
3106
|
+
detail:
|
|
3107
|
+
shortTimeouts.length === 0
|
|
3108
|
+
? `installed Stop timeouts cover the current ${requiredTimeout}s grace/reply budget`
|
|
3109
|
+
: shortTimeouts.join('; '),
|
|
3110
|
+
})
|
|
3111
|
+
|
|
2566
3112
|
// Two checkouts each installing hooks means both fire for the same event, and
|
|
2567
3113
|
// the user gets every question twice.
|
|
2568
3114
|
//
|
|
@@ -2586,19 +3132,22 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
|
|
|
2586
3132
|
detail: duplicated
|
|
2587
3133
|
.map(
|
|
2588
3134
|
(entry) =>
|
|
2589
|
-
`${entry.harness}: ${entry.scripts.size} different
|
|
3135
|
+
`${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
3136
|
)
|
|
2591
3137
|
.join('; '),
|
|
2592
3138
|
})
|
|
2593
3139
|
}
|
|
2594
3140
|
|
|
2595
|
-
const
|
|
3141
|
+
const firedPointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
3142
|
+
const fired = firedPointer !== null
|
|
3143
|
+
const activationInstallations =
|
|
3144
|
+
active !== null && activeInstallations.length > 0 ? activeInstallations : installations
|
|
2596
3145
|
checks.push({
|
|
2597
3146
|
name: 'hooks (fired)',
|
|
2598
3147
|
ok: fired,
|
|
2599
3148
|
detail: fired
|
|
2600
3149
|
? 'a session in this directory has run them'
|
|
2601
|
-
: `no session pointer from the last 24 hours — ${hookActivationAdvice(
|
|
3150
|
+
: `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
|
|
2602
3151
|
})
|
|
2603
3152
|
|
|
2604
3153
|
if (installations.some((installation) => installation.harness === 'opencode')) {
|
|
@@ -2606,7 +3155,7 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
|
|
|
2606
3155
|
name: 'hooks (opencode continuation)',
|
|
2607
3156
|
ok: false,
|
|
2608
3157
|
detail:
|
|
2609
|
-
'question routing is installed
|
|
3158
|
+
'question routing is installed; OpenCode cannot resume an idle turn automatically, but its next user prompt collects the answer',
|
|
2610
3159
|
})
|
|
2611
3160
|
}
|
|
2612
3161
|
|
|
@@ -2731,10 +3280,16 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
|
|
|
2731
3280
|
else log.error(message)
|
|
2732
3281
|
},
|
|
2733
3282
|
openUrl: (url) => {
|
|
2734
|
-
const command =
|
|
2735
|
-
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
|
|
2736
3283
|
try {
|
|
2737
|
-
|
|
3284
|
+
if (process.platform === 'darwin') {
|
|
3285
|
+
spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
|
|
3286
|
+
} else if (process.platform === 'win32') {
|
|
3287
|
+
// `start` is a cmd builtin; spawn('start', …) cannot work on Windows.
|
|
3288
|
+
// Empty title argument keeps URLs with special characters intact.
|
|
3289
|
+
spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref()
|
|
3290
|
+
} else {
|
|
3291
|
+
spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
|
|
3292
|
+
}
|
|
2738
3293
|
} catch {
|
|
2739
3294
|
// Browser opening is best-effort; the URL is printed anyway.
|
|
2740
3295
|
}
|