@raidiant/notifai 0.1.1 → 0.1.3

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/src/commands.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { randomBytes } from 'node:crypto'
1
+ import { createHash, randomBytes } from 'node:crypto'
2
2
  import { spawn } from 'node:child_process'
3
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
4
4
  import os from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
@@ -8,9 +8,12 @@ import {
8
8
  CAPABILITIES_V1,
9
9
  REPLY_MAX_WINDOW_SECONDS,
10
10
  validateDraft,
11
+ type EvidenceSnapshot,
11
12
  type ListRepliesResponse,
12
13
  type Platform,
13
14
  type ReplyView,
15
+ type RoutableDevice,
16
+ type SubmissionReceipt,
14
17
  } from '@raidiant/notifai-protocol'
15
18
  import { sha256Hex } from '@raidiant/notifai-protocol/node'
16
19
  import {
@@ -668,9 +671,24 @@ export async function statusCommand(
668
671
  }
669
672
  deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
670
673
  for (const d of snapshot.deliveries) {
671
- deps.io.out(` ${d.device_name}: ${d.state} after ${d.attempts} attempt(s)`)
674
+ deps.io.out(` ${d.device_name}:`)
675
+ deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`)
676
+ deps.io.out(
677
+ ` Provider Acceptance: ${d.state === 'provider_accepted' ? 'accepted' : 'not recorded'}`,
678
+ )
679
+ if (d.companion_receipt.state === 'observed') {
680
+ const latency = d.companion_receipt.latency_ms
681
+ deps.io.out(
682
+ ` Companion Receipt: observed at ${d.companion_receipt.observed_at}` +
683
+ (latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`),
684
+ )
685
+ } else {
686
+ deps.io.out(
687
+ ' Companion Receipt: unknown — not observed; this is not a failure or proof of non-receipt',
688
+ )
689
+ }
672
690
  for (const e of d.events) {
673
- deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
691
+ deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
674
692
  }
675
693
  }
676
694
  return EXIT.ok
@@ -679,6 +697,13 @@ export async function statusCommand(
679
697
  }
680
698
  }
681
699
 
700
+ function formatElapsed(milliseconds: number): string {
701
+ const totalSeconds = Math.floor(milliseconds / 1_000)
702
+ const minutes = Math.floor(totalSeconds / 60)
703
+ const seconds = totalSeconds % 60
704
+ return minutes === 0 ? `${seconds}s` : `${minutes}m ${seconds}s`
705
+ }
706
+
682
707
  const MEDIA_TYPES: Record<string, 'image/jpeg' | 'image/png' | 'image/gif'> = {
683
708
  '.jpg': 'image/jpeg',
684
709
  '.jpeg': 'image/jpeg',
@@ -944,15 +969,20 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
944
969
  deps.io.err(CHOICE_USAGE)
945
970
  return EXIT.usage
946
971
  }
947
- registerQuestion(
948
- sessionId,
949
- deps.env,
950
- {
951
- question: question.trim(),
952
- ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
953
- },
954
- (deps.now ?? Date.now)(),
955
- )
972
+ try {
973
+ registerQuestion(
974
+ sessionId,
975
+ deps.env,
976
+ {
977
+ question: question.trim(),
978
+ ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
979
+ },
980
+ (deps.now ?? Date.now)(),
981
+ )
982
+ } catch (err) {
983
+ deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
984
+ return EXIT.failed
985
+ }
956
986
  if (choices !== null) {
957
987
  deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`)
958
988
  }
@@ -1419,14 +1449,11 @@ export async function configSetCommand(
1419
1449
  // ---------------------------------------------------------------------------
1420
1450
 
1421
1451
  /**
1422
- * Where `npx skills add` fetches the optional agent skill from. The skill's
1423
- * canonical home is the public repository (RafaelVidaurre/notifai), which has
1424
- * nothing pushed to it yet, so no source can resolve today; the guard below
1425
- * keeps `--skills` from installing anything until a tagged release exists to
1426
- * pin this to. Never point this at the private repository — the installer
1427
- * command is printed to users, and only public sources belong in it.
1452
+ * Where `npx skills add` fetches the optional agent skill from. In skills CLI
1453
+ * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1454
+ * Keep this immutable and public because the command is printed to users.
1428
1455
  */
1429
- const SKILLS_SOURCE = ''
1456
+ export const SKILLS_SOURCE = 'RafaelVidaurre/notifai#v0.1.3'
1430
1457
 
1431
1458
  /** Derive a contract-valid project slug from a directory name. */
1432
1459
  export function projectSlugFrom(name: string): string {
@@ -1451,6 +1478,72 @@ export interface InitFlags {
1451
1478
  hooks?: boolean
1452
1479
  }
1453
1480
 
1481
+ interface SetupProofRecord {
1482
+ request_id: string
1483
+ device_id: string
1484
+ project: string | null
1485
+ started_at: string
1486
+ }
1487
+
1488
+ const DEVICE_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000
1489
+ const DEVICE_BRIDGE_POLL_MS = 2_000
1490
+ const PROOF_TIMEOUT_MS = 30_000
1491
+ const PROOF_POLL_MS = 1_000
1492
+
1493
+ function setupProofPath(deps: CommandDeps): string {
1494
+ let projectDir = path.resolve(deps.cwd)
1495
+ try {
1496
+ projectDir = realpathSync(projectDir)
1497
+ } catch {
1498
+ // A deleted or not-yet-created cwd cannot collide with a real directory:
1499
+ // the resolved absolute path is still a stable local identity for it.
1500
+ }
1501
+ const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32)
1502
+ const xdg = deps.env['XDG_STATE_HOME']
1503
+ const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state')
1504
+ return path.join(base, 'notifai', 'setup-proofs', `${digest}.json`)
1505
+ }
1506
+
1507
+ function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
1508
+ const file = setupProofPath(deps)
1509
+ if (!existsSync(file)) return null
1510
+ try {
1511
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<SetupProofRecord>
1512
+ return typeof parsed.request_id === 'string' &&
1513
+ typeof parsed.device_id === 'string' &&
1514
+ (typeof parsed.project === 'string' || parsed.project === null) &&
1515
+ typeof parsed.started_at === 'string'
1516
+ ? (parsed as SetupProofRecord)
1517
+ : null
1518
+ } catch {
1519
+ // Corrupt local evidence is not readiness. A fresh proof replaces it.
1520
+ return null
1521
+ }
1522
+ }
1523
+
1524
+ function writeSetupProof(deps: CommandDeps, proof: SetupProofRecord): boolean {
1525
+ const file = setupProofPath(deps)
1526
+ try {
1527
+ mkdirSync(path.dirname(file), { recursive: true })
1528
+ writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 })
1529
+ return true
1530
+ } catch (err) {
1531
+ deps.io.err(
1532
+ `Could not save setup proof ${proof.request_id} at ${file}: ${String(err)}`,
1533
+ )
1534
+ return false
1535
+ }
1536
+ }
1537
+
1538
+ function observedCompanionReceipt(
1539
+ snapshot: EvidenceSnapshot,
1540
+ deviceId: string,
1541
+ ): { delivery: EvidenceSnapshot['deliveries'][number]; observedAt: string } | null {
1542
+ const delivery = snapshot.deliveries.find((candidate) => candidate.device_id === deviceId)
1543
+ const receipt = delivery?.events.find((event) => event.stage === 'companion_received')
1544
+ return delivery && receipt ? { delivery, observedAt: receipt.occurred_at } : null
1545
+ }
1546
+
1454
1547
  /**
1455
1548
  * The setup coordinator that observes each prerequisite and advances the ones
1456
1549
  * this build can perform.
@@ -1469,10 +1562,16 @@ export interface InitFlags {
1469
1562
  * this stays silent about what it did — the re-assessment that follows reports
1470
1563
  * the new state, and narrating both is how a setup log becomes unreadable.
1471
1564
  *
1472
- * Returns false when the attempt failed, which is different from declining:
1473
- * a decline leaves an optional gap open and is fine, a failure is not.
1565
+ * `pending` means the action is real but its evidence has not arrived yet;
1566
+ * `failed` means the action itself could not be performed.
1474
1567
  */
1475
- async function closeGap(deps: CommandDeps, state: ReadinessState, flags: InitFlags): Promise<boolean> {
1568
+ type GapCloseResult = 'closed' | 'pending' | 'failed'
1569
+
1570
+ async function closeGap(
1571
+ deps: CommandDeps,
1572
+ state: ReadinessState,
1573
+ flags: InitFlags,
1574
+ ): Promise<GapCloseResult> {
1476
1575
  if (state.id === 'project') {
1477
1576
  const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
1478
1577
  const existing = existsSync(configPath)
@@ -1481,7 +1580,7 @@ async function closeGap(deps: CommandDeps, state: ReadinessState, flags: InitFla
1481
1580
  existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
1482
1581
  mkdirSync(path.dirname(configPath), { recursive: true })
1483
1582
  writeFileSync(configPath, `${stringifyToml(existing)}\n`)
1484
- return true
1583
+ return 'closed'
1485
1584
  }
1486
1585
 
1487
1586
  if (state.id === 'hooks') {
@@ -1497,9 +1596,9 @@ async function closeGap(deps: CommandDeps, state: ReadinessState, flags: InitFla
1497
1596
  deps.io.err(
1498
1597
  `Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
1499
1598
  )
1500
- return false
1599
+ return 'failed'
1501
1600
  }
1502
- return hooksInstallCommand(deps, { harness }) === EXIT.ok
1601
+ return hooksInstallCommand(deps, { harness }) === EXIT.ok ? 'closed' : 'failed'
1503
1602
  }
1504
1603
 
1505
1604
  if (state.id === 'skill') {
@@ -1516,10 +1615,263 @@ async function closeGap(deps: CommandDeps, state: ReadinessState, flags: InitFla
1516
1615
  deps.io.err('Skill installation failed — run it manually with:')
1517
1616
  deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`)
1518
1617
  }
1519
- return code === 0
1618
+ return code === 0 ? 'closed' : 'failed'
1619
+ }
1620
+
1621
+ if (state.id === 'proof') return await runSetupProof(deps)
1622
+
1623
+ return 'failed'
1624
+ }
1625
+
1626
+ function deviceCanReceive(device: RoutableDevice): boolean {
1627
+ return (
1628
+ device.registration_healthy &&
1629
+ (device.permission_status === 'authorized' || device.permission_status === 'provisional')
1630
+ )
1631
+ }
1632
+
1633
+ function readyIosDevices(devices: readonly RoutableDevice[]): RoutableDevice[] {
1634
+ return devices.filter((device) => device.platform === 'ios' && deviceCanReceive(device))
1635
+ }
1636
+
1637
+ function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
1638
+ if (devices.length === 0) {
1639
+ return 'Waiting for the companion app to sign in and register…'
1640
+ }
1641
+ const denied = devices.find((device) => device.permission_status === 'denied')
1642
+ if (denied) return `Waiting for notifications to be allowed on ${denied.display_name}…`
1643
+ const undecided = devices.find((device) => device.permission_status === 'not_determined')
1644
+ if (undecided) return `Waiting for ${undecided.display_name} to allow the notification prompt…`
1645
+ return 'Waiting for a companion device to become ready…'
1646
+ }
1647
+
1648
+ /**
1649
+ * Observe the supported Device Installation path while the user finishes the
1650
+ * app-side work. There is deliberately no invented QR or download URL here:
1651
+ * this release exposes neither an App Store/TestFlight URL nor a device-pairing
1652
+ * endpoint, and a placeholder bridge is worse than an explicit boundary.
1653
+ */
1654
+ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Promise<GapCloseResult> {
1655
+ const remedy = state.remedy
1656
+ if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
1657
+
1658
+ await deps.io.note?.(
1659
+ `${state.detail}\n${remedy.summary}`,
1660
+ 'Finish setup on your companion device',
1661
+ )
1662
+ if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
1663
+ return 'pending'
1664
+ }
1665
+
1666
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1667
+ const authed = authedClient(deps, config)
1668
+ if (!authed) return 'failed'
1669
+ const now = deps.now ?? Date.now
1670
+ const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
1671
+ const deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
1672
+ const spinner = await deps.io.spinner?.('Waiting for a companion device…')
1673
+ let lastDevices: RoutableDevice[] = []
1674
+
1675
+ while (now() < deadline) {
1676
+ try {
1677
+ const response = await authed.client.listDevices()
1678
+ lastDevices = response.devices
1679
+ const ready = response.devices.find(deviceCanReceive)
1680
+ if (ready) {
1681
+ spinner?.stop(`${ready.display_name} is ready to receive`)
1682
+ return 'closed'
1683
+ }
1684
+ spinner?.message(deviceBridgeMessage(response.devices))
1685
+ } catch (err) {
1686
+ if (!(err instanceof NetworkError)) {
1687
+ spinner?.error('Could not check companion readiness')
1688
+ reportError(deps, err)
1689
+ return 'failed'
1690
+ }
1691
+ spinner?.message('Connection lost — still watching…')
1692
+ }
1693
+ await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
1694
+ }
1695
+
1696
+ spinner?.error('No companion device became ready')
1697
+ deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
1698
+ return 'pending'
1699
+ }
1700
+
1701
+ function setupProofDraft(
1702
+ config: CliConfig,
1703
+ device: RoutableDevice,
1704
+ ): ReturnType<typeof buildDraft> {
1705
+ const project = config.project.value
1706
+ return buildDraft(config, {
1707
+ title: 'NotifAI is ready',
1708
+ body:
1709
+ project === null
1710
+ ? 'This real notification completed setup verification.'
1711
+ : `This real notification completed setup verification for ${project}.`,
1712
+ event: 'setup_verified',
1713
+ kind: 'update',
1714
+ platform: 'ios',
1715
+ device: [device.device_id],
1716
+ sound: 'none',
1717
+ level: 'passive',
1718
+ collapseKey: 'notifai-setup-verification',
1719
+ })
1720
+ }
1721
+
1722
+ async function submitSetupProof(
1723
+ deps: CommandDeps,
1724
+ client: ApiClient,
1725
+ config: CliConfig,
1726
+ device: RoutableDevice,
1727
+ ): Promise<SubmissionReceipt | null> {
1728
+ const build = setupProofDraft(config, device)
1729
+ if (!build.ok) {
1730
+ deps.io.err(`Could not build the setup verification notification: ${build.error}`)
1731
+ return null
1732
+ }
1733
+ const capabilities = CAPABILITIES_V1.describe(build.platform)
1734
+ if (!capabilities) {
1735
+ deps.io.err(`No capability contract is available for ${build.platform}.`)
1736
+ return null
1737
+ }
1738
+ const validation = validateDraft(build.draft, capabilities)
1739
+ if (!validation.ok) {
1740
+ for (const issue of validation.errors) {
1741
+ deps.io.err(`Setup verification ${issue.path}: ${issue.message}`)
1742
+ }
1743
+ return null
1744
+ }
1745
+ try {
1746
+ return await client.submit(
1747
+ {
1748
+ idempotency_key: `init-${randomBytes(12).toString('base64url')}`,
1749
+ draft: build.draft,
1750
+ },
1751
+ config.wait_seconds.value,
1752
+ )
1753
+ } catch (err) {
1754
+ reportError(deps, err)
1755
+ return null
1756
+ }
1757
+ }
1758
+
1759
+ /**
1760
+ * Send or resume one real setup probe, then wait for a Companion Receipt.
1761
+ * Provider Acceptance is intentionally insufficient: it proves APNs accepted
1762
+ * the push, not that a companion process received it.
1763
+ */
1764
+ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
1765
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1766
+ const authed = authedClient(deps, config)
1767
+ if (!authed) return 'failed'
1768
+
1769
+ let devices: RoutableDevice[]
1770
+ try {
1771
+ devices = (await authed.client.listDevices()).devices
1772
+ } catch (err) {
1773
+ reportError(deps, err)
1774
+ return 'failed'
1775
+ }
1776
+ const candidates = readyIosDevices(devices)
1777
+ const existing = readSetupProof(deps)
1778
+ const target =
1779
+ candidates.find(
1780
+ (device) =>
1781
+ device.device_id === existing?.device_id && existing.project === config.project.value,
1782
+ ) ?? candidates[0]
1783
+ if (!target) {
1784
+ deps.io.err(
1785
+ 'Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts.',
1786
+ )
1787
+ return 'pending'
1788
+ }
1789
+
1790
+ let proof =
1791
+ existing?.device_id === target.device_id && existing.project === config.project.value
1792
+ ? existing
1793
+ : null
1794
+ if (proof === null) {
1795
+ const receipt = await submitSetupProof(deps, authed.client, config, target)
1796
+ if (receipt === null) return 'failed'
1797
+ if (receipt.overall === 'provider_rejected_all') {
1798
+ deps.io.err(formatReceipt(receipt))
1799
+ return 'failed'
1800
+ }
1801
+ proof = {
1802
+ request_id: receipt.request_id,
1803
+ device_id: target.device_id,
1804
+ project: config.project.value,
1805
+ started_at: new Date((deps.now ?? Date.now)()).toISOString(),
1806
+ }
1807
+ if (!writeSetupProof(deps, proof)) return 'failed'
1808
+ deps.io.out(`Verification notification sent to ${target.display_name} (${proof.request_id}).`)
1809
+ } else {
1810
+ deps.io.out(`Checking verification notification ${proof.request_id} again.`)
1811
+ }
1812
+
1813
+ const now = deps.now ?? Date.now
1814
+ const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
1815
+ const deadline = now() + PROOF_TIMEOUT_MS
1816
+ const spinner = deps.io.interactive === true
1817
+ ? await deps.io.spinner?.('Waiting for a Companion Receipt…')
1818
+ : null
1819
+ let lastError: unknown = null
1820
+ let replacedMissingProof = false
1821
+
1822
+ for (;;) {
1823
+ try {
1824
+ const snapshot = await authed.client.evidence(proof.request_id)
1825
+ const observed = observedCompanionReceipt(snapshot, proof.device_id)
1826
+ if (observed) {
1827
+ spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`)
1828
+ return 'closed'
1829
+ }
1830
+ lastError = null
1831
+ } catch (err) {
1832
+ lastError = err
1833
+ if (
1834
+ err instanceof ApiCallError &&
1835
+ err.code === 'not_found' &&
1836
+ !replacedMissingProof
1837
+ ) {
1838
+ const receipt = await submitSetupProof(deps, authed.client, config, target)
1839
+ if (receipt === null) return 'failed'
1840
+ if (receipt.overall === 'provider_rejected_all') {
1841
+ deps.io.err(formatReceipt(receipt))
1842
+ return 'failed'
1843
+ }
1844
+ proof = {
1845
+ request_id: receipt.request_id,
1846
+ device_id: target.device_id,
1847
+ project: config.project.value,
1848
+ started_at: new Date(now()).toISOString(),
1849
+ }
1850
+ if (!writeSetupProof(deps, proof)) return 'failed'
1851
+ replacedMissingProof = true
1852
+ lastError = null
1853
+ deps.io.out(`The saved proof had expired; sent replacement ${proof.request_id}.`)
1854
+ continue
1855
+ }
1856
+ if (!(err instanceof NetworkError)) {
1857
+ spinner?.error('Could not read Companion Receipt evidence')
1858
+ reportError(deps, err)
1859
+ return 'failed'
1860
+ }
1861
+ spinner?.message('Connection lost — still checking the same request…')
1862
+ }
1863
+
1864
+ if (now() >= deadline) break
1865
+ await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())))
1520
1866
  }
1521
1867
 
1522
- return false
1868
+ spinner?.error('Companion Receipt not observed yet')
1869
+ if (lastError instanceof NetworkError) deps.io.err(lastError.message)
1870
+ deps.io.err(
1871
+ `No Companion Receipt was observed for ${proof.request_id} within ${PROOF_TIMEOUT_MS / 1000}s. ` +
1872
+ 'That is not proof of non-receipt; re-run `notifai init` to check this same notification again.',
1873
+ )
1874
+ return 'pending'
1523
1875
  }
1524
1876
 
1525
1877
  /** Whether an optional gap should be closed, given flags and who is watching. */
@@ -1559,37 +1911,104 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
1559
1911
  export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
1560
1912
  await deps.io.intro?.('NotifAI setup')
1561
1913
 
1562
- if (SKILLS_SOURCE === '' && flags.skills === true) {
1563
- deps.io.err('The optional agent skill is not published yet; this build has no skill source configured.')
1564
- return EXIT.failed
1565
- }
1566
-
1567
1914
  let readiness = await assessReadiness(deps)
1568
1915
  let failed = false
1916
+ const attempted = new Set<string>()
1917
+
1918
+ // Re-assess after every successful action. This is how a browser approval or
1919
+ // companion registration can unlock the next state while the user is still
1920
+ // here, without copying the dependency graph into a second setup script.
1921
+ for (;;) {
1922
+ let advanced = false
1923
+ let stop = false
1924
+
1925
+ for (const state of readiness.states) {
1926
+ if (state.status === 'ready') continue
1927
+ if (state.status === 'unknown') {
1928
+ stop = true
1929
+ break
1930
+ }
1569
1931
 
1570
- // One pass is enough: closing a gap never opens an earlier one, and the
1571
- // states that remain are exactly those needing someone who is not the CLI.
1572
- for (const state of readiness.states) {
1573
- const remedy = state.remedy
1574
- if (remedy === undefined) continue
1575
-
1576
- // Its to launch, theirs to complete. Offering to start the sign-in is the
1577
- // single most useful thing this command does for a person, so it is worth
1578
- // the extra branch — but only when someone is actually watching, since an
1579
- // agent that reached a browser prompt would simply hang.
1580
- if (remedy.by === 'user-here' && remedy.interactive === true) {
1581
- if (state.status !== 'gap' || deps.io.interactive !== true) continue
1582
- if (!(await deps.io.confirm('Sign in now? (opens your browser)', true))) continue
1583
- if ((await loginCommand(deps, {})) !== EXIT.ok) failed = true
1584
- continue
1585
- }
1932
+ const remedy = state.remedy
1933
+ if (remedy === undefined || attempted.has(state.id)) {
1934
+ if (state.status === 'gap') stop = true
1935
+ if (stop) break
1936
+ continue
1937
+ }
1938
+
1939
+ if (state.status === 'optional-gap') {
1940
+ if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, flags))) continue
1941
+ attempted.add(state.id)
1942
+ const result = await closeGap(deps, state, flags)
1943
+ if (result === 'failed') failed = true
1944
+ if (result !== 'closed') {
1945
+ stop = true
1946
+ break
1947
+ }
1948
+ readiness = await assessReadiness(deps)
1949
+ advanced = true
1950
+ break
1951
+ }
1586
1952
 
1587
- if (remedy.by !== 'cli') continue
1588
- if (state.status === 'gap') {
1589
- if (!(await closeGap(deps, state, flags))) failed = true
1590
- } else if (state.status === 'optional-gap' && (await wantsOptional(deps, state, flags))) {
1591
- if (!(await closeGap(deps, state, flags))) failed = true
1953
+ if (remedy.by === 'cli') {
1954
+ attempted.add(state.id)
1955
+ const result = await closeGap(deps, state, flags)
1956
+ if (result === 'failed') failed = true
1957
+ if (result !== 'closed') {
1958
+ stop = true
1959
+ break
1960
+ }
1961
+ readiness = await assessReadiness(deps)
1962
+ advanced = true
1963
+ break
1964
+ }
1965
+
1966
+ // Its to launch, theirs to complete. Offering to start the sign-in is
1967
+ // useful only when someone is demonstrably watching; an agent never
1968
+ // reaches this prompt or opens a browser.
1969
+ if (
1970
+ remedy.by === 'user-here' &&
1971
+ remedy.interactive === true &&
1972
+ deps.io.interactive === true
1973
+ ) {
1974
+ attempted.add(state.id)
1975
+ if (!(await deps.io.confirm('Sign in now? (opens your browser)', true))) {
1976
+ stop = true
1977
+ break
1978
+ }
1979
+ if ((await loginCommand(deps, {})) !== EXIT.ok) {
1980
+ failed = true
1981
+ stop = true
1982
+ break
1983
+ }
1984
+ readiness = await assessReadiness(deps)
1985
+ advanced = true
1986
+ break
1987
+ }
1988
+
1989
+ if (
1990
+ state.id === 'devices' &&
1991
+ remedy.by === 'user-elsewhere' &&
1992
+ deps.io.interactive === true
1993
+ ) {
1994
+ attempted.add(state.id)
1995
+ const result = await waitForReadyDevice(deps, state)
1996
+ if (result === 'failed') failed = true
1997
+ if (result !== 'closed') {
1998
+ stop = true
1999
+ break
2000
+ }
2001
+ readiness = await assessReadiness(deps)
2002
+ advanced = true
2003
+ break
2004
+ }
2005
+
2006
+ // A human-only remedy is the first blocker for an unattended agent.
2007
+ stop = true
2008
+ break
1592
2009
  }
2010
+
2011
+ if (stop || !advanced) break
1593
2012
  }
1594
2013
 
1595
2014
  readiness = await assessReadiness(deps)
@@ -1616,7 +2035,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
1616
2035
  deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
1617
2036
  }
1618
2037
  await deps.io.outro?.('One step remains (above)')
1619
- return failed ? EXIT.failed : EXIT.ok
2038
+ // An agent must be able to branch on setup being blocked without parsing
2039
+ // prose. A present human may deliberately leave and resume later.
2040
+ return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
1620
2041
  }
1621
2042
 
1622
2043
  // ---------------------------------------------------------------------------
@@ -1675,6 +2096,8 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
1675
2096
  export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
1676
2097
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1677
2098
  const states: ReadinessState[] = []
2099
+ let accountClient: ApiClient | null = null
2100
+ let accountDevices: RoutableDevice[] | null = null
1678
2101
 
1679
2102
  const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
1680
2103
  const projectSlug = config.project.value
@@ -1784,9 +2207,11 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
1784
2207
  states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` })
1785
2208
  } else {
1786
2209
  const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`)
2210
+ accountClient = client
1787
2211
  try {
1788
2212
  const { devices } = await client.listDevices()
1789
- const ready = devices.filter((d) => d.registration_healthy)
2213
+ accountDevices = devices
2214
+ const ready = devices.filter(deviceCanReceive)
1790
2215
  states.push({
1791
2216
  id: 'auth',
1792
2217
  title: 'Account',
@@ -1812,14 +2237,16 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
1812
2237
  // permission prompt.
1813
2238
  detail:
1814
2239
  devices.length === 0
1815
- ? 'nothing registered yet'
2240
+ ? 'nothing registered yet; this release has no supported App Store/TestFlight URL for the CLI to open'
1816
2241
  : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
1817
2242
  remedy: {
1818
2243
  by: 'user-elsewhere',
1819
2244
  summary:
1820
2245
  devices.length === 0
1821
- ? 'install the companion app on your phone, sign in with the same account, and allow notifications'
1822
- : 'allow notifications for NotifAI in that device’s system settings',
2246
+ ? 'install NotifAI through a supported distribution you already have, open it, sign in with the same account, and allow notifications'
2247
+ : devices.some((d) => d.permission_status === 'denied')
2248
+ ? 'allow notifications for NotifAI in that device’s system settings'
2249
+ : 'open NotifAI on that device and allow its notification prompt',
1823
2250
  },
1824
2251
  },
1825
2252
  )
@@ -1843,33 +2270,117 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
1843
2270
 
1844
2271
  states.push(...hookStates(deps))
1845
2272
 
1846
- states.push(
1847
- SKILLS_SOURCE === ''
1848
- ? {
1849
- id: 'skill',
1850
- title: 'Agent guidance skill',
1851
- // Nothing outstanding, because nothing can be done: this build has
1852
- // no published skill source configured. Silence here would read as
1853
- // an omission to anyone who had seen it mentioned in the docs.
1854
- status: 'ready',
1855
- detail: 'not applicable — this build has no published skill source configured',
1856
- }
1857
- : {
1858
- id: 'skill',
1859
- title: 'Agent guidance skill',
1860
- status: 'optional-gap',
1861
- detail: 'not installed in this project',
1862
- remedy: {
1863
- by: 'cli',
1864
- summary: 'install the skill agents follow when deciding to notify',
1865
- command: 'notifai init --skills',
1866
- },
1867
- },
1868
- )
2273
+ states.push({
2274
+ id: 'skill',
2275
+ title: 'Agent guidance skill',
2276
+ status: 'optional-gap',
2277
+ detail: `not installed from ${SKILLS_SOURCE} in this project`,
2278
+ remedy: {
2279
+ by: 'cli',
2280
+ summary: 'install the skill agents follow when deciding to notify',
2281
+ command: 'notifai init --skills',
2282
+ },
2283
+ })
2284
+
2285
+ states.push(await setupProofState(deps, config, accountClient, accountDevices))
1869
2286
 
1870
2287
  return { states }
1871
2288
  }
1872
2289
 
2290
+ async function setupProofState(
2291
+ deps: CommandDeps,
2292
+ config: CliConfig,
2293
+ client: ApiClient | null,
2294
+ devices: RoutableDevice[] | null,
2295
+ ): Promise<ReadinessState> {
2296
+ if (client === null || devices === null) {
2297
+ return {
2298
+ id: 'proof',
2299
+ title: 'Delivery proof',
2300
+ status: 'unknown',
2301
+ detail: 'not checked — account and device readiness must be established first',
2302
+ }
2303
+ }
2304
+
2305
+ const ready = devices.filter(deviceCanReceive)
2306
+ if (ready.length === 0) {
2307
+ return {
2308
+ id: 'proof',
2309
+ title: 'Delivery proof',
2310
+ status: 'unknown',
2311
+ detail: 'not checked — no companion device is ready',
2312
+ }
2313
+ }
2314
+
2315
+ const ios = readyIosDevices(devices)
2316
+ if (ios.length === 0) {
2317
+ return {
2318
+ id: 'proof',
2319
+ title: 'Delivery proof',
2320
+ status: 'gap',
2321
+ detail:
2322
+ 'blocked — the current macOS notification path does not emit Companion Receipts, so this CLI cannot prove receipt on a macOS-only setup',
2323
+ remedy: {
2324
+ by: 'user-elsewhere',
2325
+ summary:
2326
+ 'pair a receipt-capable iPhone build; no supported macOS receipt bridge is available in this release',
2327
+ },
2328
+ }
2329
+ }
2330
+
2331
+ const proof = readSetupProof(deps)
2332
+ const target = proof === null ? null : ios.find((device) => device.device_id === proof.device_id)
2333
+ if (proof === null || proof.project !== config.project.value || target === undefined) {
2334
+ return {
2335
+ id: 'proof',
2336
+ title: 'Delivery proof',
2337
+ status: 'gap',
2338
+ detail: 'no Companion Receipt has proven this project on this machine yet',
2339
+ remedy: {
2340
+ by: 'cli',
2341
+ summary: 'send one real verification notification and wait for its Companion Receipt',
2342
+ command: 'notifai init',
2343
+ },
2344
+ }
2345
+ }
2346
+
2347
+ try {
2348
+ const snapshot = await client.evidence(proof.request_id)
2349
+ const observed = observedCompanionReceipt(snapshot, proof.device_id)
2350
+ if (observed) {
2351
+ return {
2352
+ id: 'proof',
2353
+ title: 'Delivery proof',
2354
+ status: 'ready',
2355
+ detail: `Companion Receipt observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
2356
+ }
2357
+ }
2358
+ return {
2359
+ id: 'proof',
2360
+ title: 'Delivery proof',
2361
+ status: 'gap',
2362
+ detail: `${proof.request_id} was sent, but its Companion Receipt is still unknown`,
2363
+ remedy: {
2364
+ by: 'cli',
2365
+ summary: 'check the same verification notification again',
2366
+ command: 'notifai init',
2367
+ },
2368
+ }
2369
+ } catch (err) {
2370
+ return {
2371
+ id: 'proof',
2372
+ title: 'Delivery proof',
2373
+ status: 'gap',
2374
+ detail: `could not read ${proof.request_id} evidence (${err instanceof ApiCallError ? err.code : String(err)})`,
2375
+ remedy: {
2376
+ by: 'cli',
2377
+ summary: 'retry the existing verification evidence check',
2378
+ command: 'notifai init',
2379
+ },
2380
+ }
2381
+ }
2382
+ }
2383
+
1873
2384
  export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
1874
2385
  const readiness = await assessReadiness(deps)
1875
2386
  const blocker = firstBlocker(readiness)